diff --git a/02_ruway/shuma/AGENTE.md b/02_ruway/shuma/AGENTE.md new file mode 100644 index 0000000..d6938f5 --- /dev/null +++ b/02_ruway/shuma/AGENTE.md @@ -0,0 +1,98 @@ +# AGENTE.md — la IA conversacional multi-agente de shuma + +Documenta el subsistema de **chat multi-agente** de shuma: el panel estilo apps +web de IA (sidebar de conversaciones + selector de agente + hilo con bloques +ricos), construido sobre `pluma-llm`. Es la evolución de la IA *atómica* de shuma +(`:?` / `:haz` / `:explica`, una sola vuelta sin memoria) a **agentes +configurables + conversaciones multi-turno persistidas + salida en streaming**. + +> Fuente autoritativa cuando difiera con comentarios sueltos del código. Verifica +> nombres con `grep` antes de asumir: este doc envejece. + +## Mapa de crates + +Cuatro capas, agnósticas de UI hacia abajo (Regla 2 del repo): + +| Crate | Rol | +|---|---| +| `sandbox/shuma-agente` | **Núcleo** sync/puro: `Agente`, `Conversacion`/`Turno`/`BloqueSalida`, `motor` (arma el `ChatRequest`, interpreta la salida en bloques), `Almacen` sled. Sin red. | +| `sandbox/shuma-agente-host` | **Host**: `responder` / `responder_streaming` — corre `pluma-llm` (resuelve backend, bloqueante en un thread) y devuelve bloques + tokens. | +| `sandbox/shuma-module-agente` | **UI** (módulo shuma): `State`/`Msg`/`update`/`view`. Panel de chat + editor de agentes. | +| `shuma-shell-llimphi` | **Chasis**: monta el panel como diente `Tool::Agente`, abre el `Almacen`, corre el host en threads, rutea teclado y persiste. | +| `00_unanchay/pluma/pluma-llm-claude-cli` | **Backend** que usa el binario `claude` (suscripción) — ver §Auth. | + +## Modelo de datos + +- **`Agente`** — identidad + `backend` propio (`wawa_config::LlmSettings`: proveedor, + modelo, API key, endpoint) + `system_prompt` (persona) + `Capacidades` (si puede + proponer acciones de control atipay, y de qué superficies) + temperatura/max_tokens. + `backend` vacío = hereda el `[ai.llm]` global del SO. +- **`Conversacion`** — hilo multi-turno: `Vec`, título auto-derivado del primer + mensaje, timestamps. Ordenadas por `actualizada` (recientes primero). +- **`Turno`** — `rol` (Usuario/Asistente), `bloques`, `uso: Option` (tokens). +- **`BloqueSalida`** — la *gama de outputs*: `Texto` · `Codigo{lenguaje,codigo}` · + `Accion(AccionPropuesta)` (acción de control validada por atipay) · `Error`. + +El texto crudo del modelo se interpreta a bloques en `motor::interpretar_respuesta`: +cercos ```` ```accion ```` → acción atipay (validada, **nunca auto-ejecutada**), otros +cercos → código, el resto → texto. + +## Persistencia + +`Almacen` (sled) en `/agente.sled` (`persist::agente_db_path`). Dos árboles: +`agentes` y `conversaciones`, JSON por clave=id. `sembrar_defaults` crea «Asistente» +y «Control» la primera vez (idempotente). El chasis persiste tras cada cambio. + +## Patrón intent (trabajo async sin colgar el bucle Elm) + +El módulo **no toca la red**. Deja intents que el chasis cumple en threads: + +- `take_request()` → el chasis corre `shuma_agente_host::responder_streaming` y + devuelve `Msg::Token` (por fragmento) + `Msg::Respuesta` (final). +- `take_ejecucion()` → acción aprobada; el chasis la pone en el input del shell + activo (`InsertAtCursor` — revisar y Enter, nunca auto-corre). +- `take_persist_agente()` / `take_borrar_agente()` → alta/edición/borrado de agente + → el chasis escribe al `Almacen` y re-provee con `set_agentes`. + +El reloj y el alto del viewport los inyecta el chasis (`fijar_reloj`, `fijar_vista_alto`): +el `update` es puro y no lee el reloj. + +## Streaming + +`ChatClient::stream(req, on_delta)` (en `pluma-llm-core`) tiene un **default no +incremental** (corre `complete` y emite todo al final) — así los backends sin +streaming no cambian. `pluma-llm-claude-cli` lo sobreescribe: corre el CLI con +`--output-format stream-json --verbose --include-partial-messages`, lee el NDJSON y +emite cada `content_block_delta.text`. El chasis despacha `Msg::Token` por delta vía +`Handle::dispatch`; el módulo acumula en `parcial` y pinta una burbuja viva con +cursor `▌`, reemplazada por los bloques al llegar `Respuesta`. + +## Autenticación — usar la suscripción sin API key + +Tres caminos, de menos a más acoplado a Anthropic: + +1. **API key por agente** (`anthropic`/`gemini`/`deepseek`/`cohere`/`ollama`): cada + agente lleva su clave. NO requiere ser app oficial. Pago por token. +2. **Backend `claude-cli`** (default de los agentes sembrados): maneja el binario + `claude` (Claude Code) como subproceso. **Claude Code hace el OAuth** (incluida la + suscripción Pro/Max); la app no toca ni reusa el token. Es el camino **legítimo** + para usar una suscripción desde software propio, sin pagar por token aparte. + Requiere `claude` instalado y `claude login`. Override del binario por + `$CLAUDE_CLI_BIN` o el campo `endpoint` del agente. +3. **OAuth crudo de suscripción** (`sk-ant-oat01-…`): **PROHIBIDO** reusarlo en apps + de terceros (viola los ToS de Anthropic, enforcement desde feb-2026). No se usa. + +## Cómo se usa + +Abrir shuma → diente «Agente» (globo de diálogo) en el rail derecho. Elegir agente, +escribir, Enter envía. «+ agente» / «editar» abren el formulario (nombre, modelo, +persona, backend ciclable, toggle control; Tab cicla campos, Escape cancela). Las +acciones de control salen como tarjetas con aprobar/rechazar. + +## Pendientes / futuro + +- El panel entra en el slot angosto del rail (sidebar 150px, redimensionable) — quizá + convenga un slot más ancho o una vista compacta sin sidebar. +- Ejecutar acciones aprobadas directo (hoy van al input del shell, por la doctrina + «nunca auto-ejecutar»). +- Visión (imágenes): `pluma-llm-core` ya soporta `ChatImage`; falta cablearlo en la UI. diff --git a/02_ruway/shuma/COLA-SHUMA.md b/02_ruway/shuma/COLA-SHUMA.md new file mode 100644 index 0000000..e45b33a --- /dev/null +++ b/02_ruway/shuma/COLA-SHUMA.md @@ -0,0 +1,603 @@ +# Cola — shuma (input, pestañas, fugaces, consola, multiproceso, historial) + +**La cola ÚNICA de shuma.** Nació el 22-jul-2026 recogiendo pedidos **en vivo** sobre las +pestañas, y el 22-jul absorbió los pendientes de shuma que otro agente había dejado en la +raíz (`COLA-VERIFICACION.md` §11/§14) — sección **L** al final. Está pensada para retomarse +**en frío**, sin la conversación que la originó. Si aparece un pendiente de shuma en otra +lista, su lugar es acá. + +Convención: `[ ]` pendiente · `[~]` codeado, falta aprobarlo **en metal** · `[x]` aprobado +en metal. + +## Cómo probar cualquier cosa de acá + +```sh +cargo build -p pata-llimphi --release +sudo install -m755 target/release/pata-llimphi /usr/local/bin/pata-llimphi +pkill -x pata-llimphi # mirada la respawnea desde /usr/local/bin +``` + +Diagnóstico sin poder inyectar env (a pata la respawnea el compositor): + +```sh +touch /tmp/pata-diag && pkill -x pata-llimphi # el centinela se lee AL ARRANCAR +cat /tmp/pata-diag-fugaces.txt # fade de los iconos, una línea por cambio +cat /tmp/shuma-consola-dump.txt # buffer de la consola + colores + secciones (marca 'cod' = línea H4) +touch /tmp/shuma-diag # registro de pestañas (no requiere reiniciar) +cat /tmp/shuma-tab-diag.txt # TabNew ↔ muerte de run (código de salida) — bug L14 +``` + +Certificación sin mirar imágenes (Regla 8 del repo): + +```sh +cargo run -p shuma-shell-llimphi --example pestanas_shot --release # diffea píxeles +cargo test -p shuma-module-shell -p shuma-shell-llimphi -p pata-llimphi -p llimphi-widget-text-input +``` + +## Dónde vive cada cosa + +| pieza | archivo | +|---|---| +| Pestañas (modelo, avisos) | `shuma-shell-llimphi/src/workspace.rs` | +| Pestañas (vista, chip, cava) | `shuma-shell-llimphi/src/view/session.rs` | +| Campanas + título OSC | `sandbox/shuma-module-shell/src/campana.rs` | +| Caudal del cava | `sandbox/shuma-module-shell/src/pulso.rs` | +| Gate del modo consola | `sandbox/shuma-module-shell/src/update/mod.rs` | +| Secciones/tablas del output | `sandbox/shuma-module-shell/src/sections.rs` | +| Iconos fugaces + fade | `pata-llimphi/src/shuma.rs` | +| Input compartido | `llimphi/widgets/text-input/{lib,area,rico}.rs` | + +## Estado + +Hechos (falta metal): A1, B1, B2, C1/H1, C2, E1, G1, H2, H3, H4, J1, K1. +Pendientes A–K: D1, D2, F1, G2, G3, H5, H6, H7, H8, I1, J2, J3, J4, J5. +Sección L (traída de COLA-VERIFICACION, otro agente): pendientes L1–L13 + limpieza. **L1 +(Enter con el cajón plegado pierde lo escrito) DESTRUYE trabajo del usuario — prioridad.** + +## A. Notificaciones de pestaña → willay + +- [x] **A1 — Dirección invertida (corrección del usuario). HECHO (falta metal).** + El parpadeo de la pestaña se queda; **además** willay ahora despliega la notificación + de un tab cuando ese tab NO está visible. Puente en `shuma-shell-llimphi/src/update.rs` + (`drain_shell_instances`, en el mismo loop de `refrescar_aviso`): cada panel de cada + pestaña drena `sh.tomar_notificaciones()` (OSC 9/777/99); si la pestaña no es la visible + (`!(idx==activa && t==active_tab)`), cada aviso se publica como + `willay_core::Evento{Clase::Notificacion}` vía `willay_emit::emitir_silencioso` (no-opea + sin daemon). El `origen` lleva «shuma · » para saber de cuál vino. + Las de la pestaña visible se descartan (ya se ven). De paso tapa una fuga: nadie drenaba + `notificaciones`, se acumulaban sin fin. `refrescar_aviso` lee `campanadas()` (contador + aparte), así que drenar los avisos no lo afecta. **En metal: confirmar que willay + despliega el aviso de la pestaña de fondo.** + +## B. Caret del input + +- [~] **B1 — El caret se ocultaba mientras se escribe. ARREGLADO (`d34b1c9d3`).** Root cause: + `marcar_actividad` se llamaba SÓLO desde el camino del mouse + (`update/mod.rs`, handler de `TextAreaEvent`); tipear ponía `input_edit_at_ms` pero + nunca tocaba el reloj del widget → el caret creía que la última actividad fue el + último click, entraba en parpadeo y quedaba apagado medio segundo por vez. Por eso + «hice click afuera y adentro y ya se ve». +- [~] **B2 — La estela exagerada / a sitios random. ARREGLADO (`d34b1c9d3`).** Lo pedido: estela cuando el + caret **va de un lado a otro** (un salto deliberado), no en el tipeo normal. + Sospecha principal: con ajuste blando, cada re-wrap de palabra mueve el caret al + renglón siguiente y `cx` salta al principio → la estela dibuja un cuadrilátero + diagonal a lo ancho de la caja. Plan: (1) umbral de salto — por debajo de ~1 carácter + se clava, sin estela; (2) cambio de renglón = clavar, no diagonal. + +## C. Iconos fugaces (fantasmas) de la barra + +- [~] **C1 — Demoraban en desaparecer. RESUELTO CON MEDICIÓN.** «Según la longitud de este texto deberían estar + invisibles desde la palabra *notificaciones*». La REGLA está bien: sonda numérica + (`avance_en_renglon` + `fade_por_texto`) da alpha 0 a partir de ~120 caracteres con + una caja de 1100 px, que es justo donde cae esa palabra. Entonces lo que falla es la + ENTRADA: sospecha fuerte de que `avance` llega en 0 porque `data.shuma_full` es `None` + en el camino que corre (`headline_view` cae a `state.inner`, que está vacío). Es el + mismo gotcha documentado en [[marquesina-iconos-ps1]], que ya mordió una vez. + **Diag puesto**: a pata la respawnea el compositor, así que no va por env ni por + stderr — va por el centinela `/tmp/pata-diag` (que ya existía, `layer::diag_on`) y + escribe a **archivo**: + + ```sh + touch /tmp/pata-diag && rm -f /tmp/pata-diag-fugaces.txt && pkill -x pata-llimphi + # tipear una línea larga en la barra, y después: + cat /tmp/pata-diag-fugaces.txt + ``` + + Una línea por cambio de `avance`. Si `avance` se queda en 0 mientras se tipea, el + estado que lee `headline_view` no es el que recibe las teclas. Para apagarlo: + `rm /tmp/pata-diag` + respawn. +- [~] **C2 — El fondo opaco de los iconos aparecía de más. ARREGLADO (`d34b1c9d3`).** Debe verse **sólo** si se + cumplen LAS DOS: (a) hay hover, y (b) los iconos están escondidos porque el input está + lleno. Si falta cualquiera de las dos → fondo transparente. + Hoy: `color_respaldo = bg_panel_alt.with_alpha(0.92 * visibilidad)` con + `visibilidad = base.max(revelar_alpha)` (`shuma.rs`) — el respaldo se enciende con + cualquiera de las dos, que es exactamente lo contrario de lo pedido. + +## D. Barra de mando + +- [ ] **D1 — El pwd se esconde detrás del top 0 de la pantalla** y no se lee. La + `etiqueta_flotante` (pwd/git sobre el borde superior del input) se sale de la + superficie cuando la barra está pegada al borde de arriba. +- [ ] **D2 — Falta la info de git** que ohmyzsh acostumbró (rama + estado sucio/limpio, + ahead/behind). Hay un `dock_git_branch` en `app_view.rs` que sólo lee `.git/HEAD`. + +## E. Pestañas + +- [~] **E1 — «Tras reiniciar pata no veo títulos en los tabs». RESUELTO (bug propio).** + El deploy estaba bien (binario 15:52, commit 15:43). El bug era mío: apliqué la + atenuación por idle **al color del texto**, lerpeándolo hacia el fondo. Una pestaña + quieta 3 minutos quedaba con el rótulo al 45% del camino desde `bg_panel_alt` — + ilegible. La atenuación es para las señales de vida (LED + hilo de cava); el título va + siempre a color pleno. Falta confirmarlo en metal. + +## F. Paneles + +- [x] **F1 — Que lo que sube no vuelva a bajar (estético). HECHO 23-jul.** Las cosas + **efímeras** (spinner, «pensando…», una línea de progreso, un aviso que se va) + empujaban el contenido hacia arriba y al desaparecer lo dejaban caer: la vista + temblaba. Pedido textual: *«que las cosas efímeras que hagan subir las cosas no + vuelvan a bajar, sino que si siguen apareciendo cosas y suben, siguen subiendo»*. + + Implementado como **marca de agua alta monótona** (`State.content_hwm: Arc>`, + espejo de `out_overflow`). Estando pegado al fondo (`scroll_px <= 0.5`), `hwm_gap` + (en `view/surface_view.rs`) sube el HWM al `content_h` vivo y, si el HWM lo supera, + la `view` empuja un **espaciador vacío** (`Item::chrome_perezoso`) al FONDO de los + items por la diferencia — el widget clampa el `scroll_y` a ese alto reservado, así la + vista no rebota y **lo efímero nuevo cae al principio del hueco**. Scrolled-up no + reserva (el anclaje por `surf_scroll_anchor` ya estabiliza). Reset del HWM + (`State::reset_content_hwm`, `pub`) en los tres cortes naturales: `clear_output` + (types.rs), arranque de comando nuevo (`run_submitted`), y **cambio de pestaña** + (`Msg::TabSwitch` en el host, re-baseline al entrar). Test `hwm_reserva_hueco_solo_ + pinned_y_resetea`. **Falta metal**: verificar el no-rebote en vivo (consola de claude + «pensando» + avisos que se van). + + **F1.b — el hueco se volvió el bug (reporte del 25-jul, ARREGLADO).** Textual: + *«aparece un hueco de toda una pantalla en el scroll más bajo; si subo un punto, ese + hueco lo salta de golpe y brinca más de una página; luego de un rato algo cambia y se + acomoda»*. Los tres síntomas son la misma cadena, y los tres los producía `hwm_gap`: + · el **hueco** era el espaciador, que no tenía tope — si el contenido encogía mucho + (una TUI que termina, el scrollback recortado, un re-wrap por zoom/ancho), el HWM + sostenía una pantalla entera de vacío; + · el **brinco** era que scrolled-up devolvía `0`: al primer paso de rueda el + espaciador se evaporaba, el contenido se acortaba de golpe **bajo el dedo** y la + vista saltaba todo el hueco; + · el **«se acomoda solo»** era el reset del HWM al arrancar el comando siguiente. + Fix: (1) tope de `GAP_MAX_FILAS = 6` renglones (escala con el zoom vía `row_h`), + (2) si el contenido encogió MÁS que el tope se re-basa el HWM — eso es un cambio + estructural, no un efímero, (3) scrolled-up devuelve el hueco **congelado** + (`State.content_gap`), no `0`. Dos tests nuevos: `el_hueco_no_puede_ser_una_pantalla` + y `scrollear_no_evapora_el_hueco_reservado`. **Falta metal.** + +## G. Copiar y pegar (22-jul, «estoy sin copypaste acá») + +- [~] **G1 — `Ctrl+Shift+C/V/X` no funcionaban dentro de claude. ARREGLADO.** + Root cause: el gate del **modo consola** (`update/mod.rs`) manda al PTY *todo* lo + que lleva Ctrl, porque claude usa Ctrl/Alt para sus menús. Eso se llevaba puesto el + portapapeles: el handler de `Ctrl+Shift+C` estaba intacto unas líneas más abajo y + **no se alcanzaba nunca**. Ahora `es_portapapeles()` los deja en shuma, hermano de + `es_edicion_de_linea()`. `Ctrl+C` pelado sigue mandando SIGINT — la distinción es el + Shift, como en cualquier terminal. `Ctrl+Shift+X` (cortar) es nuevo. Falta metal. +- [~] **G2 — Selección con mouse en los paneles. HECHO (23-jul).** No había + `pane_body`: los dos paneles de output son `output_pane_surface` (sesión) y + `consola_surface_claude` (claude). La consola pasaba `SelectionConfig` VACÍO — + o sea el panel más mirado no copiaba nada. Ahora publica su `surf_layout` y + cablea drag + doble/triple-click igual que la sesión. Falta metal. +- [~] **G3 — Menú contextual en los paneles. HECHO (23-jul).** Click derecho abre + el `surf_menu` en ambas superficies: Copiar · **Pegar** (nuevo, índice 1) · + Copiar todo · Seleccionar todo. `apply_surf_menu_pick` renumerado. Falta metal. +- [~] **G4 — Copy-mode + cuasi-clipboard PRIMARY. HECHO (23-jul), la tanda del + pedido «copiar como editor del poderoso, aunque readonly».** Cuatro piezas: + 1. **PRIMARY**: `clipboard::{set_primary,get_primary}` — buffer interno del + proceso, espejado best-effort a la selección PRIMARY del sistema (arboard + `SetExtLinux`/`GetExtLinux`). El copy-on-select del output ahora llena el + PRIMARY (no el portapapeles principal); el **botón medio** lo pega + (`Msg::PrimaryPaste`: al PTY si hay consola viva, al input si no). + 2. **Caret readonly movible**: `select.rs` gana `Motion` + `move_caret` + + `caret_rect` + `caret_reveal_scroll`; `SelectionConfig.caret` pinta una barra + sólida SIN parpadeo (el `head` es el caret; `extend` mantiene `anchor`). + 3. **Copy-mode** (`update/copymode.rs`): `Ctrl+Shift+Espacio` entra; flechas+hjkl, + `Ctrl+←/→`+`w`/`b` palabra, `Home`/`End`+`0`/`$`, `PgUp`/`Dn`, `g`/`G`; + `Shift` o visual (`v`/Espacio) extiende y copia sola al PRIMARY; `y`/`Enter` + copia al portapapeles principal y sale; `Esc`/`q` sale. Gana sobre el PTY. + Auto-scroll mantiene el caret a la vista. + 4. **I-beam**: `Cursor::Text` sobre ambos paneles (la sesión ya lo tenía; la + consola no). + Tests: 6 en el widget (motions/caret), 2 en el shell (copy-mode e2e). **Falta + metal**: probar el gesto real, y confirmar si mirada expone `zwp_primary_selection` + para que el botón medio cruce a Brave (si no, cae limpio al buffer interno). + +## H. Tanda del 22-jul tarde + +- [~] **H1 — Los fugaces cedían al 78% de la fila.** Medido con el diag en la barra real: + fila de 164 columnas, franja de 9 → opacidad PLENA hasta el carácter 128, invisibles + recién cerca del 147. El umbral era puramente geométrico («escondete cuando el texto + esté a 15 caracteres»), y contra una fila larga eso es una eternidad. Ahora es + **proporcional a la fila** (ceden desde ~55%, fuera al 75%), con el ancho real de la + franja como PISO. El respaldo, además, va a lo ancho de la franja y no ceñido a los + glifos — un parche del tamaño justo se leía como «una cosa botada encima». +- [~] **H2 — «Ya no veo animaciones del caret».** Me pasé de mano: al cortar en seco los + movimientos chicos maté el rastro justo en el gesto más frecuente, que es escribir. + Ahora el tau escala **de forma continua** con el tamaño del salto (`tau_rapido` 14 ms + para una tecla, `tau_ms` 55 ms para un viaje): tipear deja un destello de ~3 cuadros, + un click lejano deja la estela entera. El cambio de renglón sigue clavado (era el + «sitios random»). +- [~] **H3 — Home/End por renglón visual.** El motor los movía por línea **lógica**, que + en texto envuelto es el párrafo entero: con 3 renglones de ajuste blando se portaban + como Ctrl+Home/Ctrl+End. Ahora van renglón a renglón, como ↑/↓ (que ya tenían ese + trato). `Ctrl+Home`/`Ctrl+End` siguen siendo el documento. Un End sobre un corte + blando para ANTES del espacio del corte. +- [x] **H4 — Colores de fondo en los bloques de código. HECHO (falta metal).** + Un buffer con más bloques reveló DOS presentaciones, no una: bloques con resaltado + usan **Monokai** (`#f8f8f2` default, `#75715e` comentario, `#a6e22e`/`#66d9ef`/`#be84ff`/… + tokens) y los bloques/spans sin lenguaje usan el cian plano **`#11a8cd`**. Ninguno de + esos aparece en la prosa (`#d6e8e8`), en el inline lavanda (`#b1b9f9`), en el gris + `#999999`, el blanco `#ffffff` ni el verde de viñeta `#4eba65`. Detector en + `shuma-module-shell/src/codigo.rs`: `linea_es_codigo(runs)` — voto por ancho, código vs + prosa, con tolerancia 6 (los pares más cercanos `#f8f8f2`/`#ffffff` y `#e6db74`/`#dcc878` + distan más). Wire: los dos `line_style` de `surface_view.rs` ponen `bg = theme.sunken()@96` + en la línea de código — reusa el mismo canal `LineStyle.bg` que el rojo de stderr. 9 tests + del detector, incluidas las colisiones y que el output decorado de `ls` no vota como + código. **En metal: tunear el alpha (96) y confirmar que la voz IA sintética (accent) no + cae por casualidad dentro de la paleta.** Pedido textual: «es parte de tu personalidad». +- [ ] **H5 — Tablas gráficas en vez de ASCII.** NO fue soñado: existe + `shuma-module-shell/examples/desplanizador.rs` — «el stream plano de la terminal se + *desplaniza* en estructura consultable sin que el comando coopere», con `docker ps` + como tabla ordenable, `git status` por grupo y `cargo` con secciones plegables. Falta + llevar eso a las tablas ASCII que imprime el asistente. +- [ ] **H6 — Los números de los paneles se resetean.** Sospecha del usuario: poda por + memoria. Su propuesta (mejor que arreglar el contador): **volar los paneles «no tan + legibles»** al podar, de modo que en el scroll viejo quede la secuencia de diálogo + asistente↔humano y no el ruido intermedio. +- [ ] **H7 — El texto enviado no se limpia del input.** El mensaje del usuario llegó con + el anterior pegado adelante. Sospechar del camino consola (el CR pendiente / la + sugerencia `➜` reinyectada). +- [ ] **H8 — `Ctrl+R` (buscador de historial) no dice cómo salir.** El usuario lo abrió + sin querer y quedó atrapado. Esc debería cerrarlo, y el overlay decirlo. + +## I. Opciones clickables (idea del usuario, 22-jul) + +- [ ] **I1 — Que las opciones que presenta el asistente sean clickables.** Disparador: + al usuario le apareció el «How is Claude doing?» dentro del drawer y **no tenía cómo + contestarlo**. Su idea, textual: «que todas las opciones que presentas sean clickables, + incluidas las de decisiones… tal vez es complicado reconocerlas». + + **No es de cero — hay dos caminos y las dos piezas ya existen:** + + 1. **Mouse nativo al PTY.** `shuma-module-shell/src/mouse_xterm.rs` ya codifica clicks + y rueda en xterm mouse protocol (Default/SGR/UTF-8), y el caller consulta + `screen.mouse_protocol_mode()` para saber si el programa lo pidió. Con un TUI que + habilita el mouse, **esto ya debería funcionar**. Claude Code (Ink) NO lo habilita, + y por eso el click no hace nada. Verificar primero si el path está cableado en el + panel del drawer: si lo está, arreglamos gratis a htop/vim/lazygit y compañía. + 2. **Declaración, no reconocimiento.** Para las opciones del asistente el camino bueno + NO es adivinar del pixel: es que **el asistente las declare**, exactamente como ya + hace con la marca `➜` de respuesta sugerida (Regla 9 de CLAUDE.md), que shuma + levanta de la pantalla, borra del panel y ofrece como fantasma en la barra. Ese + patrón ya está probado punta a punta. Generalizarlo a una marca de opciones + (`◈ 1) …` o similar) y pintarlas como botones es una extensión del mismo mecanismo, + no un detector nuevo. + + Precedente de detección por contenido, si hiciera falta igual: `TuiSession:: + menu_modal_vivo()` (types.rs) ya reconoce los menús modales de claude por su texto + («Enter to confirm», «Esc to cancel», «Resume session (N of M)», «Space to preview»). + El salto es devolver las OPCIONES con su fila de pantalla en vez de un `bool`, y que + el click mande ↑/↓ + Enter (o el número). + + Lo del «How is Claude doing?» es además el caso más simple: es un menú de opciones + numeradas; con el camino (2) contestarlo sería un click. + +## J. Multiprocesos y multimonitor (wishlist, 22-jul) + +Es **arquitectura**, no una tanda de arreglos: cambia quién es dueño de qué. Va +ordenado de lo que se puede hacer ya a lo que necesita diseño. + +- [~] **J1 — La `×` de cerrar en cada pestaña. HECHA.** Lo más chico y lo primero. Hoy se + cierra con click-medio o por el menú contextual (`Msg::TabClose(i)` ya existe); falta + el botón. `llimphi-widget-tabs` ya trae `on_close` — mirar si conviene adoptar el + widget compartido en vez de seguir con el chip propio, ahora que el chip ganó ancho + flexible, LED, cava y avisos (que el widget no tiene). + +- [ ] **J2 — Un shuma por monitor, independientes.** Modelo pedido, textual: *«los + monitores son independientes a nivel de input y de cuál tab está activo, pero todos + podrán ver la misma lista de tabs»*. + + O sea: **una sola lista de pestañas, N vistas con foco propio**. Hoy `Workspace` tiene + UN `active_tab`, así que la pestaña activa es global — hay que partir eso en «el + conjunto de pestañas» (compartido) y «qué mira este monitor» (por salida). Es el mismo + patrón de pertenencia que mirada y pata ya resolvieron para escritorios↔monitores + (ver [[workspace-monitor-pertenencia]] y [[pata-multimonitor-barras-por-output]]), + así que hay precedente y vocabulario en el repo. + + Consecuencia importante: **el input también se parte**. Cada monitor tiene su propio + caret, su propia selección y su propio foco de teclado. Hoy el árbitro de foco del + input es único (ver [[input-shuma-superpoderes-tanda]]). + +- [ ] **J3 — Modo launcher: distinguir lo que abre ventana de lo que no.** Al ejecutar + algo desde el escritorio, reconocer si la aplicación **abre una ventana**; si la abre: + no mostrar el drawer (o esconderlo), mantener el panel plegado, y **no bloquear**. + + Fuentes de verdad, de la más firme a la más floja: + 1. **La DB prefabricada**: el `.desktop` ya lo dice. `Terminal=false` = app gráfica; + `Terminal=true` = quiere una terminal. pata ya tiene registro de apps + (`AppRegistry`, `BarData.apps`) y shuma ya consume `LaunchableApp`. **Ésta cubre + casi todo el caso real y no es heurística: es declaración del propio programa.** + 2. **Observación**: mirada sabe qué ventanas aparecen. Si a los N ms de lanzar apareció + un toplevel de ese pid, era gráfica. Confirma o corrige a (1) sin adivinar. + 3. Heurística por nombre — último recurso, sólo para lo que no está en (1) ni (2). + +- [ ] **J4 — ¿Pide stdin? ¿Se puede tantear?** Pregunta del usuario. **Sí, y sin + heurística:** un proceso que espera stdin está bloqueado leyendo su fd 0. Se puede + mirar `/proc//wchan` (suele decir `wait_woken`/`pipe_read`) o, más directo, + `/proc//syscall`, cuyo primer campo es el número de syscall: `0` = `read`, y el + segundo argumento es el fd. **read sobre fd 0 = está esperando que le escribas.** Es + observación del kernel, no adivinanza, y es exactamente la señal que hace falta para + decidir si el drawer tiene que aparecer. + +- [ ] **J5 — Que abra pestaña nueva automáticamente** cuando: la app sí bloquea, o se + ejecuta desde un escritorio, o se abre otro monitor. Depende de J2 (la lista compartida + con foco por monitor) y de J3/J4 (saber si bloquea). + +### Nota sobre adoptar `llimphi-widget-tabs` (decisión, 22-jul) + +Revisado el widget compartido para migrar el chip de shuma. **No encaja tal cual, y el +motivo es informativo:** `tabs_view` es un compuesto **tira + contenido** (pinta el +área del tab activo debajo). shuma no puede usar eso — su contenido es el árbol de +tiling con paneles flotantes, que administra ella. Además la tira de shuma comparte fila +con los controles de tiling, y el chip tiene click-medio y menú contextual que el widget +no expone. + +**Camino correcto (pendiente):** extraer del widget un `tab_strip_view` (sólo la tira) y +subirle lo que shuma inventó y sirve a todos: ancho **flexible** (hoy el widget usa ancho +fijo + scroll por overflow), y un `TabAdorno` opcional por pestaña (LED de estado, hilo +de intensidad, tinte de aviso, atenuación por idle). `tabs_view` pasaría a ser +`tab_strip_view` + contenido, y shuma usaría la tira. Así lo estrenan pluma, nahual y +cosmos, que es la regla del repo. Es un refactor aditivo (los defaults dejan el widget +igual), pero toca a otras apps y merece su propia tanda, no la cola de una sesión larga. + +## K. Atajos de teclado de pestañas + +- [~] **K2 — Atajos CONFIGURABLES en wawa-panel (pedido 24-jul). HECHO (falta metal).** + El panel Atajos → sección «Terminal (shuma)» ganó una **tabla editable + `[combinación, acción]`** del perfil de shuma activo (antes sólo conmutaba el perfil), + con el mismo patrón que la tabla de teclas de mirada. Respaldo directo en + `~/.config/shuma/shortcuts.ron`. `wawa-panel-llimphi/src/shuma_shortcuts.rs` ganó un + `Action` tipado (espejo de `ShortcutAction`, con `Display`/`FromStr`) + `load_binds`/ + `set_binds`, **fail-safe**: si el RON trae algo que el espejo no entiende, no lo pisa + (aborta y avisa). `load()`/`set_active()` siguen opacos (robustos para conmutar). + Acciones válidas en la celda: `NewTab · CloseTab · NextTab · PrevTab · GotoTab(N) · + SplitH · SplitV · ClosePane · CycleNext · CyclePrev · FloatToggle · FloatNew`. 4 tests + de round-trip (`cargo test -p wawa-panel-llimphi shuma_shortcuts`). **CAVEAT de convivencia:** + shuma re-siembra binds de fábrica faltantes al cargar (`merge_from`), así que *borrar* + un bind de un preset no persiste (rebindar y agregar sí); duplicá el perfil para libertad + total. **En metal**: editar una tecla en el panel y confirmar que shuma la respeta al reabrir. + + **Ampliado (commit 92c01ad89): CRUD de perfiles + prefijo.** La sección ganó + **Duplicar / Renombrar / Eliminar** (espejo de la sección «conjuntos» de mirada, con + protección de builtins + fixup del activo) y un campo **Prefijo** (vacío = binds directos; + `Ctrl+b` tmux / `Ctrl+w` vim). Así se materializa el CAVEAT: duplicás un preset → perfil + propio no-builtin donde *todo* pega. CRUD sobre el mapa opaco con núcleo puro testeado + (7 tests). GOTCHA: duplicar un preset que shuma nunca sembró en disco falla con aviso + («abrí shuma una vez») — el mapa opaco no tiene sus binds hasta que shuma corre. + +- [~] **K3 — Sonda de diagnóstico «por qué sólo sirve Ctrl+Shift+C» (24-jul).** + `PATA_SHUMA_FULL` es default-ON, así que el drawer real SÍ corre `resolve_key` antes del + forward al shell — un `Ctrl+Shift+T` *debería* disparar. Como no lo hace, el sospechoso es + (a) los modificadores que llegan del compositor (ojo `mirada-compositor/drm_backend/input.rs` + en edición por otra máquina — L9), o (b) el gate. `shuma::diag_shortcut(model, e)` (expuesto + a pata vía `shuma_app::diag_shortcut`) imprime, en el diag ya existente de pata, el **chord** + computado + si matchea un bind del perfil activo. **Test en metal:** + + ```sh + touch /tmp/pata-diag && pkill -x pata-llimphi + # abrir el drawer, tipear Ctrl+Shift+T; en el diag de pata sale la línea: + # pata·shuma key=... ctrl=.. shift=.. alt=.. → on_key=.. · atajo: chord='...' → ... + ``` + Lectura: si el chord sale SIN `Ctrl`/`Shift` → los modificadores no llegan (bug de input.rs). + Si sale completo pero «SIN BIND» → es el perfil. Si matchea → dispara, el problema es aguas abajo. + +- [~] **K1 — Atajos tipo gnome-terminal/kitty. YA ESTABAN, verificado en código.** + El pedido diferido del 21-jul («tabs dinámicos con los atajos normales de un + terminal») está cableado en el perfil **nativo** `shuma` de + `perfiles/shortcuts.rs`: `Ctrl+Shift+T` (NewTab), `Ctrl+Shift+W` (CloseTab), + `Ctrl+PageUp`/`Ctrl+PageDown` (Prev/NextTab), más los `Alt+t`, `Alt+[`, `Alt+]` del + dialecto propio. Hay además un perfil `terminal` con el mismo juego. + `Ctrl+Shift+…` y no `Ctrl+…` a propósito, para no comerse los códigos de control que + el shell necesita. + **Falta sólo probarlos en metal** — y ojo con [[shuma-gate-consola-se-come-atajos]]: + con un PTY inline vivo, todo lo que lleva Ctrl se va al programa salvo que esté en + la lista de excepción. Si un atajo «no hace nada» con claude corriendo, es eso. + + **PROBADO EN METAL (24-jul, sergio).** Resultado: `Alt+t` **anda** (ruteo del drawer, + `resolve_key` y perfil: sanos; no hay bug de gate ni de modificadores). `Ctrl+Shift+C` + anda por su vía aparte (excepción del gate, no el keymap). Dos hallazgos: + 1. **Los `Ctrl+Shift+…` no disparaban porque el perfil ACTIVO es `zellij`**, que no + liga ninguno — era dialecto, no bug. **RESUELTO con la CAPA UNIVERSAL (decisión de + sergio, 24-jul): los acordes de terminal son de la APP, no del dialecto.** + `ShortcutProfiles` ganó un keymap `universal` aparte del perfil activo, que + `resolve_key` consulta **después** del perfil (que puede rebindearlo) y **también + con los perfiles de prefijo** (tmux/vim) sin tener que apretar el prefijo. Elegir + `zellij` ya no cuesta el `Ctrl+Shift+T`. + **Invariante (pedido explícito): la capa NO le roba teclas a un zellij/tmux/vim + corriendo DENTRO del terminal** — todos sus acordes llevan `Ctrl` y ninguno usa + `Alt` ni teclas sueltas, que es lo que esos programas necesitan. Test: + `la_capa_universal_no_le_roba_teclas_a_los_tui`. + Persistencia: campo `universal` en `shortcuts.ron`, con `serde(default)` → un RON + viejo carga igual y `ensure_builtins` le funde los acordes que falten. El panel de + wawa lo preserva **desnudo** (sin `Some(…)`) en el doc opaco Y en el tipado; el + test lo pilló: con un `Option` normal el panel no parseaba el RON de shuma + (`ExpectedOption`) y **caía al fallback borrando los perfiles del usuario**. + Ojo aparte, **RESUELTO también**: `Ctrl+Shift+W` no llegaba nunca a shuma porque + pata lo interceptaba para replegar el drawer. Ahora rige el reparto de un terminal + de verdad — **`Ctrl+Shift+Q` cierra la "ventana"** (repliega el drawer) y **`Ctrl+Shift+W` + cierra la PESTAÑA**. La `W` sigue replegando en el path *bare* (sin pestañas que + cerrar), así que ningún modo queda sin salida deliberada. + **Falta enchufar:** la capa universal no se edita todavía desde wawa-panel (el + panel la preserva pero no la muestra); iría como una tabla más en la sección + «Terminal (shuma)», con `load_binds`/`set_binds` apuntando al campo `universal`. + 2. **BUG REAL, ARREGLADO: `Alt+[` / `Alt+]` son intipeables en teclado español.** Ahí + `[`/`]`/`\` salen con **AltGr**, así que el acorde no existe: llega una tecla muerta, + `key_char()` da `None` y `chord_of` ni arma el chord. Los presets ganaron espejos + alcanzables (`Alt+PageUp`/`Alt+PageDown` en `shuma` y `zellij`; `Shift+Super+s` en + `hyprland`, que sólo tenía `Super+\`), sin sacar los originales (valen en US). Dos + tests nuevos lo vigilan: `toda_accion_directa_es_alcanzable_sin_altgr` (ninguna + acción de un preset directo depende de un glifo de AltGr) y `los_presets_son_canonicos` + (orden `Ctrl+Alt+Shift+Super`, base en minúscula, `Shift` sólo con letras/nombradas — + un bind mal escrito no matchea nunca). **Falta metal:** probar `Alt+PageUp/PageDown` + tras el deploy. + +## M. Menú contextual de las pestañas (25-jul) + +- [~] **M1 — El menú contextual partía el drawer al medio. ARREGLADO.** Textual: + *«cuando le doy botón derecho sobre un tab, el drawer se reduce a la mitad respecto a + su width, y el menú contextual aparece en el lado derecho»*. Dos causas encadenadas: + 1. **El overlay compartía el flujo con el canvas.** `shuma::drawer_body_view_full` + apilaba `view` y `view_overlay` como hermanos de un contenedor **sin** + `flex_direction` — o sea `Row` — así que al aparecer el menú los dos hijos se + repartían el ancho. Ahora el overlay va en `shuma::capa_absoluta` (position + absolute, inset 0) y **a nivel de la surface entera** (`render::shuma_open_view` y + `drawer_overlay_full`), no dentro del cuerpo. + 2. **Las coordenadas eran de otro sistema.** El chip usaba `on_right_click_at` + (coords LOCALES al chip: x≈20, y≈10) y el menú las tomaba como ancla absoluta → + se pintaba en un rincón. Ahora usa `on_right_click_screen`, que en pata entrega + coords de surface — las mismas en las que vive la capa del overlay. + 3. De paso: `menu::viewport()` clampeaba contra `App::initial_size()`, un tamaño FIJO + que ignoraba tanto el resize de la ventana como el hospedaje en el drawer. Ahora es + `Model::overlay_viewport()`, y pata declara la caja real con + `shuma_app::set_overlay_box` (`Model::overlay_box`) en cada `draw`. + Test: `pata-llimphi/tests/overlay_no_roba_ancho.rs` (3 casos, uno documenta la causa). + **Falta metal.** + +- [~] **M2 — Todas las operaciones aplicables a una pestaña. HECHO, falta metal.** + El menú tenía 3 entradas (nueva / cerrar / cerrar otras). Ahora: **Nueva tab · + Duplicar tab** (shell fresco en el MISMO cwd, sin heredar scrollback) **· Renombrar…** + (campo editable en el propio chip; Enter confirma, Esc cancela, vacío vuelve al título + automático) **· Mover a la izquierda / derecha · Dividir ⇅ / ⇆ · Cerrar tab · Cerrar + las de la derecha · Cerrar otras**, cada una deshabilitada cuando no aplica (una sola + pestaña, primera, última). + Piezas nuevas: `Workspace::{close_right, move_tab, rename_tab, tab_name, titulo_de}`, + `WsTab::cwd_enfocado`, y los `Msg::{TabCloseRight, TabDuplicate, TabMove, TabRename*, + TabSwitchThen}`. `TabSwitchThen` existe porque **dividir opera sobre el panel con + foco**: sin activar primero la tab clickeada, el split le caía a la que estabas + mirando. + El menú pasó a estar **dirigido por tabla** (`filas_menu_tab` + `accion_de_fila`, las + dos puras): antes era un `Vec` de ítems y un `match` sobre índices numéricos en + paralelo, así que insertar una fila en el medio corría todas las acciones en silencio. + 10 tests nuevos (5 de `menu`, 5 de `workspace`) cubren cada acción, los extremos y + que una fila deshabilitada no dispare nada. **En metal: probar las 10 del menú.** + +## L. Traído de COLA-VERIFICACION §14/§11 (otro agente, hilo 21-jul) + +Pendientes de shuma que vivían en la cola de la raíz. Los que se solapan con A–K no se +reduplican: se anotan como YA cubiertos. Orden por gravedad (el otro agente lo dejó así). + +- [~] **L1 — Enter con el cajón plegado ya no pierde el texto. HECHO (falta metal).** + Decisión de sergio (22-jul): **auto-expandir + mandar a claude** cuando hay un run de + consola inline vivo. Plegado, la tecla Enter llega como `Msg::Submit` (la arma `press_key` + de pata) y caía en `run_submitted` → encolaba la línea como comando (se perdía para + claude). Fix en el handler `Msg::Submit` de `update/mod.rs`: si + `tui_skin_vivo.is_some() && !tui_altscreen_vivo`, enruta a claude vía el helper compartido + `enviar_linea_a_consola` (extraído del Enter del gate). pata **ya** auto-expande el drawer + al ver ese mismo `Msg::Submit` (`msg_is_submit`), así que la mitad de "expandir" ya estaba + — sólo faltaba no encolar. De paso, el botón «enviar» con claude visible también enruta a + claude (antes encolaba, latente). **En metal: plegado + claude vivo + escribir + Enter → + se abre el cajón y la línea va a claude; plegado + sin run → comando normal.** +- [~] **L2 — Salida de emergencia del modo consola. HECHO (falta metal).** `Ctrl+Shift+Esc` + corta el run (SIGKILL vía `cancel_running`) por encima del programa, interceptado ARRIBA + del gate (`update/mod.rs`, primer chequeo del bloque `tui_skin_vivo && canvas_visible`), + antes de reenviar nada al PTY — funciona incluso en alt-screen (vim/htop). Es el único + atajo que el gate no reenvía. (Superset de H8, que era sólo el `Ctrl+R`.) **En metal: + probar que desencierra; CAVEAT L9 — si Ctrl+Shift no llega junto en kitty el atajo no + dispararía ahí, pero bajo pata/mirada (el target real) sí debería.** +- [~] **L3 — El `Exclusive` del drawer sólo se soltaba por el camino feliz. RELEASE POR + WATCHDOG HECHO (falta metal).** Había DOS redes ya: el cierre por Esc/✕/scrim/Ctrl+Shift+W, + y el **watchdog** (`SHUMA_WATCHDOG` 45s) que cierra el drawer inactivo — pero el watchdog + **se inhibe con un PTY interactivo vivo** (claude/vim: mirar output largo sin tipear es uso + normal). Ese era el hueco: un `Exclusive` colgado con claude a la vista no lo soltaba nadie. + **Fix (`app_impl.rs`, commit f5c74145a):** bandera `shuma_grab_released` + `SHUMA_GRAB_RELEASE` + (180s). Tras idle genuino **con PTY vivo**, el latido baja el teclado a `OnDemand` (suelta el + `Exclusive`) **sin cerrar** el drawer — otras ventanas vuelven a recibir teclado; el próximo + input real re-reclama el `Exclusive` (`toca_shuma_watchdog`). Umbral largo a propósito: nunca + muerde en uso activo. Ver [[pata-drawer-shuma-wedgea-input]]. **Falta**: soltar TAMBIÉN al + perder foco del compositor (KB `leave` sobre drawer Firme) — descartado por ahora: `leave` es + ruidoso (muchas guardas anti-churn) y arriesga «pierde foco al leer»; el watchdog de grab + cubre el wedge sin ese riesgo. **En metal**: confirmar que tras ~3 min idle con claude, otra + ventana agarra teclado, y que al volver a tipear el drawer lo recupera. +- [~] **L4 — Ventana de 2.000 del corpus de sugerencias. HECHO 2026-07-25 (falta tu ojo).** + Era la **Fase 1** de `SDD-HISTORIAL.md`. `GHOST_CORPUS_WINDOW`/`LINE_SUGGEST_WINDOW` (las dos + constantes, retiradas) recortaban por antigüedad **cruda**: un comando que usás seguido + desaparecía del autocompletado por haber tecleado mucho después. Ahora hay un corpus + **deduplicado y cacheado** (`update/corpus.rs` + `State::corpus`): las líneas distintas, la + más reciente primero, con el cwd de ese uso (para el ranking local-antes-que-global de A3). + Cubre **todo** el historial, sin ventana. + - **Cuándo se pone al día:** al construir el `State` (así el ghost sirve desde el primer + comando, no después del primero), en `refresh_patterns` (al cerrar cada comando) y + **perezosamente al leerlo** — esto último es lo que cubre las vías que no pasan por + ninguno de los dos (la importación de zsh, o un test que escribe el historial a mano). + Nunca por pulsación: el chequeo es comparar una marca de agua. + - **Costo por frame BAJA:** antes se clonaban hasta 2.000 líneas por render; ahora el corpus + filtra por prefijo y devuelve el puñado que de verdad extiende lo tipeado. + - **Agujero encontrado y tapado en el camino:** una marca de agua numérica sola es insegura + — si el objeto historial se REEMPLAZA por otro más largo, «extender desde `seen`» saltea + en silencio el prefijo del nuevo. El caché guarda además un **ancla** (la línea que estaba + en `seen-1`) y si no coincide rehace entero. Lo cazó un test, no la lectura. + - **Certificado:** `cargo test -p shuma-module-shell --lib` → **338/338**, con 5 tests nuevos + del corpus (el comando viejo sobrevive a 3.000 líneas después; dedup dejando el cwd del uso + más reciente; incremental ≡ rehacer de cero; líneas vacías; el tope recorta lo viejo) y el + test `ghost_corpus_is_bounded_to_recent_window` **dado vuelta** — ahora se llama + `el_corpus_ya_no_se_acota_por_antiguedad` y asierta lo contrario de lo que asertaba. + - **En metal (tu ojo):** tipear el prefijo de un comando que usás mucho y hace rato no usás + (p. ej. `claude --dan…`) y ver que el fantasma lo ofrece; y que el popup de líneas (`↪`) + trae completas viejas sin haberlas tecleado hoy. +- [~] **L5 — `Ctrl+Shift+Flecha` (salto por palabra CON selección) dentro del modo consola. + CÓDIGO YA HECHO (falta metal).** `es_edicion_de_linea` (`update/mod.rs:72`) **no filtra por + Shift**: la guarda es `ctrl && !alt` y matchea `ArrowLeft/Right`, así que `Ctrl+Shift+←/→` + la pasa → `de_control=false` → NO va al PTY → cae al motor compartido, que hace el + word-select con selección. El doc comment ya lo menciona explícitamente. Verificar en + metal dentro de claude. +- [~] **L6 — El fantasma vuelve a ofrecer el comando del bypass. MEDIDO SOBRE TU HISTORIAL + REAL 2026-07-25 (queda tu ojo).** Lo cerró L4 de rebote. Números de + `~/.local/share/shuma/history.jsonl` tal como está hoy: **15.624 entradas → 2.902 líneas + distintas** (5,4× menos; el corpus entero entra holgado bajo el tope de 20.000), **109 usos** + de `claude --dangerously-skip-permissions`, y para el prefijo `claude --dan` hay 11 candidatos + de los que el corpus ofrece primero justamente ése. **Matiz honesto:** hoy ese comando es la + entrada más reciente del historial, así que la ventana vieja *también* lo habría alcanzado — + lo que cambió es que ya no depende de dónde caiga. El caso que dolía (quedar sepultado por + miles de líneas después) está cubierto por test. +- [ ] **L7 — En modo shell el autocomplete salta por cada palabra** («lo molesto», + diferido explícitamente por sergio). +- [ ] **L8 — El «Terminal» del menú es una cadena de respaldo silenciosa:** + `sh -c shuma || kitty || alacritty || foot || xterm`. Abre una terminal distinta según qué + falle, con atajos distintos y sin avisar cuál te tocó. +- [ ] **L9 — Ctrl+Shift no funciona en kitty — SIN DIAGNOSTICAR.** Ctrl+C anda, fallan + todos los Ctrl+Shift. Descartado (con evidencia): grab colgado, atajo global, modificador + pegado, config de kitty. Hipótesis viva: el bit de Shift no llega junto con Ctrl. Cierre: + arrancar el compositor con `MIRADA_DEBUG_KEYS=1` (imprime bits crudos, `drm_backend/input.rs`) + o `xkbcli interactive-wayland` (sale con Ctrl+D). +- [ ] **L10 — Font zoom como feature de usuario.** La plomería ya es zoom-aware (`pty_dims`, + `Metricas::con_zoom`); falta la UI para cambiarlo. +- [ ] **L11 — Fases 2–5 de `SDD-HISTORIAL.md`:** registrar `exit`/`dur_ms`/`sesion` (hoy + `exit` es siempre `None`), grafo con frecuencias/lugares/bigramas por dir/args por verbo, + completado por especificación, grupos por lugar. +- [ ] **L12 — matilda contra una flota SSH real** (metal). §11 de la cola vieja. +- [~] **L13 — `:predice`: legibilidad del listado en metal.** LÓGICA CERTIFICADA + (`cargo test -p shuma-module-shell`, incl. `predice_lista_comandos_por_frecuencia_y_cwd`); + queda el ojo sobre el listado en el widget de input. +- [ ] **L14 — Abrir una pestaña mata la de al lado (EN DIAGNÓSTICO).** Reproducido 2×. + Descartadas por lectura: modelo de tabs, `State::new` (no auto-adjunta), resize-a-0 (las + filas de claude están clampeadas), robo de sesión (ULID fresco por run), reinicio del + daemon (`ensure_daemon` reusa). Instrumentado: `touch /tmp/shuma-diag` → `cat + /tmp/shuma-tab-diag.txt` correlaciona `TabNew` con el `run-end` del vecino y su código de + salida (137=SIGKILL, 143=SIGTERM, 129+=128+señal). Al 22-jul-22:30 no reapareció. + +### Ya cubierto por A–K (no reduplicar) +- Respaldo de los fugaces → **C2**. · Caret con estela al mover flecha → **B2**. +- `Ctrl+Shift+C/V/X` en consola → **G1**. · Fade de los fugaces «no calcula bien» → **C1/H1** + (arreglado proporcional). · shuma multiproceso → **J2–J5**. · Tabs dinámicos con atajos → + **K1**. · Detección de tablas en mensajes de claude → **H5**. + +### Limpieza pendiente (diagnósticos temporales a quitar al cerrar sus casos) +- [ ] `/tmp/shuma-consola-dump.txt` (volcado de `surface_view.rs`) y la marca `cod` — quitar + cuando H4 esté aprobado. +- [ ] `/tmp/shuma-tab-diag.txt` + `diag_tab` — quitar cuando L14 cierre. +- [ ] `avisar_ancho_inestable` (`/tmp/llimphi-input-jitter.log`) — del hilo del input. +- [ ] `/tmp/pata-diag-fugaces.txt` — quitar cuando C1/H1 esté aprobado. diff --git a/02_ruway/shuma/INTELIGENCIA.md b/02_ruway/shuma/INTELIGENCIA.md new file mode 100644 index 0000000..68fe41e --- /dev/null +++ b/02_ruway/shuma/INTELIGENCIA.md @@ -0,0 +1,345 @@ +# INTELIGENCIA.md — estrategias de control power en shuma + +> Propuesta 2026-06-12. Estado: **borrador para discusión** — nada de esto +> está comprometido; cada ítem cita el artefacto real sobre el que se monta. + +## Tesis + +La inteligencia de shuma no es un chatbot pegado a una terminal: es el shell +**observando el trabajo real** y devolviendo control en dos dosis distintas +según el usuario. El *nerdo habitual* quiere que el shell le ahorre teclas y +le avise cosas sin pedirle nada — inteligencia **que se ofrece sola y se +acepta con una tecla**. El *nerdo extremo* quiere lo contrario: superficies +**programables y direccionables** donde la inteligencia es un instrumento +más bajo su mando, nunca un piloto. + +Regla transversal: **determinista primero, LLM opcional después**. Todo lo +de la lista A funciona sin red ni modelo; el LLM (vía `pluma-llm`, fachada +con fallback a Mock) sólo entra explícitamente invocado y rotulado. + +## Inventario — lo que ya existe y dónde + +| Pieza | Crate | Estado | +|---|---|---| +| Patrones emergentes (coreografías repetidas → abstracción con `Varies`) | `sandbox/shuma-infer` | vivo; alimenta el ghost **y** el chip de coreografía (A1, 2026-06-13) | +| Ghost predictivo (prefijo → sufijo del corpus) | `sandbox/shuma-line::ghost` | vivo en el input | +| Grafo de intenciones (`%cN`/`%pN`, nodos por comando) | `sandbox/shuma-intent::SessionGraph` | vivo; lo pinta `shuma-module-canvas` | +| Macros parametrizables | `sandbox/shuma-intent::MacroBook` | **núcleo listo, sin UI ni builtin** | +| Grupos ejecutables (`:save` → F1..F8) | `shuma-module-shell` | vivo | +| Reprocess (stdout de un bloque → stdin del próximo) | `shuma-module-shell` (chip `» stdin`) | vivo | +| Completions por comando (TOML en `~/.config/shuma/completions/`) | `sandbox/shuma-config` | vivo | +| Coloreo semántico (Severity err/warn/ok, números, fechas…) | `sandbox/shuma-line::decorate` | vivo (2026-06-12) | +| Env aprendible + persistencia aprendible (`:env`, `:persist`) | `shuma-module-shell` + `shuma-config::upsert_key` | vivo (2026-06-12) | +| Daemon + workspaces + quotas + stats | `shuma-daemon` / `sandbox/shuma-protocol` | vivo | +| Gateway JSON/WS (clientes móviles) | `shuma-gateway` | vivo; PTY **efímero** (gap conocido) | +| Historial durable con cwd + éxito | `sandbox/shuma-history` | vivo | +| LLM multi-backend con Mock fallback | `00_unanchay/pluma/pluma-llm` | vivo (en pluma) | + +La estrategia entera es **cablear lo que ya está parido**, no inventar +maquinaria nueva. Sólo E3 y E4 requieren código sustancial. + +## A — El nerdo habitual: inteligencia que se ofrece sola + +Principio: cero configuración, cero prompt engineering. El shell propone, +el usuario acepta con una tecla o ignora. Toda propuesta es descartable y +**aprendible al shumarc** (la infraestructura `upsert_key` ya existe). + +### A1. Coreografías que se ofrecen como grupo (cablear `shuma-infer` a UI) ✅ (2026-06-13) +`detect_patterns` corre tras cada comando y alimentaba sólo el ghost. Ahora, +cuando un `EmergingPattern` supera el umbral (`CHOREO_OFFER_THRESHOLD = 3` +ocurrencias), un chip discreto sobre el input ofrece guardarlo: +*«↻ lo corriste 3 veces · guardar «git+cargo+cargo» como grupo? (git pull → +cargo build → cargo test) [guardar] [descartar]»*. **Hecho:** +`choreography_suggestion` / `accept_choreography` en `update/patterns.rs` +(promueve el patrón a `CommandGroup` con `suggested_name()` + las líneas reales +de la última ocurrencia, ejecutable por F-key); `choreography_chip` en +`view/mod.rs` sobre el input; Msgs `AcceptChoreography`/`DismissChoreography`; +descartes en memoria (`State.dismissed_choreo`). Verificado headless +(`examples/choreo_chip.rs` → PNG) + 3 tests unitarios. **Pendiente menor:** el +chip vive en `view()` (shell standalone); falta llevarlo a la barra de pata +(`body_view` no incluye el input). + +### A2. Alias sugerido por longitud × frecuencia ✅ (2026-06-13) +Línea ≥ 40 chars repetida ≥ 3 veces idéntica → ofrecer alias corto. **Hecho:** +gemelo de A1 sobre **una sola línea** en vez de una secuencia. +`alias_suggestion` (`update/patterns.rs`) cuenta líneas idénticas del historial +(externas — los `:builtins` no se aliasan; el dedup `IgnoreConsecutive` ya +descarta las repes pegadas, así que cuenta las separadas por otro comando, que +es la buena señal), filtra por largo/umbral/descartadas/ya-aliasadas y rankea +por (veces, largo, lex). El nombre lo arma `suggest_alias_name`: iniciales de +los tokens no-flag (`git push origin feature…` → `gpof`), con sufijo numérico +si choca contra un alias o un binario del PATH (no pisa comandos del sistema). +`alias_chip` (`view/mod.rs`) lo ofrece sobre el input — **sólo si no hay +coreografía pendiente** (una oferta a la vez); «aliasar» llama `accept_alias` +(núcleo puro `learn_alias` → config viva + `upsert_key` al `[aliases]` del +shumarc, preservando comentarios), «descartar» lo calla en la sesión. Mismo +molde visual que A1, otra fuente. Verificado headless (`examples/alias_chip.rs` +→ PNG) + 6 tests (oferta, gates de largo/umbral/builtin/descartada/aliasada, +unicidad del nombre, línea de puras flags, aprendizaje a la config viva). + +### A3. Ghost contextual por cwd ✅ (2026-06-13) +El historial guarda `cwd` por entrada. **Hecho:** `current_ghost` +(`update/patterns.rs`) rankea el corpus en dos tramos — primero las entradas +del cwd actual y sus hijos (`cwd_within`), después lo global; dentro de cada +tramo, lo más reciente primero. En un monorepo `cargo b…` en `cosmos/` +completa al build de cosmos, no al de wawa. Test: el del cwd manda aunque sea +más viejo que uno global. + +### A4. "¿Quisiste decir…?" determinista ✅ (2026-06-13) +**Hecho:** al cerrar un comando con `command not found`, `detect_did_you_mean` +(`update/patterns.rs`) busca el binario más cercano por **Damerau-Levenshtein** +(transposición = 1, atrapa `cagro`→`cargo`), **priorizando el historial** sobre +el PATH (`ShellSource::commands`). Notice clickeable bajo el bloque +(`did_you_mean_notice` en `surface_view`): *«¿quisiste decir «cargo build +--release»? · click lo lleva al input»* (`Msg::AcceptDidYouMean` rellena el +input para revisar y Enter — nunca auto-ejecuta). `State.did_you_mean` por +bloque. Sin modelo, sin red. Verificado headless (`examples/did_you_mean.rs`) ++ 6 tests (Damerau, corrección desde historial, gates de no-oferta). + +### A5. Titular de bloque al colapsar ✅ (2026-06-13) +Al plegarse un bloque, el header gana un resumen determinista contado desde +las decoraciones `Severity`: *«3 errores · 3 avisos · 7 líneas · 4 s»*, +coloreado como semáforo (rojo si hubo errores, ámbar si sólo avisos, tenue si +limpio). El nerdo habitual escanea la columna de headers como un log +semáforo. **Hecho:** helper `semaforo_titular` en `view/output_line.rs` +(cuenta líneas con severidad Error/Warn + duración `block_ended − block_started`, +campo nuevo en `State`); cableado en ambos renderers — en la superficie +(`surface_header`, default) va right-aligned en el header y reemplaza los +chips de acción al colapsar (modo escaneo), en el legacy (`command_card`) va +como segunda fila *«… · clic para ver»*. Verificado headless +(`examples/titular_a5.rs` → PNG) + 3 tests unitarios. + +### A6. Aviso de comando largo terminado ✅ (2026-06-13) +Comando ≥ `[rules].on_long_command_secs` (default 30 s) que cierra mientras el +usuario está en otra sesión/diente → badge en el diente del rail + rastro en el +bloque. Nada de notificaciones del sistema: el chasis es la superficie. **Hecho +— por fin consume el `on_long_command_secs` que quedaba inerte:** módulo — +`register_long_command` (`update/run_exec.rs`, puro y testeable) corre al cerrar +cada comando externo; si `ended − block_started ≥ umbral` (`0` = apagado) suma a +`State.long_alerts` y deja un notice `⏲ comando largo — terminó tras Ns` en el +bloque. `State::long_alerts()`/`ack_long_alerts()` lo exponen. Chasis +(`shuma-shell-llimphi`) — `Session::long_alerts()`/`ack_long_alerts()` puentean +al módulo; `session_tooth_icon` gana un parámetro `alert` que pinta un **punto +ámbar con halo** en la esquina opuesta al LED verde, **sólo en sesiones no +activas** (`!activa && long_alerts() > 0`); se acusa al `SelectSession` (vuelves +a mirarla) y por `ShellTick` sobre la sesión activa (un comando largo en primer +plano no deja badge stale al cambiar de diente). Verificado: 4 tests del módulo +(suma con umbral, corto no alerta, umbral-0 apaga, ack limpia) + build release +del chasis. La badge en sí es espejo del LED de actividad ya existente. + +## B — El nerdo extremo: superficies direccionables y programables + +Principio: el shell expone sus entrañas como **datos direccionables** y +**puntos de enganche declarativos**. Nada se ofrece solo: todo se invoca. + +### E1. Macros con parámetros (`:macro`) — darle UI al MacroBook ✅ (2026-06-13) +**Hecho:** builtin `:macro` en `update/builtins.rs` sobre el `MacroBook` ya +existente — `:macro save deploy cargo build --bin %1 && scp %1 %2:/srv`, +`:macro run deploy app host` instancia (`substitute_macro_params`: `%1..%9` + +`%*`, `instantiate_macro` une los pasos con `&&` y reusa `run_submitted`), +`:macro rm`, `:macros`/`:macro list`. Persistencia en +`~/.config/shuma/macros.toml` (`load/save_macro_book`, atómico tmp+rename; +`shuma_config::macros_path`); `State.macro_book` cargado al arrancar. Es el +ascensor de A1: el patrón emergente se promociona a macro con parámetros +explícitos. 3 tests (sustitución, instanciación multipaso, macro inexistente). + +### E2. El scrollback como base de datos (`%cN` en la línea) ✅ (2026-06-13) +**Hecho:** `resolve_injects` (`update/run_exec.rs`) parsea la línea con +`shuma_intent::Intention`; una etapa-ref `%cN`/`%pN` materializa el stdout del +bloque `N` (`gather_block_stdout`) como **stdin** del resto del pipeline — +`%c12 | grep error | sort` corre `grep error | sort` sobre el bloque 12; `%c12` +solo se re-muestra con `cat`. Tiene prioridad sobre el reprocess del chip +`» stdin` (su caso degenerado). Tag `%cN` clickeable en el header +(`surface_header` + `Msg::InsertBlockRef`) hace visible el número y lo inserta +al input. Combinado con las secciones-tabla, un `ls -l` viejo es una tabla +consultable. 4 tests (ref como fuente, ref sola→cat, %pN, línea sin ref). + +**Persistir la scrollback (2026-06-21):** dos builtins complementan a `%cN` — +el stdout de un bloque no sólo se re-procesa, también se **saca**: +- `:write [%cN] ` (`apply_write`) → vuelca el stdout a un archivo + (expande `~`, resuelve relativo al cwd, `std::fs` sin shell). +- `:yank [%cN]` / `:copy` (`apply_yank`) → lo copia al clipboard del SO + (`set_clipboard`, best-effort). +- `:diff %cN %cM` (`apply_diff`) → compara el stdout de dos bloques con + `similar::TextDiff` (Myers) y vuelca los cambios (`-`/`+`) + resumen + `X+ / Y-` (o «idénticos»). "¿qué cambió entre estas dos corridas?". +Los de salida-única sin ref usan el último bloque con salida. Reusan +`parse_block_ref` (el resolver de `:explica`) + `gather_block_stdout`. 9 tests. + +### E3. Reglas declarativas en el rc (`[rules]`) — el plano de control ✅ (2026-06-13) +El shumarc gana gatillos deterministas (`shuma_config::RulesConfig`): + +```toml +[rules] +on_exit_nonzero = ":jobs" # qué correr cuando algo falla +on_pattern_score = 3 # umbral de A1 (0 = nunca ofrecer) +on_long_command_secs = 30 # umbral de A6 (aún sin consumidor) + +[rules.on_enter_cwd] +"~/proyectos/wawa" = ":env RUST_BACKTRACE=1" +``` + +**Hecho:** `on_exit_nonzero` corre el comando declarado cuando un comando +externo cierra con exit ≠ 0 (guarda `exit_rule_fired` re-armada por submit +del usuario → el propio comando de la regla no la re-dispara). `on_enter_cwd` +(mapa prefijo→comando, `~` expandido, gana el prefijo más largo; +`RulesConfig::command_for_cwd`) corre al `cd` local exitoso (guarda +`in_cwd_rule` contra recursión). `on_pattern_score` gobierna el umbral de A1 +(`choreography_suggestion`; `0` lo apaga). Motor: match determinista en +`update`/`apply_cd`, sin DSL turing-completo. `on_long_command_secs` queda +declarable pero inerte hasta A6. Verificado: 1 test en shuma-config +(matching + más-específico-gana) + 2 en shuma-module-shell (on_exit_nonzero +una-sola-vez, on_enter_cwd dispara). + +### E4. Flota persistente (daemon attach/detach) ✅ (2026-06-13) +El daemon ya tenía el **registro de sesiones PTY persistentes** +(`pty_sessions::PtyRegistry`: spawn/attach/list/kill, ring de scrollback + +broadcast, desacoplado de la conexión) y el protocolo +(`PtySpawn`/`PtyAttach`/`PtyList`/`PtyKill`); faltaba el **cliente para el +nerdo de terminal**. **Hecho:** `shuma pty {spawn,ls,attach,kill}` en +`shuma-cli`. `attach` es un cliente full-duplex real: terminal en raw +(`RawGuard` con restauración en Drop), teclas → `PtyInput`, SIGWINCH → +`PtyResize`, `ExecBytes` → stdout; **Ctrl-]** desadjunta sin matar la sesión. +Verificado end-to-end contra el daemon: spawn persiste entre invocaciones, +attach hace round-trip (eco de `cat`), detach deja la sesión `viva`. Bonus: +arreglado el detach idle en `handle_pty_attach`/`_enc` (un `select!` sobre la +tarea lectora corta el writer al instante → el `attached` baja a 0 sin +esperar tráfico). + +**Pulido — el shell sobre sesiones del daemon** ✅ (2026-06-13): +`shuma-remote-exec` ganó `spawn_session`/`attach_session`/`list_sessions`/ +`kill_session` (el `RemoteRunHandle` que devuelven es idéntico al de +`run_pty`, así el shell las rinde igual). El shell tiene builtins +`:spawn ` (corre en el daemon, **sobrevive a cerrar shuma**, se adjunta +y rinde como TUI), `:sessions` (lista), `:attach ` (re-adjunta), y +`:kill-session `. Cerrar shuma = detach (la sesión vive); reconectas con +`:attach` o `shuma pty attach`. Verificado e2e contra daemon vivo +(`examples/session_smoke`: spawn→list→attach lee scrollback→detach-sigue-viva +→kill). + +**Cliente móvil vía gateway ✅ (2026-06-21):** `shuma-gateway` sirve `GET /term` +— una página HTML autocontenida pensada para un teléfono en la misma red. Lista +las sesiones por `POST /rpc` (`"PtyList"`), adjunta a una (o crea) por el +WebSocket `/ws/pty` (primer msg JSON `{"session":id,rows,cols}` o +`{"program",args,…}`; binarios = stdin/salida; `{"t":"resize",…}`), con botones +Abrir/Matar/Nueva. El token (si el gateway lo exige) va en `?token=…`: el JS lo +manda como `Authorization: Bearer` a `/rpc` y como `?token=` al WS. La página en +sí no requiere auth (no tiene secretos; el gateo está en /rpc y /ws/pty). +**xterm.js (5.3.0) + fit addon (0.8.0) VENDORIZADOS** (`src/vendor/*`, +embebidos por `include_str!`, servidos en `/vendor/…` con su content-type) — la +consola anda **100% offline en una LAN sin internet**, cero CDN. Servido y +content-types verificados por curl (`/term` text/html sin refs a CDN; +`/vendor/xterm.js` 283 KB application/javascript). El flujo terminal en vivo +pide daemon + navegador real. **E4 cerrado del todo.** + +### E5. LLM como instrumento invocado (`:?`) ✅ (2026-06-13) +**Hecho** con `pluma-llm` (backend por env, Mock sin credenciales): +- `:? ` — lenguaje natural → línea de comando propuesta, **al + input** (NUNCA auto-ejecutada; revisar y Enter). +- `:explica [%cN]` — explica la salida de un bloque (la del más reciente si + no se da ref). +- `:resume [%cN]` — resumen narrativo, para logs gigantes (el cuerpo se capea + por cabeza+cola si excede 8k). +Siempre rotulado `🜲`, opt-in por invocación. **Arquitectura (Regla 2):** el +módulo `shuma-module-shell` sólo expresa la intención (`State::llm_request`, +sin dependencias de red); el **chasis** la toma (`take_llm_request`), corre +`pluma-llm` en un thread con su runtime y devuelve `Msg::LlmResult`. Sin +credenciales `from_env` cae a Mock (responde igual, nunca cuelga). El LLM se +monta sobre las refs `%cN` y el scrollback ya deterministas — no sobre texto +plano. Verificado: 3 tests del módulo (petición armada, tomada una sola vez, +resultado al input/output); el chasis compila con el stack LLM. + +### E6. `:stats` — telemetría propia, local, consultable ✅ (2026-06-13) +**Hecho:** builtin `:stats [filtro]` (`update/builtins.rs`) sobre el historial +durable (`line`/`exit`/`started`/`duration_ms`). Agrega por binario (primera +palabra; los `:` builtins se omiten) → veces, fallos, %fallo, p50/p95 de +duración, último uso (`hace Nm/Nh/Nd`); resumen con total, distintos, con-exit +y hora pico (UTC). Corazón puro `compute_stats(entries, filtro, now_s)` → +líneas; emite 1 línea de resumen sin tab + tabla tab-separada que +`sections::detect_stats` reconoce y parte en sección «resumen» (Lines) + +«por comando» (Table **ordenable**, el mismo widget que `ls -l`; columna +`comando` ensanchada en `section_table_view`). `:stats foo` filtra a binarios +que contienen `foo`. Cero red: los datos no salen de la máquina; alimenta los +rankings de A3/A4. Verificado headless (`examples/stats_e6.rs` → PNG) + 4 tests +(agregación con fallos/percentiles, filtro + None, round-trip detector, +`humanizar_hace`). + +## Orden propuesto + +1. **A5 + A1** (titular semáforo + chip de coreografía): máximo efecto/LOC, + todo el material ya está en memoria. ✅ **hecho 2026-06-13.** +2. **A3 + A4** (ghost por cwd + quisiste-decir): afinan el día a día. ✅ **hecho 2026-06-13.** +3. **E1 + E2** (`:macro` + `%cN`): desbloquean el techo del extremo con + núcleos ya escritos. ✅ **hecho 2026-06-13.** +4. **E3 + E6** (`[rules]` + `:stats`): convierten el rc en plano de control. + ✅ **ambos hechos 2026-06-13.** +5. **E4** (PTY persistente): cliente `shuma pty` ✅ **hecho 2026-06-13** + (daemon + gateway ya estaban). Pulido pendiente: shell Llimphi sobre + sesiones del daemon; cliente móvil vía gateway. +6. **E5** (LLM): ✅ **hecho 2026-06-13** — montado sobre refs/tablas, no + sobre texto plano, como decía el plan. + +--- + +**Roadmap COMPLETO (2026-06-13):** A1·A2·A3·A4·A5·A6 + E1·E2·E3·E4·E5·E6 ✅ — +toda la lista de inteligencia, cerrada. El pulido de E4 (cliente móvil vía +gateway) quedó cerrado el 2026-06-21 con `GET /term`. **Sin pendientes.** + +--- + +## Extensión 2026-06-28 — redirección/análisis de salida + filtro IA + predicción consultable + +Más allá del roadmap A/E. Tres frentes pedidos por el usuario («analizar y +redireccionar output, incluyendo los de IA + filtro IA; predecir comandos y +grupos por frecuencia, cwd y contexto»): + +- **La salida de IA es de primera clase.** Nuevo `OutputKind::Ai`: las + respuestas del LLM (`:explica`/`:resume`/`:filtra`) dejan de ser *notices + muertas* y aterrizan en su **propio bloque referenciable** (`%cM`), teñidas + con el acento. `gather_block_text` (stdout + stderr + IA) reemplaza al + stdout-only en los redireccionadores: una respuesta de IA o un volcado de + errores se `:write`/`:yank`/se vuelve a `:filtra`/encadena con `%cM`. `:explica` + ahora ve stderr (explica builds fallidos). El pipeline crudo (`%cN` inject, + `:diff`) sigue en stdout-only para no contaminar datos. + +- **`:filtra` / `:filter` / `:fia` ` [%cN]` — filtro IA.** El LLM + aplica una instrucción en lenguaje natural a la salida de un bloque y devuelve + SÓLO el texto resultante, en un bloque `Ai` nuevo. Encadenable (filtrar el + filtro). System prompt anti-preámbulo/markdown. + +- **Etapas del tee direccionables: `%cN.K`.** Las capturas intermedias del pipe + (antes sólo mirables — los «pipe muertos») ahora son objetivo de + `:filtra`/`:write`/`:yank`/`:explica` vía `%c5.1` (etapa 1 del bloque 5, + 0-based como los chips). `parse_block_and_stage` + `gather_target_text`. + +- **`:compara` / `:cotejar` / `:vs` `%cN %cM` — cotejo de pluma.** Integra + `pluma-cotejo` (alineación párrafo-a-párrafo por similitud léxica, + Needleman–Wunsch) para comparar la salida de dos bloques *al estilo pluma*: + no un diff de líneas exacto, sino emparejar líneas parecidas aunque difieran y + clasificarlas idéntica (≡) / similar (≈) / divergente (✗) / agregada (+) / + eliminada (−). Pinta un side-by-side `izq │ der` con % de similitud, + eliminadas en rojo, en bloque propio referenciable; el bloque se saltea el + desplanizador. Núcleo puro `cotejo_rows` (testeable). Acepta refs con etapa + (`%cN.K`). Verificado headless (`examples/pantallazo_compara.rs`). + +- **`:predice` / `:sugiere` / `:next` — predicción consultable.** + `rank_command_predictions` (puro) pondera cada línea del historial por + **frecuencia** + **afinidad con el cwd** (×3 las corridas en el directorio + actual o hijos) + **recencia**. Lista: la continuación inmediata (motor de + patrones), los comandos probables aquí (marca ◆ de afinidad de cwd) y las + secuencias/grupos aplicables al contexto (`applicable_sequences`, filtradas por + marcadores de proyecto) + las F-keys guardadas. Hace consultable lo que ya + alimentaba el ghost (A3) y las coreografías (A1). + +Todo certificado por tests (223/223 verde): `:filtra`, redirección de IA, +encadenado, etapas del tee, ranking por cwd. + +**UI accionable HECHA y verificada en pantalla** (`examples/pantallazo_tee.rs`): +chips del tee rotulados con su índice `K`; al desplegar una etapa, fila de +acciones 🜲 filtrar / copiar / guardar / explicar que direcciona `%cN.K` +(filtrar/guardar prellenan el input vía `Msg::PrefillInput`; copiar/explicar +corren ya); chip «🜲 filtrar» en el header de cada bloque (prellena `:filtra +%cN `). Las líneas `Ai` se pintan en acento y el bloque IA se saltea el +desplanizador (`is_ai_block`). Las acciones viven en chips, no en menú +contextual (más descubribles). **Pendiente de pantalla aún:** legibilidad del +listado de `:predice` (sólo tests). diff --git a/02_ruway/shuma/LEEME.md b/02_ruway/shuma/LEEME.md index fed95fe..70c123a 100644 --- a/02_ruway/shuma/LEEME.md +++ b/02_ruway/shuma/LEEME.md @@ -2,7 +2,9 @@ > Shell interactivo con paridad zsh/fish, sobre chasis Llimphi. -`shuma` reemplaza zsh + tmux + mosh con una sola pieza: shell con history/completion/job-control, multiplexing nativo (no `tmux`), sesiones remotas (no `mosh`), todo dentro de un chasis Llimphi de 4 slots (TopBar, Main, BottomBar, DrawerTab + drawer Quake). Roadmap de 8 bloques (target 2026-05-25). `matilda` es la herramienta hermana para configuración declarativa multi-host. +![una sesión de shuma sobre la superficie de bloques: ls -l reconocido como tabla ordenable, ls -R partido en sub-bloques colapsables por directorio, y un comando corriendo en vivo sobre un proceso real](https://tawasuyu.net/02_ruway/shuma/pantallazo.png) + +`shuma` reemplaza zsh + tmux + mosh con una sola pieza: shell con history/completion/job-control, multiplexing nativo (no `tmux`), sesiones remotas (no `mosh`), todo dentro de un chasis Llimphi de 4 slots (TopBar, Main, BottomBar, DrawerTab + drawer Quake). `matilda` es la herramienta hermana para configuración declarativa multi-host. ## Instalación @@ -20,53 +22,76 @@ cargo run --release -p shuma-daemon ## Compatibilidad - **Linux / macOS / Windows** — shell + UI Llimphi. -- **Wawa** — corre adentro del kernel. -- Protocolo `shuma-protocol` permite cliente local + server remoto sin SSH. +- **Wawa** — planificado (todavía no hay port kernel-side). +- `shuma-daemon` + `shuma-protocol` permiten cliente local + server remoto sin SSH. ## Crates: shuma +Los binarios viven en la raíz del dominio; las librerías, en `sandbox/`. + | Crate | Rol | |---|---| -| [`shuma-core`](shuma-core/README.md) | Tipos: Session, Command, Output. | +| [`shuma-core`](sandbox/shuma-core/README.md) | Tipos: Session, Command, Output. | | [`shuma-cli`](shuma-cli/README.md) | CLI (no Llimphi). | -| [`shuma-daemon`](shuma-daemon/README.md) | Daemon de sesiones. | +| [`shuma-daemon`](shuma-daemon/README.md) | Daemon de workspaces (Unix socket + TCP cifrado Noise XK). | +| [`shuma-gateway`](shuma-gateway/README.md) | Gateway HTTP → daemon. | +| [`shuma-askpass`](shuma-askpass/) | Popup de contraseña compatible `SUDO_ASKPASS`. | | [`shuma-shell-llimphi`](shuma-shell-llimphi/README.md) | Shell con UI Llimphi. | -| [`shuma-shell-render`](shuma-shell-render/README.md) | Renderer de output (ANSI, imágenes, links). | -| [`shuma-protocol`](shuma-protocol/README.md) | Protocolo wire (reemplazo de SSH/mosh). | -| [`shuma-gateway`](shuma-gateway/README.md) | Gateway de sesiones remotas. | -| [`shuma-remote-exec`](shuma-remote-exec/README.md) | Exec remoto vía gateway. | -| [`shuma-session`](shuma-session/README.md) | Sesión persistente. | -| [`shuma-history`](shuma-history/README.md) | History con búsqueda fuzzy. | -| [`shuma-exec`](shuma-exec/README.md) | Ejecutor de comandos. | -| [`shuma-line`](shuma-line/README.md) | Readline (edición · completion · highlight). | -| [`shuma-config`](shuma-config/README.md) | Config del shell. | -| [`shuma-intent`](shuma-intent/README.md) | Intent → comando (predictor). | -| [`shuma-infer`](shuma-infer/README.md) | Inferencia para `intent`. | -| [`shuma-discern`](shuma-discern/README.md) | Discriminador comando-vs-texto. | -| [`shuma-link`](shuma-link/README.md) | Links clickables en output. | -| [`shuma-sysmon`](shuma-sysmon/README.md) | Monitor de sistema embebido. | -| [`shuma-card`](shuma-card/README.md) | Card escritorio. | -| [`shuma-module`](shuma-module/README.md) | Trait módulo del chasis. | -| [`shuma-module-shell`](shuma-module-shell/README.md) | Módulo shell (Main slot). | -| [`shuma-module-commandbar`](shuma-module-commandbar/README.md) | Módulo command bar (TopBar). | -| [`shuma-module-launcher`](shuma-module-launcher/README.md) | Módulo launcher (DrawerTab). | -| [`shuma-module-matilda`](shuma-module-matilda/README.md) | Módulo matilda integrado. | +| [`shuma-shell-render`](sandbox/shuma-shell-render/README.md) | Renderer de output (ANSI, imágenes, links). | +| [`shuma-protocol`](sandbox/shuma-protocol/README.md) | Protocolo wire daemon ↔ cliente (length-prefix + postcard). | +| [`shuma-remote-exec`](sandbox/shuma-remote-exec/README.md) | Exec remoto vía gateway. | +| [`shuma-session`](sandbox/shuma-session/README.md) | Sesión persistente. | +| [`shuma-history`](sandbox/shuma-history/README.md) | History con búsqueda fuzzy. | +| [`shuma-exec`](sandbox/shuma-exec/README.md) | Ejecutor de comandos (PTY cross-platform). | +| [`shuma-line`](sandbox/shuma-line/README.md) | Readline (edición · completion · highlight). | +| [`shuma-config`](sandbox/shuma-config/README.md) | Config del shell. | +| [`shuma-intent`](sandbox/shuma-intent/README.md) | Intent → comando (predictor). | +| [`shuma-infer`](sandbox/shuma-infer/README.md) | Inferencia para `intent`. | +| [`shuma-discern`](sandbox/shuma-discern/README.md) | Discriminador comando-vs-texto. | +| [`shuma-link`](sandbox/shuma-link/README.md) | Transporte autenticado (handshake + canal cifrado Noise). | +| [`shuma-sysmon`](sandbox/shuma-sysmon/README.md) | Monitor de sistema embebido. | +| [`shuma-card`](sandbox/shuma-card/README.md) | Workspaces + `PipelineSpec` (DAG de comandos). | +| [`shuma-module`](sandbox/shuma-module/README.md) | Trait módulo del chasis (+ `Source`: local / daemon Unix / daemon TCP / SSH / container). | +| [`shuma-module-shell`](sandbox/shuma-module-shell/README.md) | Módulo shell (Main slot). | +| [`shuma-module-commandbar`](sandbox/shuma-module-commandbar/README.md) | Módulo command bar (TopBar). | +| [`shuma-module-launcher`](sandbox/shuma-module-launcher/README.md) | Módulo launcher (DrawerTab). | +| [`shuma-module-canvas`](sandbox/shuma-module-canvas/) | Lienzo de Contexto: el `SessionGraph` como grafo visual. | +| [`shuma-module-minga`](sandbox/shuma-module-minga/README.md) | Visualizador del repo Minga del cwd. | +| [`shuma-module-matilda`](sandbox/shuma-module-matilda/README.md) | Módulo matilda integrado. | +| [`shuma-agente`](sandbox/shuma-agente/) + [`-host`](sandbox/shuma-agente-host/) | El núcleo de la IA conversacional (sync, sin red) y el host que corre un turno. | +| [`shuma-module-agente`](sandbox/shuma-module-agente/) | El panel de chat multi-agente. | +| [`shuma-consola-core`](sandbox/shuma-consola-core/) + [`-host`](sandbox/shuma-consola-host/) + [`-client`](sandbox/shuma-consola-client/) | La consola de sesiones agénticas: núcleo puro, registro de sesiones vivas y cliente HTTP contra el gateway. | +| [`shuma-module-consola`](sandbox/shuma-module-consola/) | La UI Llimphi de esa consola. | +| [`shuma-voz-ui`](sandbox/shuma-voz-ui/) | El indicador de escucha por voz, compartido entre superficies. | + +La superficie de terminal reusable vive en llimphi: `llimphi-widget-terminal` (`02_ruway/llimphi/widgets/terminal`) y `llimphi-module-shuma-term` (`02_ruway/llimphi/modules/shuma-term`, terminal embebible estilo Ctrl+` para cualquier app Llimphi). ## Crates: matilda (declarative host config) | Crate | Rol | |---|---| -| [`matilda-core`](matilda/matilda-core/README.md) | Modelo de config declarativa. | -| [`matilda-config`](matilda/matilda-config/README.md) | Loader de archivos. | -| [`matilda-plan`](matilda/matilda-plan/README.md) | Planificador de diff (estado actual → deseado). | -| [`matilda-apply`](matilda/matilda-apply/README.md) | Ejecutor del plan. | -| [`matilda-discover`](matilda/matilda-discover/README.md) | Descubrimiento de estado actual. | -| [`matilda-linker`](matilda/matilda-linker/README.md) | Enlaza dotfiles. | -| [`matilda-ghost`](matilda/matilda-ghost/README.md) | Modo dry-run. | -| [`matilda-app`](matilda/matilda-app/README.md) | CLI/UI. | +| [`matilda-core`](baremetal/matilda-core/README.md) | Modelo de config declarativa. | +| [`matilda-config`](baremetal/matilda-config/README.md) | Loader de archivos. | +| [`matilda-plan`](baremetal/matilda-plan/README.md) | Planificador de diff (estado actual → deseado). | +| [`matilda-apply`](baremetal/matilda-apply/README.md) | Ejecutor del plan. | +| [`matilda-discover`](baremetal/matilda-discover/README.md) | Descubrimiento de estado actual. | +| [`matilda-linker`](baremetal/matilda-linker/README.md) | Enlaza dotfiles. | +| [`matilda-ghost`](baremetal/matilda-ghost/README.md) | Modo dry-run. | +| [`matilda-app`](baremetal/matilda-app/README.md) | CLI/UI. | +| [`matilda-android`](baremetal/matilda-android/) | matilda desde el bolsillo: frontend móvil (Llimphi sobre Android NativeActivity) del admin de servidores. | ## Consideraciones -- **Reemplazo, no añadido.** Si usás shuma, podés desinstalar zsh/tmux/mosh; todo el comportamiento está cubierto. +- **Reemplazo, no añadido.** Si usas shuma, puedes desinstalar zsh/tmux/mosh; todo el comportamiento está cubierto. - **`intent → comando`** es opcional; sin LLM corre el shell tradicional sin diferencia. -- Sesiones remotas usan **`shuma-protocol`** sobre TCP/TLS — no requiere demonio SSH. +- Las sesiones remotas van por **`shuma-daemon` sobre TCP, cifrado y autenticado con Noise XK** (`shuma-link`, pinning de peers conocidos) — no requiere demonio SSH ni TLS/CA. `shuma-protocol` es el framing del wire (length-prefix + postcard). + +## Estado (2026-06-09) + +- **La superficie de terminal es el path de render por defecto** (SDD-TERMINAL fases 0–5: store de scrollback append-only, modo línea virtualizado, bloques de comando + chrome, selección/copy + find con Ctrl+F, grilla de celdas GPU detrás de `SHUMA_GPU_GRID=1`). El pane legacy queda accesible con `SHUMA_TERMINAL_LEGACY=1`. El scrollback persistente derrama a disco; `:scrollback` / `:scrollback grep ` inspeccionan el archivo. Ver [SDD-TERMINAL.md](SDD-TERMINAL.md). +- **Workspaces con engines de aislamiento reales**: `unshare` (default), `bwrap`, `podman` — un workspace puede correr dentro de un contenedor OCI de verdad (`Source::Container`), elegible en el form de sesión. +- **`sudo` funciona**: `shuma-askpass` es un popup Llimphi compatible `SUDO_ASKPASS`, así que `sudo` pelado ya no cuelga. +- **Streaming de output en vivo** (progress bars, bytes recibidos), sub-collapsables por comando (`ls -R`) y tablas ordenables (`ls -l`). +- **Cards y pipelines**: `shuma-card` modela workspaces y DAGs `PipelineSpec` (comandos unidos por flow edges) que sirve el daemon. +- **PTY/TUI remoto full-duplex** sobre el canal cifrado del daemon (Unix socket local, TCP Noise XK remoto). +- La superficie reusable vive en `02_ruway/llimphi/widgets/terminal` (`llimphi-widget-terminal`); `llimphi-module-shuma-term` embebe un terminal estilo Ctrl+` en cualquier app Llimphi. diff --git a/02_ruway/shuma/MATILDA.md b/02_ruway/shuma/MATILDA.md new file mode 100644 index 0000000..44ad232 --- /dev/null +++ b/02_ruway/shuma/MATILDA.md @@ -0,0 +1,168 @@ +# MATILDA.md — el bloque de matilda como superficie de administración + +> Análisis 2026-06-13. matilda = administración **declarativa** de +> servidores (contenedores Docker + vhosts de proxy reverso), montada como +> tab del chasis de shuma (`sandbox/shuma-module-matilda`). Este documento +> separa lo que ya hace de lo que falta para "administrar +> servidores/servicios/contenedores/monitoreo efectivamente desde shuma". + +## Qué es hoy (verificado contra el código) + +matilda es un reconciliador deseado-vs-actual, tipo NixOS/Ansible mínimo: + +| Capa | Crate | Hace | +|---|---|---| +| Modelo declarativo | `matilda-core` | `Inventory { hosts, containers, vhosts }`; `Container { image, ports, env, volumes, restart }` | +| Observación | `matilda-discover` | lee `docker ps` + `/etc/nginx/sites-enabled`; **drift** real por `docker inspect` (imagen/puerto/env/volumen/restart) | +| Diff | `matilda-plan` | `actual → deseado` → `Vec` (Create/Update/Remove) ordenado por dependencia | +| Ejecución | `matilda-apply` / `matilda-ghost` | aplica / dry-run; cada paso loguea | +| Transporte | `matilda-linker` | SSH (discover + apply remotos) | +| Carga | `matilda-config` | `matilda.toml` + includes | +| UI | `shuma-module-matilda` | tab inventario\|plan+log, shortcuts Discover/Plan/Dry-run/Apply/Reload, monitores | + +El flujo declarativo (discover→plan→dry-run→apply, local y por SSH) **está +completo y es sólido**. El drift detection ya existe (no era obvio desde los +LEEME). Lo que faltaba no es *reconciliación* sino *operación en vivo*. + +## El eje nuevo: monitoreo runtime (arrancado 2026-06-13) + +El monitor del bloque sólo contaba "pasos de plan pendientes" — útil para +saber si el servidor está al día, inútil para saber si **algo se cayó**. +Primer ladrillo entregado: + +- `matilda-discover`: `RunState` (running/exited/paused/…), `ContainerStatus + { name, image, state, status, ports }`, `RuntimeState` (con `up_count`/ + `down_count`/`container(name)`), `parse_docker_ps` (formato rico tab- + separado `DOCKER_PS_FORMAT`) y `discover_runtime()` local. Puro + testeado. +- `shuma-module-matilda`: `State.runtime`, `Msg::SetRuntime`, el `Discover` + local captura runtime además del inventario, el panel pinta cada contenedor + con semáforo (`●` vivo / `○` parado, coloreado) + el `status` de Docker, + lista **huérfanos** (corren fuera del inventario), y un segundo monitor + `matilda · up` samplea `(up, down)`. Verificado headless + (`examples/runtime_monitor.rs`). + +## Lo que falta — roadmap para "administrar efectivamente" + +Ordenado por palanca. Todo determinista; nada exige LLM. + +### M1. Acciones por contenedor (lifecycle dirigido) ✅ (2026-06-13) +`matilda-apply::lifecycle::ContainerAction` (Start/Stop/Restart/Logs/Stats/ +Remove) con `command()`/`is_mutating()` puros. El bloque hace las filas +clickeables → barra de acciones; ejecución local (`sh -c`, captura al log) + +`container_action_remote_blocking` (SSH) para el chasis; tras acción mutante +re-observa el runtime. + +### M2. Logs y stats en vivo ✅ (2026-06-13 on-demand · series CPU/mem 2026-06-21) +Acciones `Logs` (`docker logs --tail 200`) y `Stats` (`docker stats +--no-stream`) vuelcan al log del bloque. +**Series CPU/mem ✅ (2026-06-21):** `matilda-discover` gana `ContainerStats +{cpu_pct,mem_pct}` + `DOCKER_STATS_FORMAT` + `parse_docker_stats` + +`discover_stats()`. El módulo guarda un ring por contenedor +(`stats_history`, cap `STATS_HISTORY_CAP=40`) alimentado por el polling +(`source_stats_remote_blocking` local/SSH → `Msg::SetStatsQuiet`, +silencioso); el chasis sólo lo muestrea **si hay un contenedor seleccionado** +(`docker stats` es caro y la sparkline sólo se pinta bajo el seleccionado). +La fila del contenedor seleccionado muestra `CPU x% ▁▂▅▇▆▃ MEM y%` — +sparkline de bloques Unicode (`sparkline()` puro, auto-escala al máximo +observado). Funciona local y remoto (Source montado). +**Live-tail `docker logs -f` ✅ (2026-06-21):** se destrabó extendiendo la capa +SSH. `shared/ssh::SshSession::exec_streaming` (nuevo) corre un comando de larga +vida y entrega cada chunk por callback a medida que llega, con `should_stop` +chequeado cada `poll` (cierra el canal → SIGHUP al proceso remoto); expuesto en +`matilda-linker::exec_streaming`. El módulo gana `stream_logs_blocking(source, +name, tail, stop, on_line)` (local = subproceso `sh -c … 2>&1`; remoto = canal +SSH, líneas re-ensambladas por `LineSplitter`) + estado `LogStream{container, +lines (cap 500), stop: Arc, ended}`. La barra de acciones del +contenedor gana **Tail ▶**: emite `StartLogStream`; el chasis lee el `stop` que +el módulo creó y lanza un **thread crudo** (no `handle.spawn`: emite N msgs en +el tiempo) que dispatcha `LogStreamLine` por línea y `LogStreamEnded` al cerrar. +Una card bajo el contenedor muestra las últimas 12 líneas en vivo + `Stop ⏹`. +Probado por partes (LineSplitter, handlers, corte por bandera); el live real +necesita docker/host (degradación: sin docker, `2>&1` emite el error y cierra). + +### M3. Servicios systemd ✅ (2026-06-13, runtime + acciones + declarativos) +**Runtime:** `matilda-discover` `ServiceState`/`ServiceStatus` + +`parse_systemctl_units` + `discover_services()` (running,failed); +`RuntimeState.services`. El bloque muestra la sección SERVICES (semáforo +●/✖/○ + sub + descripción) con barra de acciones (`ServiceAction`: +start/stop/restart/enable/disable/status). +**Declarativos:** `matilda_core::Service { unit, enabled, active }` + +`Inventory.services`; `matilda-plan` `Resource::Service` (diff +Create/Update/Remove); `matilda-apply` genera los `systemctl +enable --now / disable / start / stop` (combina `--now` cuando enable+active +coinciden); `matilda-discover` consulta `is-enabled`/`is-active` por unidad +declarada para el drift (sólo administra las declaradas, no las cientos del +sistema). El panel lista "SERVICES declarados" con sus flags y si corren. El +loop declarar→plan→apply→runtime queda cerrado. +**Discovery remoto de drift de servicios ✅ (2026-06-21):** `fetch_remote_ +inventory` ya no hardcodea `services: Vec::new()`. Sondea `is-enabled`/ +`is-active` de TODAS las unidades declaradas en **un solo round-trip** (un loop +shell `for u in …; do printf …; done`, `remote_service_probe_command` + +`parse_service_states` en `matilda-discover`), llena `ServerState.services` y +deja que `observed_inventory` arme el `current`. Resultado: el plan remoto +emite **Update** sobre drift real (p. ej. declarado active pero inactive) en +vez de un **Create** espurio. Tests del payoff con `plan` (coincide→0 acciones, +drift→1 Update). + +### M4. Polling periódico real ✅ (2026-06-13 local · 2026-06-21 remoto) +El chasis poll-ea `poll_runtime()` cada 5 s en un thread para las instancias +matilda Local (topbar/bottombar/main) → `Msg::SetRuntimeQuiet`. El semáforo +queda vivo sin pulsar Discover. **Remoto ✅ (2026-06-21):** el Source montado +remoto se re-observa por SSH a la cadencia lenta (~30 s, no cada 5 s porque el +fetch es caro) vía `poll_matilda_remote_runtime` + `source_runtime_remote_ +blocking` (factorizado con el fetch de flota en `fetch_remote_runtime`), +silencioso (`SetRuntimeQuiet`) y con guard atómico (`runtime_poll_inflight`) +contra el apilamiento si el host queda colgado. Un fallo de SSH se deja pasar +silencioso y el próximo tick reintenta. + +### M5. Multi-host fan-out ✅ (2026-06-13, monitoreo de flota) +`matilda_core::Host` gana `user`/`port` SSH (default root/22). El bloque tiene +`fleet: BTreeMap` + +`selected_host`; el shortcut **Fleet** hace que el chasis spawnee un thread +por host declarado (`host_runtime_remote_blocking`: SSH + `docker ps` + +`systemctl` + `ls sites-enabled`, reusando los parsers) y reenvíe +`SetHostRuntime`/`SetHostError`. La sección FLEET pinta cada host con +semáforo (●/◐/✖/◌) + resumen up/down/svc o el error, y al seleccionarlo +expande sus contenedores/servicios (grilla "host × estado", read-only). +**Acciones sobre la flota ✅ (2026-06-21):** dentro del host expandido, cada +contenedor/servicio es clickeable → abre una barra de acciones **remotas**. +El click emite `FleetContainerAction`/`FleetServiceAction { host, name, action }`; +el módulo sólo deja la intención en el log y el chasis corre el `docker`/ +`systemctl` por SSH contra ESE host (`fleet_container_action_blocking`/ +`fleet_service_action_blocking`, exit code real vía `; echo __rc:$?`). Si la +acción fue mutante y exitosa, re-observa el host (`host_runtime_remote_blocking`) +y refresca su `FleetEntry` con `FleetActionDone { lines, runtime }` — el +semáforo queda al día sin re-pulsar «Fleet». La selección de recurso es +scoped al host (se limpia al cambiar de host expandido). +**Polling de la flota ✅ (2026-06-21):** una vez que el usuario activó la flota +(pulsó «Fleet»), el chasis re-observa cada host por SSH cada ~30 s +(`poll_matilda_fleet` en el `Tick`, cadencia más lenta que el runtime local +porque un fetch SSH por host es caro) y reenvía resultados **silenciosos** +(`SetHostRuntimeQuiet`/`SetHostErrorQuiet`: refrescan el `FleetEntry` sin +loguear ni parpadear a «consultando»). Un guard por host +(`fleet_poll_inflight`, compartido con el thread, que se borra a sí mismo al +terminar) evita que un host colgado acumule threads tick tras tick. + +**Acciones del Source montado remoto ✅ (2026-06-21):** la barra de acciones +de CONTAINERS/SERVICES sobre un Source remoto antes sólo logueaba "delegado al +chasis" y no ejecutaba nada. Ahora el chasis intercepta `ContainerActionMsg`/ +`ServiceActionMsg` cuando el source es remoto y corre el comando por SSH +(`container_action_remote_blocking`/`service_action_remote_blocking`), +volcando la salida por `Msg::LogLines`. + +### M6. Drift visible en la UI ✅ (2026-06-13) +El contenedor que el discover marcó `(desviado)` lleva un chip `⚠ drift` en +su fila — el operador lo ve sin leer el plan. + +## Estado + +M1–M6 entregados 2026-06-13 (varios con el alcance acotado anotado arriba). El +tab pasó de "visor declarativo" a **consola de operación viva de una flota**: +ves qué corre y qué se cayó en cada host, operas el host montado sin bajar a la +terminal, y reconcilias contenedores/vhosts/servicios declarativamente. Operas +también recursos de cualquier host de la flota sin montarlo (M5, 2026-06-21). +**M1–M6 COMPLETOS** al 2026-06-21. La consola matilda no tiene pendientes de +roadmap: polling (local/remoto/flota), acciones (local/Source-remoto/flota), +series CPU/mem con sparkline, live-tail `docker logs -f` (local y remoto, vía +streaming SSH) y discovery de drift de servicios remotos por SSH — todo +cerrado. Lo que reste será pulido o features nuevas, no huecos del plan. diff --git a/02_ruway/shuma/README.md b/02_ruway/shuma/README.md index 4f1b379..4a0eb23 100644 --- a/02_ruway/shuma/README.md +++ b/02_ruway/shuma/README.md @@ -2,7 +2,9 @@ > Interactive shell with zsh/fish parity, on a Llimphi chassis. -`shuma` replaces zsh + tmux + mosh with a single piece: shell with history/completion/job-control, native multiplexing (no `tmux`), remote sessions (no `mosh`), all inside a Llimphi 4-slot chassis (TopBar, Main, BottomBar, DrawerTab + Quake drawer). 8-block roadmap (target 2026-05-25). `matilda` is the sibling tool for declarative multi-host configuration. +![a shuma session over the block surface: ls -l recognized as a sortable table, ls -R split into collapsible sub-blocks per directory, and a live streaming command over a real process](https://tawasuyu.net/02_ruway/shuma/pantallazo.png) + +`shuma` replaces zsh + tmux + mosh with a single piece: shell with history/completion/job-control, native multiplexing (no `tmux`), remote sessions (no `mosh`), all inside a Llimphi 4-slot chassis (TopBar, Main, BottomBar, DrawerTab + Quake drawer). The terminal surface is its own render path (append-only scrollback store, virtualized line mode, command blocks, selection/copy and find, GPU cell grid) — see [SDD-TERMINAL.md](SDD-TERMINAL.md). `matilda` is the sibling tool for declarative multi-host configuration. ## Install @@ -15,13 +17,23 @@ cargo run --release -p shuma-daemon ## Compatibility - **Linux / macOS / Windows** — shell + Llimphi UI. -- **Wawa** — runs inside the kernel. -- `shuma-protocol` enables local-client + remote-server without SSH. +- **Wawa** — planned (no kernel-side port yet). +- `shuma-daemon` + `shuma-protocol` enable local-client + remote-server without SSH. -Crates listed in [README.md](README.md) (shuma + matilda). +Crates listed in [LEEME.md](LEEME.md) (shuma + matilda). ## Considerations - **Replacement, not addition.** If you use shuma, you can uninstall zsh/tmux/mosh; behavior fully covered. - **`intent → command`** is optional; without LLM the traditional shell runs unchanged. -- Remote sessions use **`shuma-protocol`** over TCP/TLS — no SSH daemon required. +- Remote sessions go through **`shuma-daemon` over TCP, encrypted and authenticated with Noise XK** (`shuma-link`, known-peers pinning) — no SSH daemon and no TLS/CA required. `shuma-protocol` is the wire framing (length-prefix + postcard). + +## Status (2026-06-09) + +- **Terminal surface is the default render path** (SDD-TERMINAL phases 0–5: append-only scrollback store, virtualized line mode, command blocks + chrome, selection/copy + Ctrl+F find, GPU cell grid behind `SHUMA_GPU_GRID=1`). Legacy pane stays reachable with `SHUMA_TERMINAL_LEGACY=1`. Persistent scrollback spills to disk; `:scrollback` / `:scrollback grep ` inspect the archive. See [SDD-TERMINAL.md](SDD-TERMINAL.md). +- **Workspaces with real isolation engines**: `unshare` (default), `bwrap`, `podman` — a workspace can run inside an actual OCI container (`Source::Container`), selectable in the session form. +- **`sudo` works**: `shuma-askpass` is a `SUDO_ASKPASS`-compatible Llimphi popup, so bare `sudo` no longer hangs. +- **Live streaming output** (progress bars, byte counters), per-command sub-collapsibles (`ls -R`) and sortable tables (`ls -l`). +- **Cards & pipelines**: `shuma-card` models workspaces and `PipelineSpec` DAGs (commands joined by flow edges) served by the daemon. +- **Remote PTY/TUI is full-duplex** over the encrypted daemon channel (Unix socket locally, Noise-XK TCP remotely). +- The reusable surface lives in `02_ruway/llimphi/widgets/terminal` (`llimphi-widget-terminal`); `llimphi-module-shuma-term` embeds a Ctrl+`-style terminal in any Llimphi app. diff --git a/02_ruway/shuma/REPORTE.md b/02_ruway/shuma/REPORTE.md index ea2696b..3e84e44 100644 --- a/02_ruway/shuma/REPORTE.md +++ b/02_ruway/shuma/REPORTE.md @@ -193,7 +193,7 @@ Forma actual de la config: **Contrato dividido (D3):** - **`shumarc-modules.toml`** (TOML, project-local): topología de la UI del shell — qué módulo se monta en qué slot (TopBar/Main/BottomBar/Drawer), labels custom, Source (Local/Daemon/DaemonTcp/Remote). Esto es estructura de la app y vive con la app. -- **`$XDG_CONFIG_HOME/wawa/config.json`** (JSON, perfil del usuario): preferencias visuales (`theme_variant`, `accent`), locale (`lang`), formato del reloj (`timefmt_24h`), bitmask de qué apps están on (`modules.{shuma, mirada, pluma, …}`). Esto es preferencia del usuario y es compartida por **todas** las apps Llimphi de gioser (pluma, dominium, cosmos, nada, nakui, shuma…). +- **`$XDG_CONFIG_HOME/wawa/config.json`** (JSON, perfil del usuario): preferencias visuales (`theme_variant`, `accent`), locale (`lang`), formato del reloj (`timefmt_24h`), bitmask de qué apps están on (`modules.{shuma, mirada, pluma, …}`). Esto es preferencia del usuario y es compartida por **todas** las apps Llimphi de tawasuyu (pluma, dominium, cosmos, nada, nakui, shuma…). El toggle `modules.shuma = false` en el JSON wawa no apaga el binario corriendo (el chasis no se suicida); el efecto es que los launchers no listan a shuma como app activa. La supervisión del binario en sí es decisión del SO (wawa-init en el futuro arje, o systemd/manual hoy). @@ -265,7 +265,7 @@ F3. Editor multi-línea: `shuma-line::continuation::needs_continuation` ya está - **El binario `shuma-shell` GPUI (3.7k LOC) ya no existe** — se borró en `b92b643`. Cualquier referencia a "shuma-shell" en docs viejas es a esa versión. Las features grandes (completion, decoración, historial) viven en sandbox/* sueltas, no en un shell ensamblado. - **`russh v0.54.5`** dispara warning de future-incompat — no bloquea, llega vía `matilda-linker`. -- **`gpui extinto en gioser** (memoria del proyecto): nada nuevo sobre GPUI. Todo gráfico es Llimphi. +- **`gpui extinto en tawasuyu** (memoria del proyecto): nada nuevo sobre GPUI. Todo gráfico es Llimphi. - **El módulo matilda en remoto SÍ ejecuta SSH real** (vía `matilda-linker`/`brahman-ssh-multiplex`); las pruebas reales necesitan un servidor con sshd alcanzable. - **`shuma-line::decorate` ya hace mucho** (paths clickeables, URLs, SHAs, grep refs) pero ningún consumidor lo usa hoy — fácil ganancia al cablearlo a `shuma-module-shell`. @@ -364,4 +364,4 @@ wc -l 02_ruway/shuma/sandbox/*/src/*.rs --- -*Generado por Claude (Opus 4.7) — `2026-05-27`. Si el plan cambia, actualizá la tabla de la §8 antes de tocar la §3.* +*Generado por Claude (Opus 4.7) — `2026-05-27`. Si el plan cambia, actualiza la tabla de la §8 antes de tocar la §3.* diff --git a/02_ruway/shuma/SDD-HISTORIAL.md b/02_ruway/shuma/SDD-HISTORIAL.md new file mode 100644 index 0000000..355c758 --- /dev/null +++ b/02_ruway/shuma/SDD-HISTORIAL.md @@ -0,0 +1,156 @@ +# SDD-HISTORIAL.md — la memoria del shell como grafo que aprende + +> Plan pedido el 2026-07-22, a raíz de una sesión en la que el autocompletado +> dejó de ofrecer `claude --dangerously-skip-permissions` pese a ser el comando +> más tecleado del usuario. Estado: **plan comprometido, no implementado**. +> Las Fases 0 están HECHAS (2026-07-22) y la **Fase 1 también (2026-07-25)**; +> de la Fase 2 en adelante, nada. + +## Por qué existe este documento + +El diagnóstico que lo originó, con números medidos y no supuestos: + +| Hallazgo | Medición | +|---|---| +| El historial de zsh **nunca se importó** | `~/.zsh_history` no es UTF-8 (zsh metafica los bytes ≥ 0x80) y el importador usaba `read_to_string`, que fallaba y salteaba la fuente en silencio. 9.913 líneas invisibles, con **446 usos** del comando más frecuente del usuario | +| El 78% del historial era basura | 25.596 de 32.851 entradas eran la misma importación de bash (68 líneas distintas) reescrita ~376 veces | +| Los tests escribían en el historial real | `State::new` abría `~/.local/share/shuma/history.jsonl`; 277 entradas con `cwd: /repo` | +| La ventana de sugerencias mira 2.000 entradas | El último uso del comando estaba en la 28.883 de 32.851 — fuera de alcance | + +Ninguno era el disco lleno, que era la sospecha inicial. Pero el hallazgo más +importante es **estructural**, no un bug: + +> **`~/.zsh_history` es un búfer circular, no un archivo.** Con +> `SAVEHIST=10000` zsh recorta reescribiendo el fichero entero, y con +> `histexpiredupsfirst` expira duplicados primero. El archivo del usuario +> estaba en 9.913 de 10.000. Su historial se da vuelta cada pocas semanas — +> por eso "se le pierde seguido". + +De ahí la tesis: **la db de shuma tiene que ser el archivo durable que +sobrevive a la ventana de zsh**, no un espejo de ella. Y si va a ser durable, +que además sea útil: que mida, agrupe y prediga. + +## Lo que ya está hecho (Fase 0 — 2026-07-22) + +- **Importar zsh de verdad**: `desmetaficar()` + lectura por bytes. Un acento + ya no hace perder el historial entero. +- **Marca de agua por timestamp** (`SourceState::ultimo_ts`): sobrevive a la + reescritura por recorte. El contador de líneas suponía append-only, y esa + suposición es falsa en zsh — fue lo que multiplicó 68 líneas por 376. +- **Los tests no escriben en casa**: `ruta_historial()` respeta + `SHUMA_HISTORY_PATH`, usa un temporal bajo `cfg(test)`, y la importación se + apaga (`importacion_permitida()`). Verificado: la suite corre y el md5 del + historial del usuario no cambia. + +La ventana de 2.000 (`GHOST_CORPUS_WINDOW`, `LINE_SUGGEST_WINDOW`) que quedaba +pendiente **ya no existe**: se hizo la Fase 1 el 2026-07-25 (ver abajo). + +## El estado del arte, y qué tomar de cada uno + +| Sistema | Lo que hace bien | Qué tomar | +|---|---|---| +| **fish** | Autosuggestion por prefijo sobre historial + "por directorio primero" | Ya lo tenemos (ghost). Su lección real: **la sugerencia se acepta con →**, sin modal | +| **atuin** | Historial en SQLite con cwd/exit/duración/host/sesión, sincronizado y cifrado | El **esquema**: registrar exit, duración y sesión. Hoy `exit` está siempre en `None` | +| **zsh-autosuggestions** (strategy `match_prev_cmd`) | Sugiere según **el comando anterior**, no sólo el prefijo | Bigramas de comandos. Es la base del "grupo por directorio" | +| **nushell** | Historial estructurado; los comandos devuelven datos, no texto | A largo plazo. Cruza con `nakui_sheet` y el `:compara` de pluma | +| **Warp / Fig** | Completado **por especificación** del comando (subcomandos, flags, argumentos tipados) | La Fase 4. Hay ~600 specs libres de Fig reutilizables | +| **McFly** | Reordena el historial con una red neuronal chica (contexto: dir, últimos comandos, exit) | El **ranking**, no la red: los mismos rasgos alcanzan con un modelo lineal explicable | + +Regla que hereda de `INTELIGENCIA.md` y que este plan respeta: +**determinista primero, LLM opcional después**. Nada de lo que sigue necesita +red ni modelo. + +## El modelo de datos + +Hoy `shuma-history` es un `.jsonl` append-only de `Entry { line, cwd, started }`. +Sirve para un historial; no para aprender. Lo que hace falta es un **grafo con +contadores**, no una lista. + +``` +Comando { verbo, usos, ultimo_uso, exito, fallo } +Invocacion{ linea_completa, verbo, args[], cwd, started, dur_ms, exit, sesion } +Lugar { cwd, usos, verbos_top[] } -- qué se hace en cada directorio +Arista { verbo_a -> verbo_b, cwd, veces } -- bigrama CONDICIONADO al lugar +Argumento { verbo, forma(flag|ruta|literal), valor, veces, cwd? } +``` + +Las cuatro preguntas que este esquema tiene que contestar barato — y que son, +literalmente, el pedido del usuario: + +1. *"Escribo `cd tawasuyu`; ¿qué suelo hacer después ahí?"* → `Arista` + filtrada por `cwd`. Su caso real: `cd tawasuyu` → `export` del proxy → `claude`. +2. *"Escribo `claude`; ¿qué parámetros suelo ponerle?"* → `Argumento` por verbo, + ordenado por `veces`, con los del `cwd` actual primero. +3. *"¿Qué comando quiero, entre mil parecidos?"* → ranking por frecuencia × + recencia × afinidad-con-el-lugar, no por "el más nuevo". +4. *"¿Qué opciones acepta este comando que nunca usé?"* → catálogo externo + (Fase 4), no historial. + +**Dónde vive.** `sled` ya está en el repo y `~/.config/shuma/agente.sled` ya +existe: un árbol por entidad (`comandos`, `lugares`, `aristas`, `argumentos`) +con claves ordenadas para poder hacer prefix-scan. El `.jsonl` **se conserva** +como registro crudo y auditable — la db es un índice derivado y reconstruible. +Que sea reconstruible es la propiedad importante: si el esquema cambia o el +índice se corrompe, se rehace del crudo sin pérdida. + +## Fases + +**Fase 1 — Corpus deduplicado y cacheado. HECHA (2026-07-25).** Sacar la ventana +de 2.000: líneas distintas, más reciente primero, reconstruido cuando el +historial crece, no por pulsación. Barato, resuelve el síntoma que originó todo, +y no compromete el esquema. Cómo quedó, con lo que se aprendió al implementarla: + +- Vive en `shuma-module-shell/src/update/corpus.rs`; el caché es + `State::corpus` (`Arc>`). Cada línea guarda **su cwd** — + el del uso más reciente— porque el ranking local-antes-que-global (A3) lo + necesita, así que no es un `Vec` pelado como decía el plan. +- Tres disparos, no uno: al construir el `State`, en `refresh_patterns` y + **perezoso al leer**. El tercero no estaba en el plan y hace falta: el + historial también crece por la importación de zsh, que no pasa por + `refresh_patterns`. De ahí el `Mutex` (el camino del tecleo recibe `&State`). +- Los locks van siempre historial→caché y el del historial es `try_lock`: + antes que arriesgar un deadlock en el camino del tecleo, se sirve el corpus + un frame viejo. +- **Una marca de agua numérica sola no alcanza.** Si el objeto historial se + reemplaza por otro más largo, extender «desde `seen`» saltea el prefijo del + nuevo en silencio. El caché guarda un **ancla** (la línea en `seen-1`) y + rehace entero cuando no coincide. +- Costo por frame más BAJO que antes: se filtra por prefijo dentro del corpus + en vez de clonar la ventana entera por render. +- Certificado: `cargo test -p shuma-module-shell --lib` 338/338, con el test que + asertaba la ventana **dado vuelta**. + +**Fase 2 — Registrar lo que no se registra.** `exit`, `dur_ms` y `sesion` en +cada `Invocacion`. Hoy `exit` es siempre `None` y por eso `infer_records` trata +todo como éxito. Sin esto no se puede aprender: un comando que falla siempre no +debería sugerirse jamás. + +**Fase 3 — El grafo y el ranking.** Los cuatro árboles, alimentados desde el +crudo. Ranking explicable: `score = log(1+usos) · recencia · afinidad_cwd · +tasa_exito`. Explicable importa — el usuario tiene que poder preguntar *por qué* +le ofreciste eso, y un `:por-que` que conteste con los cuatro factores es +verificable; una red no. + +**Fase 4 — Completado por especificación.** Catálogo de verbos conocidos +(subcomandos, flags, tipo de argumento). Empezar por los propios (`cargo`, +`git`, `pacman`, `claude`) y los que ya declaran completions en +`~/.config/shuma/completions/`. Fusionar con lo aprendido: **lo que el usuario +usa va primero, lo que el comando acepta va después**, marcado distinto. + +**Fase 5 — Grupos por lugar.** Materializar el caso del usuario: al entrar a un +directorio donde hay una coreografía frecuente, ofrecerla como grupo de un +toque. Se apoya en `shuma-infer` (patrones emergentes) y en `:save`/F1–F8, que +ya existen — cablear, no inventar. + +## Riesgos anotados + +- **Privacidad.** El historial lleva rutas, hosts y a veces secretos tipeados. + Nada de esto sale de la máquina. `histignorespace` de zsh (comando con espacio + al frente = no se guarda) tiene que respetarse también acá. +- **Costo por pulsación.** Es lo que motivó las ventanas de 2.000. La regla: + el camino del tecleo **lee índices**, nunca recorre el crudo. +- **Que el índice mienta.** Reconstruible desde el `.jsonl`, siempre. Y un + `:historial verificar` que lo rehaga y compare. +- **Aprender basura.** Lo aprendido no puede venir de fixtures ni de + importaciones duplicadas — precisamente lo que rompió esto. La Fase 0 es el + requisito de todas las demás. diff --git a/02_ruway/shuma/SDD-TERMINAL.md b/02_ruway/shuma/SDD-TERMINAL.md new file mode 100644 index 0000000..b1e4dc1 --- /dev/null +++ b/02_ruway/shuma/SDD-TERMINAL.md @@ -0,0 +1,359 @@ +# SDD — superficie de terminal infinita y supereficiente + +> Estado: **implementado — Fases 0-5 ✅** (ver §Estado al final) · diseño 2026-06-05, forjado al 2026-06-07. +> Idioma del repo: español. Reemplaza, por fases, el `output_pane` actual del shell Llimphi. + +## Tesis + +El shell de tawasuyu existe para **desplanar la terminal**: el output no es un volcado +plano, es contenido vivo que se despliega en la ventana a medida que se genera. Hoy +eso se logra con cards IDE (numeración, color, selección, badges) — pero el control +**no escala**: capa a ~500 líneas y pinta *todo lo que hay*, no *lo que se ve*. + +La apuesta: una **superficie de terminal** virtualizada que (a) sostiene scrollback +**ilimitado** a costo de render **constante**, (b) sirve tres modos sobre la misma +tela — **línea** (IDE: numerada, selectable), **grilla** (alt-screen TUI), **híbrido** +(PTY en modo líneas) — y (c) usa **GPU directo** exactamente donde paga (la grilla y +los floods), vello donde alcanza (chrome + línea virtualizada). Sin render plano, +jamás. + +## Principios irrenunciables (el norte, no se negocia) + +Lo que define este control y lo separa de una terminal cualquiera (pedido explícito +del usuario, 2026-06-05): + +1. **Nunca plano.** El output JAMÁS es un volcado de texto crudo. Siempre es contenido + estructurado y vivo (bloques, numeración, color, chrome). Si algo cae a render + plano, es un bug, no un fallback aceptable. +2. **Interactivo y dinámico.** Se despliega a medida que se genera (streaming), se + colapsa/expande, se scrollea fluido, responde al mouse y al teclado. No es estático. +3. **Menú contextual + clipboard de primera clase.** Selección moderna (arrastre, + doble/triple-click), copiar/pegar **nuestro** (no el del terminal crudo), menú de + botón-derecho con acciones — en TODOS los modos (línea, grilla, híbrido), no sólo en + líneas. +4. **Emula TUIs.** Las apps de pantalla completa (vim/htop/less/…) corren de verdad, + con su grilla de celdas, dentro de la misma superficie — no como un terminal opaco + "por un vidrio", sino integradas y (donde aplique) con nuestra selección/copia. + +Todo lo de abajo está al servicio de estos cuatro puntos. + +## La limitación actual (el porqué de este SDD) + +- `MAX_OUTPUT_LINES = 500` (`shuma-module-shell/src/lib.rs`): el buffer se capa. Si no, + el render explota. +- `output_pane` (`view.rs`) arma **un text-editor por comando**, cada uno pintando + **todas** sus líneas (subimos el cap embebido a `EMBEDDED_LINE_CAP = 512`), y el + panel **traslada** todo con un `transform` de scroll. +- Resultado: ~500 Views pintadas por frame es el techo (pared de `wgpu` + `max_*_buffer_binding_size` + costo de layout). Y el modelo "editor por comando + + panel que traslada" fue la fuente de bugs reales (negro al anclar al fondo, + desalineación gutter/contenido por el transform multicolor del compositor — + arreglado en commit `caf37079`). + +**Conclusión:** el techo no es un número a subir, es la arquitectura. Para infinito hay +que **virtualizar** (pintar sólo la ventana visible) y dejar el scroll a UN control, +no a editores anidados que el panel traslada. + +## Arquitectura — capas estrictas + +``` +┌─ Capa 4 · Interacción ────────────────────────────────────────────┐ +│ selección sobre el stream · numeración · find · menú · copy/paste │ +├─ Capa 3 · Render ─────────────────────────────────────────────────┤ +│ GPU-directo (atlas glifos + celdas instanciadas) → grilla/flood │ +│ vello → chrome (cards/badges/colapsables) + modo línea virtualiz. │ +├─ Capa 2 · Virtualización ─────────────────────────────────────────┤ +│ ventana visible (fila inicial..fila final) sobre el viewport; │ +│ sólo esas filas/bloques se materializan en Views/draws │ +├─ Capa 1 · Modelo de bloques ──────────────────────────────────────┤ +│ stream de bloques (comando = header+cuerpo+badge+stages+colapso) │ +│ cada bloque indexa su rango de filas en el store │ +├─ Capa 0 · Store de scrollback ────────────────────────────────────┤ +│ append-only, compacto: bytes + índice de offsets de línea; │ +│ cap por MEMORIA (MB), no por líneas; spill a disco opcional │ +└────────────────────────────────────────────────────────────────────┘ +``` + +Regla dura del repo: **núcleo agnóstico, frontend lo pinta** (Regla 2). Por eso: + +- **`llimphi-widget-terminal`** (crate nuevo, reusable — logs, consolas, no sólo shuma): + Capas 0–4 agnósticas de shuma. No sabe de comandos; sabe de *bloques de filas* con + un `BlockKind` (líneas numeradas / grilla / chrome opaco que el caller pinta). +- **shuma** maneja el modelo de comando (header/badge/stages/reprocess) como + *decoración de bloque* que inyecta al widget; el widget virtualiza y pinta. + +## Principio rector: **un control, los paneles son datos** (no al revés) + +La inversión que hace funcionar todo lo de abajo. En el diseño viejo cada panel +(card de comando) **era un control** con su propio scroll/estado (`text_editor` por +comando) y un contenedor los **trasladaba a todos** con un `transform`. Aquí es al +revés: hay **un solo control** (la superficie) y los paneles son **items de datos** +(`Item::Chrome` / `Item::Lines`) que el control coloca y virtualiza. Las ventajas +—por las que se eligió esta forma, no por estética—: + +1. **Costo de render desacoplado del contenido.** Un único scroller virtualiza: el + costo es ∝ la ventana visible, **no** ∝ la cantidad de paneles ni de líneas. El + modelo "un control por panel" pagaba por *cada* panel siempre — la pared de ~500. +2. **Un scroll, un sistema de coordenadas.** Sin transforms anidados → mata de raíz + la clase de bug clip+transform (negro al anclar, desalineación gutter). Y habilita + la **selección/find sobre todo el stream** (Capa 4) en un único espacio + `(fila global, columna)`, no card-por-card. +3. **Estado mínimo.** Los paneles son datos planos rearmados desde el modelo cada + frame; no hay estado de widget por-panel que sincronizar o que se filtre. + Colapsar/reordenar/insertar = cambiar la lista de items. +4. **La composición GPU encaja (Capa 3).** Como la superficie es dueña de todo el + paint, compone **una** pasada GPU-directo (celdas de grilla) + **una** pasada vello + (líneas/chrome) en una sola escena. Con controles independientes por panel, esa + pasada única sería imposible. + +Precio aceptado (no es gratis): el caller arma el chrome de **todos** los bloques por +frame (O(n_bloques); el control descarta los no visibles) y los paneles pierden estado +local salvo que se modele como dato. Para este dominio —líneas ilimitadas, bloques +acotados a lo que un humano tipea— es claramente conveniente: lo verdaderamente +ilimitado (las líneas) se virtualiza de raíz; los bloques están acotados por +naturaleza. Si algún día importara, el paso es **chrome lazy** (`Fn() -> View` por +item en vez del `View` ya construido). + +## Capa 0 — Store de scrollback + +- **Append-only.** Cada línea (o chunk de bytes del PTY) se appendea. Nunca se + reescribe lo viejo. +- **Compacto.** Texto en un `Vec`/rope; un índice `Vec` (o `Vec` si supera + 4 GB) de offsets de inicio de línea. Acceso a la línea N = O(1). +- **Cap por MEMORIA, no por líneas.** `scrollback_limit_mb` (default generoso, p. ej. + 64 MB ≈ cientos de miles de líneas). Al excederlo, se descarta el principio + (drop-front del rope + reindex). El usuario pidió "infinito"; en la práctica es + "limitado por una memoria que eliges", con **spill a disco** opcional (ya hay + precedente: `:limit`/`:spill` de captura por MB en el shell). +- **Estable bajo append durante scroll** (deuda B del PLAN-OUTPUT): si el usuario + scrolleó arriba y llega output, la posición de lectura se preserva (anclar a un + *line id*, no a px desde el fondo). + +## Capa 1 — Modelo de bloques + +- El stream es una secuencia de **bloques**. Para shuma: un bloque = un comando + (header `$ …` + cuerpo + badge de estado + filas de etapa + estado colapsado). +- Cada bloque conoce su **rango de filas** `[fila_inicio, fila_fin)` en el store y su + `BlockKind`: + - `Lines` — filas de texto numeradas/coloreadas (modo línea, lo común). + - `Grid { rows, cols }` — una grilla de celdas (alt-screen TUI), su contenido vive + en el emulador vt100, no en el store de líneas. + - `Chrome` — un nodo opaco que el caller pinta (header de card, fila de etapas) y + que ocupa un alto fijo conocido. +- **Colapso = el bloque reporta alto 0 para su cuerpo** (sólo su header). La + virtualización lo respeta gratis. + +## Capa 2 — Virtualización (el corazón) + +Dado `scroll_y` y `viewport_h`, el widget calcula la **ventana visible** de filas +globales `[v0, v1)` y materializa **sólo** esas: + +1. Mapa fila-global → (bloque, fila-local) por búsqueda binaria sobre los rangos de + bloque (los bloques son monótonos en filas). +2. Sólo los bloques que intersectan `[v0, v1)` emiten Views/draws. Un `ls -alR` de 1 M + de líneas: si 40 filas caben en pantalla, se materializan ~40 + el chrome de los + bloques visibles. **Costo de render constante**, independiente del scrollback. +3. El scroll es **del widget** (un `scroll_y` interno, no un `transform` del panel + sobre editores altos). Esto evita de raíz el bug clip+transform que ya nos costó. + +Anclaje al fondo (estilo terminal) = `scroll_y` clamp al máximo salvo que el usuario +scrollee arriba; append mantiene el fondo pegado. + +## Capa 3 — Render (dónde entra GPU-directo, con precisión) + +Regla del repo (validada, ver [[project_gpu_directo_bench_pending]]): *datos fijos → +buffer persistente GPU; datos dinámicos → vello*. + +- **Modo línea (lo común): vello alcanza.** 40 filas × layout de texto por frame es + trivial. Numeración, color por runs, selección como rects. **No** necesita GPU + directo. Reusa la maquinaria del `text-editor` (selección/clipboard/find) extraída a + un núcleo compartido, NO duplicada (Regla 2 + un-término-un-artefacto). +- **Modo grilla (TUI) + floods: GPU directo paga.** Una grilla de celdas (htop, vim, + un juego-TUI) redibuja toda la pantalla a alta frecuencia. Patrón: **atlas de glifos + persistente** (cada glifo rasterizado una vez a una textura) + **quads de celda + instanciados** (un draw instanced de `rows*cols` celdas, cada una = índice de glifo + + fg/bg). Es el patrón `GpuPipelines.*` ya validado (141 fps @ 1M instancias en Iris + Xe). Throughput de terminal real, sin generar miles de Views. +- **Chrome (cards/badges/colapsables): vello.** Bordes, gradientes de recencia, + iconos vectoriales — exactamente como hoy. +- **Híbrido (PTY en modo líneas, p. ej. `claude`/`watch`):** modo línea sobre el + screen vt100, virtualizado igual. + +La superficie compone: una pasada GPU-directo para las celdas de grilla visibles + una +pasada vello para texto-línea visible y chrome. Una sola escena. + +## Modos sobre la misma tela + +| Modo | Disparador | Render | Selección | +|---|---|---|---| +| **Línea** | output normal | vello text virtualizado + numeración | rangos de líneas globales | +| **Grilla** | `ESC[?1049h` (alt-screen, señal dura ya detectada) | GPU-directo celdas instanciadas | rectangular por celdas | +| **Híbrido** | PTY sin alt-screen | modo línea sobre el screen vt100 | como línea | + +La detección de modo ya existe en el shell (`is_tui_fullscreen` / alt-screen del parser +vt100); se mueve a la superficie como `BlockKind`. + +## Capa 4 — Interacción + +- **Selección sobre el stream completo** (no por-card): un ancla y una cabeza en + coords de *fila global, columna*. Copia une las líneas del rango desde el store. +- **Numeración** continua o por-bloque (configurable; hoy es por-bloque). +- **Find** (Ctrl+F) sobre el store (búsqueda en bytes, salta scroll a los hits) — deuda + D del PLAN-OUTPUT, aquí nace natural. +- **Menú contextual** (ya hecho, commit `09cd0429`) se reusa. +- **Gancho IA** sobre una selección (depende de [[project_shuma_ctls_ia_busqueda]]). + +## Fases de forja (incremental, cada una verificable headless) + +> **Gotcha de verificación obligatorio** (lección 2026-06-05, costó confianza): todo +> dump de prueba con output alto DEBE simular el **viewport medido y el scroll al +> fondo** (`out_viewport_h` real), o el bug se esconde y se commitea algo roto. + +- **Fase 0 — Store + índice. ✅ (2026-06-05)** Crate `llimphi-widget-terminal` + (`02_ruway/llimphi/widgets/terminal`), módulo `store`: `Scrollback` append-only, + índice de offsets de línea (sentinela), acceso O(1), cap por memoria con recorte + de frente en un `drain`+reindex, ids globales estables (`line_id`/`index_of_id`) + que sobreviven al recorte, numeración 1-based, `slice_text` para copiar, `clear`. + Puro, sin deps de UI. 11 tests (incl. 100k líneas acotadas e indexadas). +- **Fase 1 — Virtualización modo línea. ✅ (2026-06-05)** Capas 1–2 en + `llimphi-widget-terminal::view`: `line_surface` materializa **sólo** la ventana + visible (`visible_window`, pura y testeada) bajo un `scroll_y` **propio del + widget** (no transform de contenido alto — la anti-feature del SDD), con + numeración global 1-based del store, color base + runs + tinte de fondo por + renglón (inyectados por el caller vía `LineStyle`, Regla 2), scrollbar via + `thumb_geometry` dimensionada al alto TOTAL virtual, scroll sub-renglón + (`partial_px`) y painter de medición del viewport. 19 tests (store + ventana). + **Verificado headless** (`examples/dump_terminal.rs`): 1 M de líneas, anclado al + fondo → **38 filas materializadas** (999963..1000000), sin negro, alineado, + costo constante (independiente del scrollback). Falta: enganchar al shell + (Fase 2 trae bloques/chrome y el flag `SHUMA_TERMINAL_SURFACE`). +- **Fase 2 — Bloques + chrome. ✅ (2026-06-05)** Capa 1 en + `llimphi-widget-terminal::blocks`: el stream es una secuencia de `Item`s — + `Chrome{height, view}` (header/badge/etapa de alto fijo que el caller pinta) o + `Lines{start, end}` (rango del store en modo línea). `block_surface` virtualiza + sobre **alturas mixtas**: `item_tops` + `visible_items` (búsqueda binaria, + O(log n) en bloques) localizan los items que tocan el viewport, y dentro de un + `Lines` enorme `visible_rows_in_item` materializa sólo las sub-filas visibles — + costo constante aunque un body tenga 500 k líneas. **Colapsar** = no emitir el + `Lines`. El modo línea de la Fase 1 quedó **unificado** como el caso de un solo + `Item::Lines(0, len)` (delega en `block_surface`, sin duplicar render). 26 tests. + **Verificado headless** (`examples/dump_blocks.rs`): 6 comandos, un flood de + 500 k líneas, un bloque colapsado, stderr tintado, anclado al fondo → ~40 filas + materializadas. + - **Integración al shell ✅ (2026-06-05).** `output_pane_surface` en + `shuma-module-shell/src/view.rs` mapea el modelo del shell + (`OutputLine`/bloques/`collapsed`/`block_command`) a `Item`s: cada comando = + un header chrome (`surface_header`: chevron + `$ cmd` + badge, click→colapso) + + su cuerpo (rango en un `Scrollback`), reusando + `body_lines_for_block`/`body_color_runs`/`CmdStatus`. Conversión de scroll + `scroll_px` (desde el fondo) ↔ `scroll_y` (desde arriba); rueda/arrastre del + widget → `Msg::Scroll(-delta)`. Detrás del flag **`SHUMA_TERMINAL_SURFACE`** + (env, leído una vez); el `output_pane` viejo queda intacto para A/B y + rollback. **Verificado** (`examples/dump_surface.rs`, viewport sembrado + + scroll al fondo): flood de 3 000 líneas virtualizado, bloque colapsado, + stderr tintado, anclado al fondo, sin negro, en la composición real del + `view()`. 94 tests del shell pasan; `output_pane` sin cambios. + - Deuda de paridad (no crítica): filas de etapa (tee) y chip de reprocess del + header todavía no están en el chrome de la superficie; numeración global + continua (no por-bloque). Se cierran antes de la migración (Fase 5). +- **Fase 3 — Selección + find sobre el stream.** Extraer el núcleo de selección del + `text-editor` a compartido; selección global; copy; Ctrl+F. +- **Fase 4 — GPU directo grilla.** Atlas de glifos + celdas instanciadas para el modo + grilla (TUI). Bench vs el grid vt100 actual. Híbrido. +- **Fase 5 — Pulido + migración. ✅** Anclaje estable bajo append, scroll inertial, + spill a disco, y **borrado del `output_pane`/per-command-editor viejo** + (2026-06-14): la superficie es la **única** vía de output (salvo PTY/TUI + fullscreen). Se eliminaron `view/output_pane.rs`, la fn `command_card` + + `pipe_stages_row`, `render_output_line` + sus helpers exclusivos + (`build_span_children`/`kind_icon`/`partition_line`/`LinePiece`), el menú + legacy (`view/chrome.rs::body_context_menu`), la maquinaria del editor IDE + per-comando (`body_sel`/`body_menu`/`body_drag_accum`, `apply_body_pointer`, + `apply_body_double_click`, `body_editor_state`, los `Msg` + `BodyPointer`/`BodyDoubleClick`/`CopyBody`/`OpenBodyMenu`/`BodyMenu{Pick,Dismiss}`) + y el flag `terminal_surface_enabled`/`SHUMA_TERMINAL_LEGACY`. Quedan, ahora + como helpers compartidos por la superficie, `stage_capture_rows`, + `copy_command_block`+`Msg::CopyCommandBlock`, `word_range_at`, `mix_color`, + `ROW_H`/`STAGES_H`/`COLLAPSE_ANIM`, `pty_lines_panel` y `body_editor_metrics`/ + `body_editor_palette`. `cargo check --workspace` verde; 181 tests pasan + (los 2 que fallan ya fallaban en `main`, sin relación con esto). + +Cada fase es un commit (o pocos) verificado con render headless + viewport medido, y +deja el shell funcionando (flag de migración hasta la Fase 5). + +## Cómo reemplazó al `output_pane` (sin romper) + +- Fases 1–4 convivieron con el `output_pane` viejo detrás de un flag + (`SHUMA_TERMINAL_SURFACE` / opt-out `SHUMA_TERMINAL_LEGACY`), para A/B y + rollback inmediato. +- El modelo de datos no cambió de raíz: las `OutputLine` + `block_command` + + `expanded_stages` se mapean a bloques de la superficie. El emulador vt100 y la + detección de alt-screen se reusan. +- La **Fase 5 borró el camino viejo** (2026-06-14) una vez verificada la + paridad: la superficie tiene su propia selección/copy/find/menú sobre el + stream (`surf_*`), así que el editor IDE per-comando y su menú legacy ya no + aportaban nada. Ya no hay flag: la superficie es el único path (salvo PTY/TUI + fullscreen). + +## Anti-features (rechazadas con motivo) + +- **Subir `MAX_OUTPUT_LINES` y ya.** Mueve la pared, no la rompe; sigue siendo + "render todo". +- **Un text-editor gigante para todo el scrollback.** El widget de archivo virtualiza + pero no modela bloques (header/badge/grilla); forzarlo es lo que ya rompió. +- **GPU directo para TODO.** El modo línea no lo necesita; meterlo ahí es complejidad + sin payoff y pelea con vello (texto rico). +- **Scroll por `transform` del panel sobre contenido alto.** Es la fuente del bug + clip+transform. El scroll vive en la superficie, que sólo materializa lo visible. + +## Pila exacta (sin negociación) + +- Crate `llimphi-widget-terminal` (Capas 0–4 agnósticas), consumido por + `shuma-module-shell`. +- Texto: `llimphi-text` (vello) para modo línea + chrome. +- Grilla: `llimphi-raster` GPU directo (`GpuPipelines`, patrón persistente) + + `fontdue`/atlas para el glyph cache (precedente: `atlas` de wawa, Fontdue). +- vt100: el parser ya en uso (`vt100` crate) para grilla/híbrido. +- Núcleo de selección/find: extraído de `text-editor` a compartido, NO duplicado. + +## Referencias + +- Código actual: `shuma-module-shell/src/view.rs` (`output_pane`, `command_card`, + `body_editor_*`), `lib.rs` (`MAX_OUTPUT_LINES`, `block_command`). +- Bugs que motivaron esto: negro al anclar al fondo + desalineación gutter/contenido → + fix de raíz del transform multicolor (commit `caf37079`); cap embebido + (`2038492c`); ruteo a card IDE (`01befe89`). +- GPU directo validado: [[project_gpu_directo_bench_pending]] (141 fps @ 1M, Iris Xe). +- Plan de UX del output: `PLAN-OUTPUT.md` (deudas D/find, anclaje estable). +- Memoria viva: [[project_shuma_output_ux]], [[project_shuma_rescate]]. + +## Estado + +**Implementado al 2026-06-07; migración cerrada el 2026-06-14.** Fases 0-5 ✅ (foundation, virtualización, bloques, selección + copy + find, GPU grid behind `SHUMA_GPU_GRID=1`, pulido y migración). La superficie es **el único path** de output (salvo PTY/TUI fullscreen): el `output_pane` viejo + las cards per-comando IDE + su menú legacy + el flag `SHUMA_TERMINAL_LEGACY` fueron **borrados** (no hay más opt-out). + +**Cerrado en Fase 5**: +- Anclaje estable bajo append (no más jiggle al recibir output mientras se lee historia). +- Doble-click select-word + triple-click select-line. +- Scroll inercial (touchpad/wheel decay). +- Menú contextual right-click (Copiar / Copiar todo / Seleccionar todo) sobre el stream (`surf_*`). +- Spill a disco: configurable vía `[scrollback]` en `shumarc.toml`, archive automático al recortar el frente, chip de status en UI, builtin `:scrollback open` para abrirlo con `$EDITOR`. +- **Borrado del `output_pane`/per-command-editor viejo** (2026-06-14): ver el detalle en la lista de Fases (Fase 5). Migración de `view()`/`body_view()` a `output_pane_surface` incondicional; eliminados los módulos/funciones/`Msg`/campos de `State` legacy; helpers compartidos reubicados. `cargo check --workspace` verde, downstream (`shuma-shell-llimphi`, `pata-llimphi`, `shuma-cli`) compila, 181 tests pasan (2 fallos pre-existentes en `main`). + +**Fase 5.12 — paginado del archive al scrollear (✅ 2026-06-21):** el view ya +no se queda en las últimas `MAX_SPILLED_VISIBLE` (200) líneas spilled. El cache +(`SurfSpilledCache`) gana `window_start: Option` — `None` = ventana "cola" +liviana (las últimas N, sigue el final cuando spillea más); `Some(id)` = el +usuario paginó hacia atrás. `refresh_surf_spilled_visible` carga +`[effective_start, spilled_count)` con `spill_effective_start` (clampea a no +más de `MAX_SPILLED_LOADED` = 2000 desde el final). Al rozar el borde superior +del contenido, `apply_scroll_delta` llama `spill_page_back` (función pura) y, si +hay más archive, retrocede `window_start` una página (`SPILL_PAGE` = 200); +**prependear K líneas no cambia la distancia al fondo**, así que sólo sube el +ancla `K·row_h` para que la vista no salte (estabilidad gratis del modelo +anclado-desde-el-fondo de la Fase 5). Volver al fondo resetea la ventana a +"cola". El header del archive avisa cuántas líneas quedan más arriba; pasado +`MAX_SPILLED_LOADED`, `:scrollback open` sigue siendo el escape para forense +profundo. Lógica pura testeada (`spill_effective_start`/`spill_page_back`/ +refresh paginado/wiring scroll→page); el *feel* fino del scroll pide validar a +ojo en GUI. + +Decisión de construir tomada con el usuario 2026-06-05; ejecución completa de Fase 0 a 5.10 entre 2026-06-06 y 2026-06-07. El control nuevo se justifica por el techo arquitectónico de ~500 líneas del path viejo y por la eficiencia GPU-directo en grilla/TUI. diff --git a/02_ruway/shuma/VOZ.md b/02_ruway/shuma/VOZ.md new file mode 100644 index 0000000..5b4c70e --- /dev/null +++ b/02_ruway/shuma/VOZ.md @@ -0,0 +1,192 @@ +# VOZ.md — voz manos-libres en shuma + +> Propuesta 2026-06-27. Estado: **borrador para discusión** — nada comprometido. +> Cada pieza cita el artefacto real sobre el que se monta. Gemelo de +> `INTELIGENCIA.md`: misma doctrina (*determinista primero, modelo opcional +> después*; *el shell propone, el usuario acepta/habla, nunca es piloto*). + +## Tesis + +La voz es **otra superficie de E/S opt-in y rotulada**, no un asistente que +toma el mando. Tres capacidades separables — no son una: + +1. **Dictar (STT):** voz → texto al input. Mismo molde que `:?`: el host corre + el engine en un thread y dispatcha `Msg` al update Elm. +2. **Leer discriminado (TTS):** la doctrina prohíbe leer todo. Se lee **sólo + los `BloqueSalida::Texto`** del agente (prosa), **nunca** código ni volcados + de stdout, y sólo con toggle por-agente o tecla *«leéme esto»*. +3. **Entonación:** dos capas. (a) **determinista/barata** — contorno de f0 / + subida final → ¿pregunta vs orden?, ¿urgencia? como *pista* de intención. + (b) intención emocional rica → modelo, opt-in. No se promete (a) como magia. + +Wake-word manos-libres es el **gate** de (1), no una cuarta capacidad. + +## Decisiones tomadas (2026-06-27) + +- **Engine híbrido:** wake-word + VAD **siempre local**; STT/TTS **configurable + local o nube por agente** — espeja `LlmSettings` por agente que ya existe en + `wawa-config` / `shuma-agente`. +- **Manos libres directo** (no push-to-talk primero). Primer corte sin entrenar + modelo: **VAD-gated STT + match del llamado** (ver §Wake-word). + +## STT/TTS son IA GENERAL → van en `rimay`, no en shuma + +Corrección de fondo (2026-06-27): el habla no es de shuma. `rimay` (quechua +*hablar*) es el dominio de «lo que quiere decir algo»; ya hospeda +`rimay-verbo` (embeddings) con el patrón canónico de la suite — **fachada + +trait + mock fallback + daemon que carga el modelo una vez por socket**. La voz +es el gemelo: vive en **`rimay-voz`** y *cualquier* app la consume (shuma, +mirada, pluma). **shuma sólo cablea** — no aloja nada de IA general. + +## Lo que ya está parido (cablear, no inventar) + +| Pieza | Dónde | Nota | +|---|---|---| +| Contrato STT/TTS + lógica de escucha | `00_unanchay/rimay/rimay-voz-core` | **hecho** — traits `Transcriptor`/`Locutor` + máquina/lectura/prosodia | +| Backend mock determinista | `00_unanchay/rimay/rimay-voz-mock` | **hecho** — STT/TTS sin modelo, para CI/demos | +| Patrón fachada+daemon a copiar | `rimay-verbo` (embeddings) | molde exacto para el daemon de voz | +| Captura de micrófono (cpal + opus) | `02_ruway/media/media-recorder-wav`, `media-encode-opus`, `supay-audio` | la entrada de audio NO es código nuevo | +| Acciones se proponen, no se auto-ejecutan | `AccionPropuesta` + `atipay` | la voz no salta este gate | +| Backend configurable por agente | `wawa-config::LlmSettings` | el STT/TTS por agente copia el patrón | +| Config de voz global del SO | `wawa-config::VozSettings` (`ai.voz`) | **hecho** — STT/TTS/llamado/wake editables en wawa-panel (sección «Voz»); los hosts la leen para armar `VozConfig` + `OpcionesEscucha` | + +Lo que **falta**: los backends reales (whisper/piper/nube), el daemon, y el +host que corre cpal+VAD. + +## Pipeline + +``` +cpal frames ─► VAD (Silero, local, det.) ─► [hay voz] ─► STT del fragmento + │ + ┌──── ¿el texto arranca con el llamado? ──────┘ + │ sí │ no + ▼ ▼ + Despierto: dictado al input descartar (nada sale de la máquina) + │ + ├─► texto al input (mismo Msg que el ghost/`:?`) + └─► f0/contorno ─► pista de intención (pregunta/orden/urgencia) +``` + +Nada pesado corre hasta que el VAD ve voz; nada sale de la máquina hasta que +matchea el llamado. + +## Wake-word — corte honesto por fases + +- **F0 (manos libres ya, sin entrenar):** VAD local siempre-encendido. Cuando + hay voz, STT sobre ese fragmento; si el transcript **empieza con el llamado** + (`"shuma"` u otro quechua — Regla 6, *no* "Alexa"), se entra en `Despierto`. + Cuesta más CPU (STT por utterance) pero en desktop es aceptable y es cero + modelo nuevo. +- **F1 (compuerta dedicada, hecho):** `rimay-voz-core::wake` — trait + `DetectorLlamado` (¿esta utterance suena al llamado?) **antes** del STT. Si no + matchea, el audio **no se transcribe** (con STT de nube, no sale de la + máquina) — cierra el agujero de privacidad del "transcribe-todo" de F0. Default + sin modelo: `DetectorPlantilla`, *speaker-dependent* — se enrola con unas + grabaciones del llamado y compara por **DTW** sobre rasgos baratos + (log-energía + cruces por cero, sin FFT). El `Lazo` la consulta sólo estando + **dormido** (despierto/dictando no gatea, dictas libre). Un wake-word neuronal + *speaker-independent* (openWakeWord ONNX) entra como otra impl del trait, sin + tocar el lazo. **Honestidad:** los tests certifican el *mecanismo* + (idéntico-a-la-plantilla dispara, distinto no; el gateo corta el STT), no la + precisión real sobre «shuma» — eso se afina con el enrolado en metal. Falta la + **UX de enrolado** (grabar «shuma» N veces) en la app. + +## Forma del código (Regla: un dominio = un crate raíz + subcrates) + +Familia **`rimay-voz`** en el dominio `rimay`, molde de `rimay-verbo`: + +- **`rimay-voz-core` (hecho, sync/puro/testeable):** + - traits `Transcriptor` (STT) + `Locutor` (TTS) + `Audio`/`Transcripcion` — + el contrato model-agnostic (gemelo del `Provider` de verbo). + - máquina de estados `Dormido → Despierto → Dictando` (+ detección del + llamado, + timeout de re-dormida). + - **VAD + segmentador** (`vad`): trait `DetectorVoz` (¿voz en este frame? → + prob) con default `DetectorEnergia` (RMS, sin modelo) — Silero entra como + otra impl del trait. `Segmentador` puro convierte el flujo de probs en + bordes de utterance (`PulsoVad::{Inicio,Sigue,Fin}`) con debounce de + arranque + colgado (hangover). `Vad` junta detector+segmentador+acumulación + y entrega el `Audio` al cerrar (recortando el silencio del colgado), listo + para el STT. + - **Wake-word** (`wake`): trait `DetectorLlamado` + default `DetectorPlantilla` + (DTW sobre rasgos baratos, enrolable, sin modelo). La compuerta F1 — ver + §Wake-word. + - **política de lectura** discriminada: el consumidor mapea su tipo de bloque + → `TipoBloque` (sólo la prosa se vocaliza). + - clasificador prosódico determinista sobre features de f0. + - sin sockets, sin `tokio`, sin cpal. +- **`rimay-voz-mock` (hecho):** STT/TTS deterministas sin modelo (CI/demos). +- **`rimay-voz` (hecho, fachada):** re-exporta core+mock, constructores + `stt_mock`/`tts_mock`, convención del socket `voz.sock`. Demos canónicos: + `cargo run -p rimay-voz --example escucha_mock` (lazo desde transcripts) y + `--example pipeline_vad` (upstream completo: frames → VAD → STT → máquina, + certificado por texto). +- **`VozConfig` (hecho, selector híbrido):** el híbrido configurable, gemelo de + `pluma-llm::from_env`. STT y TTS se eligen por separado (`Backend::{Mock, + Local,Nube}`), vía `RIMAY_VOZ_STT`/`RIMAY_VOZ_TTS` (`"local"`, + `"nube:openai:whisper-1"`…). `construir_stt`/`construir_tts` (+ `_o_mock` con + fallback). **El daemon es el brazo local, no compite con la nube.** +- **`rimay-voz-nube` (hecho, rama Nube del híbrido):** backend HTTP shape + OpenAI. STT → `POST /audio/transcriptions` (Whisper): el PCM se empaqueta como + WAV en memoria y sube por `multipart`. TTS → `POST /audio/speech` con + `response_format:"pcm"` (16-bit LE mono 24 kHz), decodificado directo a + `Audio`. `TranscriptorNube`/`LocutorNube` con `openai_from_env()` (lee + `OPENAI_API_KEY`) + `con_modelo`/`con_voz`; `base` configurable → sirve + cualquier proxy OpenAI-compatible. `VozConfig` cablea la rama `Nube{openai}`; + sin credencial erra explícito (ningún constructor hace red). Certificado por + codec WAV↔PCM y manejo de error, sin tocar red (11 tests entre crate+fachada). +- **`rimay-voz-daemon` + `rimay-voz-daemon-bin` (hecho, brazo local):** daemon + que carga el par STT+TTS una vez y lo sirve por socket Unix; el `DaemonClient` + lo consume desde otro proceso cumpliendo **ambos** traits (`Transcriptor` + + `Locutor`), indistinguible de un backend local. Calcado de + `rimay-verbo-daemon`: wire postcard con prefijo de largo, transporte + Unix-socket / TCP-loopback por `cfg`, reintento corto ante transitorios, + `serve_with_shutdown`. El daemon sirve dos traits a la vez (un proceso puede + cargar whisper + piper, o mock en el lado sin backend real). `VozConfig` cablea + `Backend::Local` → `DaemonClient::connect(socket)` (override `socket` o + `voz.sock` por convención); sin daemon, `_o_mock` cae a mock. Binario + `voz-daemon` (`--socket/--stt/--tts`, hoy sólo mock). Certificado por + round-trip sobre socket Unix real (10 tests: STT/TTS/handshake/2-clientes/ + ping/shutdown/daemon-ausente). +- **`rimay-voz-{whisper,piper,…}` (falta):** backends **locales** reales que + reemplazan el mock dentro del daemon — entran como variantes del `--stt`/ + `--tts` del binario, sin tocar protocolo ni cliente. +- **`rimay-voz-host` (hecho, host de captura):** corre el micrófono y empuja + los frames por el lazo `VAD → STT → Maquina`, emitiendo `EventoEscucha` + (`Escuchando`/`Desperto`/`Dictar`/`SeDurmio`) que la app dispatcha como `Msg`. + **Es IA general (oír) → vive en `rimay`, no en shuma** (misma corrección que + STT/TTS: shuma sólo cablea). Dos capas: el `Lazo` puro (muestras `i16` mono → + framing → VAD → STT → máquina + `tick`), testeable sin micrófono; y el driver + `escuchar()` detrás de la feature **`microfono` (ON por default, apagable con + `--no-default-features`)**, que abre cpal en un **hilo dedicado** (el `Stream` + es `!Send`), prepara el audio (`a_mono` + `Remuestreador` lineal con estado + + `a_i16`, reusando la captura de `media-source-capture/mic`) y alimenta el + `Lazo` desde una task async, emitiendo eventos por canal. **Palabra de llamada + configurable** y **compuerta wake-word (F1) opcional** vía `OpcionesEscucha` + (`escuchar_con`); el `Lazo` gatea el STT con el `DetectorLlamado` estando + dormido. Certificado por texto: 15 tests (lazo: ruido/llamado/cola/re-dormida/ + silencio + gateo wake acepta/rechaza/no-gatea-despierto; prep: downmix/ + remuestreo/clamp). Demos: `escuchar_microfono` (en metal) y `wake_gateo` + (gateo F1 sin micrófono, por texto). Lo único que quedará «de shuma»: mapear + `shuma_agente::BloqueSalida` → `rimay_voz::TipoBloque` y dispatchar los + eventos. Sin micrófono real, el lazo ya está demostrado en + `rimay-voz/examples/pipeline_vad`. + +## Dependencias candidatas + +- **VAD:** la *lógica* (segmentación + trait `DetectorVoz`) ya vive en + `rimay-voz-core::vad`, con default de energía. Para robustez, una impl Silero + (`voice_activity_detector`, ONNX) entra como otro `DetectorVoz`; alt. + `webrtc-vad`. +- **STT local:** `whisper-rs` (bindings whisper.cpp). **Nube:** ✅ aterrizado en + `rimay-voz-nube` (shape OpenAI sobre `reqwest`) — el gap de "pluma-llm no tiene + STT" se resolvió con fachada propia, no estirando `ChatClient`. +- **TTS local:** piper / espeak-ng. **Nube:** API por agente. +- **Captura:** reusar cpal vía los crates de `media/`. + +## Gaps conocidos + +- ~~`pluma-llm` no modela STT/TTS~~ — resuelto: la rama "nube" tiene su propia + fachada (`rimay-voz-nube`), no se estiró `ChatClient`. +- Always-on + privacidad: el indicador de escucha debe ser **visible siempre** + en el chasis (diente/rail), no oculto. +- Barge-in (hablar encima del TTS) queda para después de F0. diff --git a/02_ruway/shuma/baremetal/matilda-android/Cargo.toml b/02_ruway/shuma/baremetal/matilda-android/Cargo.toml new file mode 100644 index 0000000..bb967c6 --- /dev/null +++ b/02_ruway/shuma/baremetal/matilda-android/Cargo.toml @@ -0,0 +1,62 @@ +[package] +name = "matilda-android" +version.workspace = true +edition.workspace = true +rust-version.workspace = true +license.workspace = true +authors.workspace = true +publish.workspace = true +description = "matilda desde el bolsillo — frontend Android (Llimphi) del admin de servidores: inventario, plan, dry-run, apply y flota por SSH. Chasis standalone que reproduce el cableado remoto de shuma-shell." + +# Android NativeActivity carga la lib como .so vía dlopen: el binario final +# es una `cdylib` con `android_main` exportado (mismo patrón que +# clear-screen-android). `rlib` además, para que el example desktop y los +# tests consuman la misma app sin duplicar nada. +[lib] +crate-type = ["cdylib", "rlib"] + +[dependencies] +llimphi-ui = { workspace = true } +llimphi-theme = { workspace = true } +shuma-module = { path = "../../sandbox/shuma-module" } +shuma-module-matilda = { path = "../../sandbox/shuma-module-matilda" } +matilda-core = { path = "../matilda-core" } +serde = { workspace = true } +serde_json = { workspace = true } +log = "0.4" + +[target.'cfg(target_os = "android")'.dependencies] +# Misma versión que la que winit 0.30 usa internamente — el `AndroidApp` de +# `android_main` debe ser EL MISMO tipo que consume `with_android_app`. +android-activity = { version = "0.6", features = ["native-activity"] } +android_logger = "0.14" +# Activa el backend NativeActivity de winit para todo el grafo (unificación +# de features): sin esto el event loop android no existe. +winit = { workspace = true, features = ["android-native-activity"] } + +[dev-dependencies] +pollster = { workspace = true } + +[[example]] +name = "matilda_movil_desktop" +path = "examples/matilda_movil_desktop.rs" + +# Metadata cargo-apk-style (xbuild también la entiende parcialmente); la +# fuente autoritativa de permisos/label para xbuild es manifest.yaml. +[package.metadata.android] +package = "net.tawasuyu.matilda" +build_targets = ["aarch64-linux-android", "x86_64-linux-android"] +min_sdk_version = 24 +target_sdk_version = 34 + +[package.metadata.android.application] +label = "Matilda" +debuggable = true + +[[package.metadata.android.uses_permission]] +name = "android.permission.INTERNET" + +[package.metadata.android.application.activity] +config_changes = "orientation|screenSize|keyboardHidden" +launch_mode = "singleTop" +orientation = "unspecified" diff --git a/02_ruway/shuma/baremetal/matilda-android/LEEME.md b/02_ruway/shuma/baremetal/matilda-android/LEEME.md new file mode 100644 index 0000000..968ef15 --- /dev/null +++ b/02_ruway/shuma/baremetal/matilda-android/LEEME.md @@ -0,0 +1,23 @@ +# matilda-android + +*Read this in English: [README.md](README.md).* + +Matilda desde el bolsillo. + +Frontend móvil (Llimphi sobre Android NativeActivity) del admin de +servidores. **No reimplementa nada**: el cerebro es +`shuma-module-matilda` (`State`/`Msg`/`update`/`view`) y este crate es +sólo el chasis standalone que shuma-shell le presta en desktop — +reproduce su cableado remoto (discover / dry-run / apply / flota / +acciones de contenedor+servicio por SSH en threads) sin slots ni +multi-módulo: una instancia, pantalla completa. + +En un teléfono el `Source::Local` no sirve (no hay docker): la fuente +viene de `matilda.json` en el dir de datos de la app (ver `config`), +normalmente un `Remote { host, user }` cuya clave SSH vive en +`$HOME/.ssh/id_ed25519` — `android_main` redirige `HOME` al dir interno +de la app, así que la clave se empuja con `adb push` una sola vez. + +--- + +Parte de **shuma** — ver [shuma](../../LEEME.md). diff --git a/02_ruway/shuma/baremetal/matilda-android/README.md b/02_ruway/shuma/baremetal/matilda-android/README.md new file mode 100644 index 0000000..2eaa690 --- /dev/null +++ b/02_ruway/shuma/baremetal/matilda-android/README.md @@ -0,0 +1,12 @@ +# matilda-android + +Matilda from your pocket. + +The mobile frontend (Llimphi over Android's NativeActivity) of the server admin. +It **reimplements nothing**: the brain is `shuma-module-matilda` +(`State`/`Msg`/`update`/`view`) and this crate is only the standalone chassis that +shuma-shell lends it on the desktop. + +--- + +Part of **shuma** — see [shuma](../../README.md). diff --git a/02_ruway/shuma/baremetal/matilda-android/examples/matilda_movil_desktop.rs b/02_ruway/shuma/baremetal/matilda-android/examples/matilda_movil_desktop.rs new file mode 100644 index 0000000..b70f9da --- /dev/null +++ b/02_ruway/shuma/baremetal/matilda-android/examples/matilda_movil_desktop.rs @@ -0,0 +1,7 @@ +//! Corre la app móvil en desktop, ventana con proporción de teléfono. +//! Misma app, mismo cerebro — sólo cambia el runner. Útil para iterar la +//! UI sin device: `cargo run -p matilda-android --example matilda_movil_desktop`. + +fn main() { + matilda_android::correr(); +} diff --git a/02_ruway/shuma/baremetal/matilda-android/manifest.yaml b/02_ruway/shuma/baremetal/matilda-android/manifest.yaml new file mode 100644 index 0000000..dc33283 --- /dev/null +++ b/02_ruway/shuma/baremetal/matilda-android/manifest.yaml @@ -0,0 +1,17 @@ +# Manifiesto xbuild — fuente de los permisos/label del APK. Sin esto xbuild +# genera un AndroidManifest sin INTERNET y el SSH muere con Permission denied. +android: + manifest: + package: net.tawasuyu.matilda + uses_permission: + - name: android.permission.INTERNET + application: + label: Matilda + activities: + - config_changes: orientation|screenSize|keyboardHidden + launch_mode: singleTop + sdk: + min_sdk_version: 24 + # xbuild 0.2.0 no soporta targetSdk 34 ("ndk doesn't support sdk + # version 34") — 33 es el techo que empaqueta. + target_sdk_version: 33 diff --git a/02_ruway/shuma/baremetal/matilda-android/src/android.rs b/02_ruway/shuma/baremetal/matilda-android/src/android.rs new file mode 100644 index 0000000..2bea399 --- /dev/null +++ b/02_ruway/shuma/baremetal/matilda-android/src/android.rs @@ -0,0 +1,31 @@ +//! Entry-point Android: NativeActivity dlopen-ea la cdylib y +//! `android-activity` invoca este `android_main`. Todo lo específico del +//! target vive aquí — la app ([`crate::MatildaMovil`]) no sabe dónde corre. + +const TAG: &str = "matilda"; + +#[no_mangle] +fn android_main(app: android_activity::AndroidApp) { + android_logger::init_once( + android_logger::Config::default() + .with_max_level(log::LevelFilter::Debug) + .with_tag(TAG), + ); + // Sin esto un panic muere en silencio: Android cierra el proceso antes + // de que nada flushee. El hook lo manda a logcat primero. + std::panic::set_hook(Box::new(|info| { + log::error!("PANIC: {info}"); + })); + + // HOME/MATILDA_DIR → dir interno de la app: ahí viven matilda.json y + // ~/.ssh/id_ed25519 (la clave que `default_ssh_key()` del módulo espera). + // Se empujan una vez con `adb push` (run-as para app debuggable). + if let Some(dir) = app.internal_data_path() { + std::env::set_var("HOME", &dir); + std::env::set_var("MATILDA_DIR", &dir); + log::info!("HOME/MATILDA_DIR = {}", dir.display()); + } + + log::info!("android_main → llimphi_ui::run_android"); + llimphi_ui::run_android::(app); +} diff --git a/02_ruway/shuma/baremetal/matilda-android/src/config.rs b/02_ruway/shuma/baremetal/matilda-android/src/config.rs new file mode 100644 index 0000000..e550411 --- /dev/null +++ b/02_ruway/shuma/baremetal/matilda-android/src/config.rs @@ -0,0 +1,62 @@ +//! Carga de `matilda.json` — la config del chasis móvil. +//! +//! ```json +//! { +//! "source": { "Remote": { "host": "203.0.113.7", "user": "root" } }, +//! "inventory": { ... } // inline, o +//! "inventory_path": "inv.json" // relativo al dir de datos +//! } +//! ``` +//! +//! El dir de datos es `$MATILDA_DIR` (en Android, `android_main` lo apunta +//! al internal data path de la app) o `$HOME/.config/matilda` en desktop. +//! La clave SSH se resuelve como siempre (`$HOME/.ssh/id_ed25519`) — en +//! Android `HOME` también apunta al dir interno, así que todo el estado de +//! la app (config + clave) se empuja con `adb push` al mismo lugar. + +use matilda_core::Inventory; +use shuma_module::Source; +use std::path::PathBuf; + +#[derive(Debug, serde::Deserialize)] +struct ConfigMovil { + source: Source, + #[serde(default)] + inventory: Option, + #[serde(default)] + inventory_path: Option, +} + +/// Dir de datos de la app: `$MATILDA_DIR` > `$HOME/.config/matilda`. +pub fn dir_datos() -> PathBuf { + if let Ok(d) = std::env::var("MATILDA_DIR") { + return PathBuf::from(d); + } + let home = std::env::var("HOME").unwrap_or_else(|_| ".".into()); + PathBuf::from(home).join(".config/matilda") +} + +/// Lee y valida `matilda.json`. Devuelve `(source, inventario, path leído)`. +pub fn cargar() -> Result<(Source, Inventory, PathBuf), String> { + let path = dir_datos().join("matilda.json"); + let raw = std::fs::read_to_string(&path) + .map_err(|e| format!("no pude leer {}: {e}", path.display()))?; + let cfg: ConfigMovil = + serde_json::from_str(&raw).map_err(|e| format!("{} inválido: {e}", path.display()))?; + let inventario = match (cfg.inventory, cfg.inventory_path) { + (Some(inv), _) => inv, + (None, Some(rel)) => { + let p = if rel.is_absolute() { rel } else { dir_datos().join(rel) }; + let raw = std::fs::read_to_string(&p) + .map_err(|e| format!("no pude leer {}: {e}", p.display()))?; + serde_json::from_str(&raw).map_err(|e| format!("{} inválido: {e}", p.display()))? + } + (None, None) => { + return Err(format!( + "{}: falta \"inventory\" (inline) o \"inventory_path\"", + path.display() + )) + } + }; + Ok((cfg.source, inventario, path)) +} diff --git a/02_ruway/shuma/baremetal/matilda-android/src/lib.rs b/02_ruway/shuma/baremetal/matilda-android/src/lib.rs new file mode 100644 index 0000000..00e0b24 --- /dev/null +++ b/02_ruway/shuma/baremetal/matilda-android/src/lib.rs @@ -0,0 +1,429 @@ +//! `matilda-android` — matilda desde el bolsillo. +//! +//! Frontend móvil (Llimphi sobre Android NativeActivity) del admin de +//! servidores. **No reimplementa nada**: el cerebro es +//! `shuma-module-matilda` (`State`/`Msg`/`update`/`view`) y este crate es +//! sólo el chasis standalone que shuma-shell le presta en desktop — +//! reproduce su cableado remoto (discover / dry-run / apply / flota / +//! acciones de contenedor+servicio por SSH en threads) sin slots ni +//! multi-módulo: una instancia, pantalla completa. +//! +//! En un teléfono el `Source::Local` no sirve (no hay docker): la fuente +//! viene de `matilda.json` en el dir de datos de la app (ver [`config`]), +//! normalmente un `Remote { host, user }` cuya clave SSH vive en +//! `$HOME/.ssh/id_ed25519` — `android_main` redirige `HOME` al dir interno +//! de la app, así que la clave se empuja con `adb push` una sola vez. + +use llimphi_ui::llimphi_layout::taffy::{ + prelude::{length, percent, FlexDirection, Size, Style}, + AlignItems, FlexWrap, JustifyContent, Rect, +}; +use llimphi_ui::llimphi_text::Alignment; +use llimphi_ui::{App, Handle, View}; +use llimphi_theme::Theme; +use shuma_module_matilda as mat; +use shuma_module_matilda::Msg as MMsg; + +pub mod config; + +#[cfg(target_os = "android")] +mod android; + +/// Ancho del panel de inventario en móvil: el default del módulo (380) +/// está pensado para un tab desktop y en un teléfono vertical se come la +/// pantalla entera. El splitter sigue siendo arrastrable. +const SPLIT_MOVIL: f32 = 210.0; + +pub struct Modelo { + st: mat::State, + theme: Theme, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum Accion { + Discover, + Plan, + DryRun, + Apply, + Flota, + Recargar, +} + +impl Accion { + fn label(self) -> &'static str { + match self { + Accion::Discover => "Discover", + Accion::Plan => "Plan", + Accion::DryRun => "Dry-run", + Accion::Apply => "Apply", + Accion::Flota => "Flota", + Accion::Recargar => "Recargar", + } + } + + const TODAS: [Accion; 6] = [ + Accion::Discover, + Accion::Plan, + Accion::DryRun, + Accion::Apply, + Accion::Flota, + Accion::Recargar, + ]; +} + +#[derive(Debug, Clone)] +pub enum Msg { + /// Mensaje del módulo (la UI del módulo los emite lifteados aquí). + M(MMsg), + /// Botón de la barra de acciones propia del chasis móvil. + Accion(Accion), +} + +/// Aplica un `Msg` del módulo a su `State` — el paso puro, sin threads. +fn aplicar(m: Modelo, mm: MMsg) -> Modelo { + Modelo { + st: mat::update(m.st, mm), + theme: m.theme, + } +} + +pub struct MatildaMovil; + +impl App for MatildaMovil { + type Model = Modelo; + type Msg = Msg; + + fn title() -> &'static str { + "Matilda" + } + + fn initial_size() -> (u32, u32) { + // Sólo aplica al example desktop: proporción de teléfono vertical + // para ver lo mismo que se verá en el device. Android la ignora. + (420, 840) + } + + fn init(_handle: &Handle) -> Modelo { + let mut st = match config::cargar() { + Ok((source, inventory, origen)) => { + let mut st = mat::State::with_inventory(source, inventory); + st.log.push(format!("✓ config: {}", origen.display())); + st + } + Err(motivo) => { + let mut st = mat::State::new(shuma_module::Source::Local); + st.log.push(format!("⚠ {motivo}")); + st.log.push(format!( + "⚠ sin matilda.json — inventario de ejemplo, source local. \ + Empuja {}/matilda.json (source + inventory) y toca «Recargar».", + config::dir_datos().display() + )); + st + } + }; + st.split_width = SPLIT_MOVIL; + Modelo { + st, + theme: Theme::dark(), + } + } + + fn update(m: Modelo, msg: Msg, handle: &Handle) -> Modelo { + match msg { + Msg::Accion(a) => accion(m, a, handle), + Msg::M(mm) => interceptar(m, mm, handle), + } + } + + fn view(m: &Modelo) -> View { + let theme = &m.theme; + let cuerpo = mat::view(&m.st, theme, Msg::M); + View::new(Style { + flex_direction: FlexDirection::Column, + size: Size { + width: percent(1.0_f32), + height: percent(1.0_f32), + }, + ..Default::default() + }) + .fill(theme.bg_app) + .children(vec![barra_acciones(theme), cuerpo]) + } +} + +/// Barra de acciones táctil del chasis: lo que en shuma-shell son los +/// shortcuts de la toolbar (`matilda.discover` / `.plan` / `.dry_run` / +/// `.apply` / `.fleet`) aquí son botones de dedo (44 px de alto). +fn barra_acciones(theme: &Theme) -> View { + let mut botones: Vec> = Vec::new(); + for a in Accion::TODAS { + // Apply pinta destructivo: es el único que muta el servidor entero. + let color = if a == Accion::Apply { + theme.fg_destructive + } else { + theme.accent + }; + botones.push( + View::new(Style { + size: Size { + width: length(92.0_f32), + height: length(44.0_f32), + }, + align_items: Some(AlignItems::Center), + justify_content: Some(JustifyContent::Center), + ..Default::default() + }) + .fill(theme.bg_button) + .hover_fill(theme.bg_button_hover) + .radius(8.0) + .on_click(Msg::Accion(a)) + .text_aligned(a.label().to_string(), 14.0, color, Alignment::Center), + ); + } + View::new(Style { + flex_direction: FlexDirection::Row, + flex_wrap: FlexWrap::Wrap, + size: Size { + width: percent(1.0_f32), + height: taffy_auto(), + }, + padding: Rect { + left: length(8.0_f32), + right: length(8.0_f32), + top: length(8.0_f32), + bottom: length(8.0_f32), + }, + gap: Size { + width: length(8.0_f32), + height: length(8.0_f32), + }, + ..Default::default() + }) + .fill(theme.bg_panel) + .children(botones) +} + +fn taffy_auto() -> llimphi_ui::llimphi_layout::taffy::prelude::Dimension { + llimphi_ui::llimphi_layout::taffy::prelude::auto() +} + +/// Botones de la barra — el equivalente móvil del dispatch de shortcuts del +/// chasis shuma (`update.rs` de shuma-shell-llimphi, action_ids `matilda.*`). +/// Local → el módulo resuelve solo; remoto → SSH en un thread que al volver +/// dispatcha el resultado como `Msg` del módulo. +fn accion(m: Modelo, a: Accion, handle: &Handle) -> Modelo { + match a { + Accion::Plan => aplicar(m, MMsg::MakePlan), + Accion::Discover => { + if m.st.source.is_remote() { + let source = m.st.source.clone(); + let desired = m.st.desired.clone(); + handle.spawn(move || { + match mat::discover_remote_blocking(&source, &desired) { + Ok(inv) => Msg::M(MMsg::SetCurrent(inv)), + Err(e) => Msg::M(MMsg::LogLine(format!("✘ discover remoto: {e}"))), + } + }); + aplicar( + m, + MMsg::LogLine("→ conectando para discover…".into()), + ) + } else { + aplicar(m, MMsg::Discover) + } + } + Accion::DryRun => { + if m.st.source.is_remote() { + let source = m.st.source.clone(); + let desired = m.st.desired.clone(); + handle.spawn(move || { + match mat::dry_run_remote_blocking(&source, &desired) { + Ok(lines) => Msg::M(MMsg::DryRunReport(lines)), + Err(e) => Msg::M(MMsg::LogLine(format!("✘ dry-run remoto: {e}"))), + } + }); + aplicar( + m, + MMsg::LogLine("→ dry-run remoto (sin tocar nada)…".into()), + ) + } else { + aplicar(m, MMsg::DryRun) + } + } + Accion::Apply => { + if m.st.source.is_remote() { + let source = m.st.source.clone(); + let desired = m.st.desired.clone(); + handle.spawn(move || { + match mat::apply_remote_blocking(&source, &desired) { + Ok((lines, new_current)) => { + Msg::M(MMsg::ApplyReport { lines, new_current }) + } + Err(e) => Msg::M(MMsg::LogLine(format!("✘ apply remoto: {e}"))), + } + }); + aplicar(m, MMsg::LogLine("→ apply remoto por SSH…".into())) + } else { + aplicar(m, MMsg::Apply) + } + } + Accion::Flota => { + // Marca cada host declarado como Pending y spawnea un fetch SSH + // por host — calcado de `matilda.fleet` del chasis shuma. + let hosts: Vec = m.st.desired.hosts().cloned().collect(); + let m = aplicar(m, MMsg::RefreshFleet); + for host in hosts { + handle.spawn(move || { + match mat::host_runtime_remote_blocking(&host) { + Ok(runtime) => Msg::M(MMsg::SetHostRuntime { + host: host.name.clone(), + runtime, + }), + Err(error) => Msg::M(MMsg::SetHostError { + host: host.name.clone(), + error, + }), + } + }); + } + m + } + Accion::Recargar => match config::cargar() { + Ok((source, inventory, origen)) => { + let mut st = mat::State::with_inventory(source, inventory); + st.split_width = m.st.split_width; + st.log = m.st.log; + st.log.push(format!("✓ recargado: {}", origen.display())); + Modelo { st, theme: m.theme } + } + Err(motivo) => aplicar(m, MMsg::LogLine(format!("✘ recargar: {motivo}"))), + }, + } +} + +/// Msgs del módulo que en shuma-shell intercepta el chasis porque necesitan +/// SSH + thread. Reproducción 1:1 de `app_update_more.rs` (Msg::Module) — +/// el módulo deja la intención en su log y aquí corre lo bloqueante. +fn interceptar(m: Modelo, mm: MMsg, handle: &Handle) -> Modelo { + match &mm { + // Live-tail (`docker logs -f`): el módulo prepara buffer + bandera + // stop al aplicar el Msg; aquí arranca el thread lector. Thread crudo + // (no `handle.spawn`) porque emite N mensajes, no uno. + MMsg::StartLogStream(_) => { + let m = aplicar(m, mm); + if let Some(ls) = m.st.log_stream.as_ref() { + let source = m.st.source.clone(); + let contenedor = ls.container.clone(); + let stop = ls.stop.clone(); + let h = handle.clone(); + std::thread::spawn(move || { + let h_linea = h.clone(); + let _ = mat::stream_logs_blocking(&source, &contenedor, 200, &stop, move |line| { + h_linea.dispatch(Msg::M(MMsg::LogStreamLine(line))); + }); + h.dispatch(Msg::M(MMsg::LogStreamEnded)); + }); + } + m + } + MMsg::FleetContainerAction { host, name, action } => { + if let Some(h) = m.st.desired.hosts().find(|x| x.name == *host).cloned() { + let (name, action) = (name.clone(), *action); + handle.spawn(move || { + let (ok, lines) = mat::fleet_container_action_blocking(&h, &name, action); + let runtime = if ok && action.is_mutating() { + mat::host_runtime_remote_blocking(&h).ok() + } else { + None + }; + Msg::M(MMsg::FleetActionDone { + host: h.name.clone(), + lines, + runtime, + }) + }); + } + aplicar(m, mm) + } + MMsg::FleetServiceAction { host, name, action } => { + if let Some(h) = m.st.desired.hosts().find(|x| x.name == *host).cloned() { + let (name, action) = (name.clone(), *action); + handle.spawn(move || { + let (ok, lines) = mat::fleet_service_action_blocking(&h, &name, action); + let runtime = if ok && action.is_mutating() { + mat::host_runtime_remote_blocking(&h).ok() + } else { + None + }; + Msg::M(MMsg::FleetActionDone { + host: h.name.clone(), + lines, + runtime, + }) + }); + } + aplicar(m, mm) + } + MMsg::ContainerActionMsg { name, action } if m.st.source.is_remote() => { + let source = m.st.source.clone(); + let (name, action) = (name.clone(), *action); + handle.spawn(move || { + let lines = mat::container_action_remote_blocking(&source, &name, action) + .unwrap_or_else(|e| vec![format!("✘ {} {name}: {e}", action.label())]); + Msg::M(MMsg::LogLines(lines)) + }); + aplicar(m, mm) + } + MMsg::ServiceActionMsg { name, action } if m.st.source.is_remote() => { + let source = m.st.source.clone(); + let (name, action) = (name.clone(), *action); + handle.spawn(move || { + let cmd = action.command(&name); + let lines = + mat::service_action_remote_blocking(&source, &cmd, action.label(), &name) + .unwrap_or_else(|e| vec![format!("✘ {} {name}: {e}", action.label())]); + Msg::M(MMsg::LogLines(lines)) + }); + aplicar(m, mm) + } + _ => aplicar(m, mm), + } +} + +/// Corre la app en desktop (example / debugging) — misma app, otro runner. +pub fn correr() { + llimphi_ui::run::(); +} + +#[cfg(test)] +mod tests { + use super::*; + + /// El view del chasis monta sin panicar con el estado inicial (config + /// ausente → inventario de ejemplo) — smoke del árbol completo barra + + /// módulo, sin GPU. + #[test] + fn view_inicial_monta() { + let handle: Handle = Handle::for_test(); + let modelo = MatildaMovil::init(&handle); + let v = MatildaMovil::view(&modelo); + let contadas = contar_nodos(&v); + // barra (6 botones) + header + splitter + paneles: bastante más de 10. + assert!(contadas > 10, "árbol sospechosamente chico: {contadas} nodos"); + } + + fn contar_nodos(v: &View) -> usize { + 1 + v.children.iter().map(contar_nodos).sum::() + } + + /// Las acciones locales puras no requieren threads: Plan sobre el + /// inventario de ejemplo produce un plan no vacío (todo es creación). + #[test] + fn plan_local_produce_acciones() { + let handle: Handle = Handle::for_test(); + let modelo = MatildaMovil::init(&handle); + let modelo = MatildaMovil::update(modelo, Msg::Accion(Accion::Plan), &handle); + let plan = modelo.st.plan.as_ref().expect("plan calculado"); + assert!(!plan.actions.is_empty(), "el ejemplo debería generar acciones"); + } +} diff --git a/02_ruway/shuma/baremetal/matilda-app/Cargo.toml b/02_ruway/shuma/baremetal/matilda-app/Cargo.toml index 8fffe1a..4c14399 100644 --- a/02_ruway/shuma/baremetal/matilda-app/Cargo.toml +++ b/02_ruway/shuma/baremetal/matilda-app/Cargo.toml @@ -13,8 +13,8 @@ name = "matilda" path = "src/main.rs" [dependencies] +bitacora = { workspace = true } matilda-core = { path = "../matilda-core" } -matilda-config = { path = "../matilda-config" } matilda-plan = { path = "../matilda-plan" } matilda-apply = { path = "../matilda-apply" } matilda-ghost = { path = "../matilda-ghost" } diff --git a/02_ruway/shuma/baremetal/matilda-app/LEEME.md b/02_ruway/shuma/baremetal/matilda-app/LEEME.md index 2051f13..38ab581 100644 --- a/02_ruway/shuma/baremetal/matilda-app/LEEME.md +++ b/02_ruway/shuma/baremetal/matilda-app/LEEME.md @@ -7,7 +7,7 @@ Comandos: `matilda discover`, `matilda plan`, `matilda apply`, `matilda ghost`, ## Uso ```sh -cargo run --release -p matilda-app -- apply +cargo run --release -p matilda -- apply ``` ## Deps diff --git a/02_ruway/shuma/baremetal/matilda-app/README.md b/02_ruway/shuma/baremetal/matilda-app/README.md index 397bd56..337a0c1 100644 --- a/02_ruway/shuma/baremetal/matilda-app/README.md +++ b/02_ruway/shuma/baremetal/matilda-app/README.md @@ -7,7 +7,7 @@ Commands: `matilda discover`, `matilda plan`, `matilda apply`, `matilda ghost`, ## Usage ```sh -cargo run --release -p matilda-app -- apply +cargo run --release -p matilda -- apply ``` ## Deps diff --git a/02_ruway/shuma/baremetal/matilda-app/src/main.rs b/02_ruway/shuma/baremetal/matilda-app/src/main.rs index 80cd944..b644a5d 100644 --- a/02_ruway/shuma/baremetal/matilda-app/src/main.rs +++ b/02_ruway/shuma/baremetal/matilda-app/src/main.rs @@ -228,6 +228,7 @@ fn run() -> Result<(), String> { } fn main() -> ExitCode { + bitacora::abrir("shuma"); match run() { Ok(()) => ExitCode::SUCCESS, Err(e) => { diff --git a/02_ruway/shuma/baremetal/matilda-apply/src/lib.rs b/02_ruway/shuma/baremetal/matilda-apply/src/lib.rs index 353c08f..f293ef7 100644 --- a/02_ruway/shuma/baremetal/matilda-apply/src/lib.rs +++ b/02_ruway/shuma/baremetal/matilda-apply/src/lib.rs @@ -12,6 +12,9 @@ #![forbid(unsafe_code)] +pub mod lifecycle; +pub use lifecycle::{ContainerAction, ServiceAction}; + use matilda_config::{docker_run_command, nginx_server_block}; use matilda_core::Inventory; use matilda_plan::{Op, Plan, Resource}; @@ -99,6 +102,22 @@ pub fn plan_to_steps(plan: &Plan, desired: &Inventory) -> Vec { ], }), + // --- Servicios systemd --- + (Op::Create | Op::Update, Resource::Service) => { + desired.service(&action.name).map(|svc| ApplyStep { + describe, + files: Vec::new(), + commands: service_commands(svc), + }) + } + (Op::Remove, Resource::Service) => Some(ApplyStep { + describe, + files: Vec::new(), + // Dejar de administrar un servicio = pararlo y deshabilitarlo + // (matilda no borra el unit file: no lo creó). + commands: vec![format!("systemctl disable --now {}", action.name)], + }), + // --- Hosts: no se "aplican" (son destino de conexión) --- (_, Resource::Host) => None, }; @@ -109,6 +128,24 @@ pub fn plan_to_steps(plan: &Plan, desired: &Inventory) -> Vec { steps } +/// Comandos `systemctl` para llevar un servicio a su estado deseado: +/// enable/disable (boot) + start/stop (ahora). `enable --now`/`disable +/// --now` combinan ambos cuando coinciden. +fn service_commands(svc: &matilda_core::Service) -> Vec { + match (svc.enabled, svc.active) { + (true, true) => vec![format!("systemctl enable --now {}", svc.unit)], + (false, false) => vec![format!("systemctl disable --now {}", svc.unit)], + (true, false) => vec![ + format!("systemctl enable {}", svc.unit), + format!("systemctl stop {}", svc.unit), + ], + (false, true) => vec![ + format!("systemctl disable {}", svc.unit), + format!("systemctl start {}", svc.unit), + ], + } +} + /// Vuelca los pasos a un script de shell único — útil para revisarlo, o /// para ejecutarlo de un tirón en el servidor. Los archivos se emiten /// como heredocs. @@ -181,6 +218,30 @@ mod tests { assert!(cmds.iter().any(|c| c.contains("rm -f") && c.contains("viejo.com"))); } + #[test] + fn service_steps_use_systemctl() { + use matilda_core::Service; + let mut desired = Inventory::new(); + desired.add_service(Service::new("nginx")); // enabled + active + desired.add_service(Service::new("debug").with_enabled(false).with_active(true)); + let steps = plan_to_steps(&matilda_plan::plan(&Inventory::new(), &desired), &desired); + let all: Vec<&str> = steps.iter().flat_map(|s| s.commands.iter()).map(|s| s.as_str()).collect(); + // enabled+active → enable --now combinado. + assert!(all.iter().any(|c| *c == "systemctl enable --now nginx.service")); + // disabled+active → disable + start. + assert!(all.iter().any(|c| *c == "systemctl disable debug.service")); + assert!(all.iter().any(|c| *c == "systemctl start debug.service")); + + // Remove → disable --now. + let mut current = Inventory::new(); + current.add_service(Service::new("viejo")); + let steps = plan_to_steps(&matilda_plan::plan(¤t, &Inventory::new()), &Inventory::new()); + assert!(steps + .iter() + .flat_map(|s| s.commands.iter()) + .any(|c| c == "systemctl disable --now viejo.service")); + } + #[test] fn host_actions_produce_no_steps() { let mut desired = Inventory::new(); diff --git a/02_ruway/shuma/baremetal/matilda-apply/src/lifecycle.rs b/02_ruway/shuma/baremetal/matilda-apply/src/lifecycle.rs new file mode 100644 index 0000000..fd99502 --- /dev/null +++ b/02_ruway/shuma/baremetal/matilda-apply/src/lifecycle.rs @@ -0,0 +1,152 @@ +//! Acciones de ciclo de vida **ad-hoc** sobre un contenedor existente — +//! operación viva, distinta de la reconciliación declarativa (plan/apply). +//! +//! Puro: cada acción se traduce a un comando de shell. Ejecutarlo (local +//! o por SSH) es trabajo de la capa de I/O (el bloque de shuma). + +use serde::{Deserialize, Serialize}; + +/// Acción dirigida a un contenedor por nombre. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "kebab-case")] +pub enum ContainerAction { + Start, + Stop, + Restart, + /// Muestra las últimas líneas del log (lectura, no muta el contenedor). + Logs, + /// CPU/mem/red de un snapshot (`docker stats --no-stream`, lectura). + Stats, + /// Detiene y elimina (`rm -f`). + Remove, +} + +impl ContainerAction { + /// Etiqueta corta para el botón en la UI. + pub fn label(self) -> &'static str { + match self { + ContainerAction::Start => "Start", + ContainerAction::Stop => "Stop", + ContainerAction::Restart => "Restart", + ContainerAction::Logs => "Logs", + ContainerAction::Stats => "Stats", + ContainerAction::Remove => "Remove", + } + } + + /// `true` si la acción cambia el estado del contenedor (vs. sólo leer). + /// El caller refresca el runtime después de una acción mutante. + pub fn is_mutating(self) -> bool { + !matches!(self, ContainerAction::Logs | ContainerAction::Stats) + } + + /// Comando de shell que ejecuta la acción sobre `name`. Puro. + /// `name` se asume un nombre de contenedor válido (sin espacios); el + /// caller no debe pasar entrada de usuario sin validar. + pub fn command(self, name: &str) -> String { + match self { + ContainerAction::Start => format!("docker start {name}"), + ContainerAction::Stop => format!("docker stop {name}"), + ContainerAction::Restart => format!("docker restart {name}"), + ContainerAction::Logs => format!("docker logs --tail 200 {name}"), + ContainerAction::Stats => format!("docker stats --no-stream {name}"), + ContainerAction::Remove => format!("docker rm -f {name}"), + } + } + + /// Todas las acciones, en el orden en que la UI las pinta. + pub fn all() -> [ContainerAction; 6] { + [ + ContainerAction::Start, + ContainerAction::Stop, + ContainerAction::Restart, + ContainerAction::Logs, + ContainerAction::Stats, + ContainerAction::Remove, + ] + } +} + +/// Acción de ciclo de vida sobre un servicio systemd. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "kebab-case")] +pub enum ServiceAction { + Start, + Stop, + Restart, + Enable, + Disable, + /// Estado detallado (lectura, no muta). + Status, +} + +impl ServiceAction { + pub fn label(self) -> &'static str { + match self { + ServiceAction::Start => "Start", + ServiceAction::Stop => "Stop", + ServiceAction::Restart => "Restart", + ServiceAction::Enable => "Enable", + ServiceAction::Disable => "Disable", + ServiceAction::Status => "Status", + } + } + + pub fn is_mutating(self) -> bool { + !matches!(self, ServiceAction::Status) + } + + /// Comando `systemctl` para la acción sobre `unit`. Puro. Las acciones + /// mutantes suelen requerir privilegios; si fallan, el caller lo loguea. + pub fn command(self, unit: &str) -> String { + match self { + ServiceAction::Start => format!("systemctl start {unit}"), + ServiceAction::Stop => format!("systemctl stop {unit}"), + ServiceAction::Restart => format!("systemctl restart {unit}"), + ServiceAction::Enable => format!("systemctl enable {unit}"), + ServiceAction::Disable => format!("systemctl disable {unit}"), + ServiceAction::Status => format!("systemctl status {unit} --no-pager --lines=20"), + } + } + + pub fn all() -> [ServiceAction; 6] { + [ + ServiceAction::Start, + ServiceAction::Stop, + ServiceAction::Restart, + ServiceAction::Enable, + ServiceAction::Disable, + ServiceAction::Status, + ] + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn comandos_systemctl_por_accion() { + assert_eq!(ServiceAction::Start.command("sshd"), "systemctl start sshd"); + assert_eq!(ServiceAction::Enable.command("nginx"), "systemctl enable nginx"); + assert!(ServiceAction::Status.command("x").contains("--no-pager")); + assert!(!ServiceAction::Status.is_mutating()); + assert!(ServiceAction::Restart.is_mutating()); + } + + #[test] + fn comandos_docker_por_accion() { + assert_eq!(ContainerAction::Start.command("web"), "docker start web"); + assert_eq!(ContainerAction::Stop.command("web"), "docker stop web"); + assert_eq!(ContainerAction::Restart.command("web"), "docker restart web"); + assert_eq!(ContainerAction::Remove.command("web"), "docker rm -f web"); + assert!(ContainerAction::Logs.command("web").contains("docker logs")); + } + + #[test] + fn logs_no_muta_el_resto_si() { + assert!(!ContainerAction::Logs.is_mutating()); + assert!(ContainerAction::Start.is_mutating()); + assert!(ContainerAction::Remove.is_mutating()); + } +} diff --git a/02_ruway/shuma/baremetal/matilda-core/LEEME.md b/02_ruway/shuma/baremetal/matilda-core/LEEME.md index deb5ec4..3e6ac58 100644 --- a/02_ruway/shuma/baremetal/matilda-core/LEEME.md +++ b/02_ruway/shuma/baremetal/matilda-core/LEEME.md @@ -2,7 +2,7 @@ > Modelo de config declarativa de [shuma/matilda](../../README.md). -`HostConfig { packages, files, services, dotfiles, ... }` serializable a TOML. La verdad de "cómo debería estar el host" se escribe acá. +`HostConfig { packages, files, services, dotfiles, ... }` serializable a TOML. La verdad de "cómo debería estar el host" se escribe aquí. ## Deps diff --git a/02_ruway/shuma/baremetal/matilda-core/src/host.rs b/02_ruway/shuma/baremetal/matilda-core/src/host.rs index 75bc095..ce9a5d6 100644 --- a/02_ruway/shuma/baremetal/matilda-core/src/host.rs +++ b/02_ruway/shuma/baremetal/matilda-core/src/host.rs @@ -11,11 +11,24 @@ pub struct Host { pub address: String, /// Etiquetas libres — `"prod"`, `"db"`, `"edge"`. pub tags: Vec, + /// Usuario SSH para administrar el host (default `root`). Opcional para + /// que los inventarios viejos sigan parseando. + #[serde(default)] + pub user: Option, + /// Puerto SSH (default 22). + #[serde(default)] + pub port: Option, } impl Host { pub fn new(name: impl Into, address: impl Into) -> Self { - Self { name: name.into(), address: address.into(), tags: Vec::new() } + Self { + name: name.into(), + address: address.into(), + tags: Vec::new(), + user: None, + port: None, + } } /// Añade una etiqueta (encadenable). No duplica. @@ -27,10 +40,32 @@ impl Host { self } + /// Fija el usuario SSH (encadenable). + pub fn with_user(mut self, user: impl Into) -> Self { + self.user = Some(user.into()); + self + } + + /// Fija el puerto SSH (encadenable). + pub fn with_port(mut self, port: u16) -> Self { + self.port = Some(port); + self + } + /// `true` si el host lleva la etiqueta `tag`. pub fn has_tag(&self, tag: &str) -> bool { self.tags.iter().any(|t| t == tag) } + + /// Usuario SSH efectivo (default `root`). + pub fn ssh_user(&self) -> &str { + self.user.as_deref().unwrap_or("root") + } + + /// Puerto SSH efectivo (default 22). + pub fn ssh_port(&self) -> u16 { + self.port.unwrap_or(22) + } } #[cfg(test)] diff --git a/02_ruway/shuma/baremetal/matilda-core/src/inventory.rs b/02_ruway/shuma/baremetal/matilda-core/src/inventory.rs index 01259a3..7050121 100644 --- a/02_ruway/shuma/baremetal/matilda-core/src/inventory.rs +++ b/02_ruway/shuma/baremetal/matilda-core/src/inventory.rs @@ -10,6 +10,7 @@ use serde::{Deserialize, Serialize}; use crate::container::Container; use crate::host::Host; +use crate::service::Service; use crate::vhost::VHost; /// El inventario completo — la fuente de verdad declarativa. @@ -18,6 +19,8 @@ pub struct Inventory { hosts: BTreeMap, containers: BTreeMap, vhosts: BTreeMap, + #[serde(default)] + services: BTreeMap, } impl Inventory { @@ -67,11 +70,28 @@ impl Inventory { self.vhosts.values() } + // --- Servicios systemd --- + + pub fn add_service(&mut self, service: Service) { + self.services.insert(service.unit.clone(), service); + } + + pub fn service(&self, unit: &str) -> Option<&Service> { + self.services.get(unit) + } + + pub fn services(&self) -> impl Iterator { + self.services.values() + } + // --- Consultas transversales --- /// `true` si el inventario no tiene nada declarado. pub fn is_empty(&self) -> bool { - self.hosts.is_empty() && self.containers.is_empty() && self.vhosts.is_empty() + self.hosts.is_empty() + && self.containers.is_empty() + && self.vhosts.is_empty() + && self.services.is_empty() } /// VHosts cuyo upstream apunta a un contenedor inexistente — la diff --git a/02_ruway/shuma/baremetal/matilda-core/src/lib.rs b/02_ruway/shuma/baremetal/matilda-core/src/lib.rs index 5135abb..160b454 100644 --- a/02_ruway/shuma/baremetal/matilda-core/src/lib.rs +++ b/02_ruway/shuma/baremetal/matilda-core/src/lib.rs @@ -18,9 +18,11 @@ pub mod container; pub mod host; pub mod inventory; +pub mod service; pub mod vhost; pub use container::{Container, PortMap, RestartPolicy}; pub use host::Host; pub use inventory::Inventory; +pub use service::Service; pub use vhost::{Upstream, VHost}; diff --git a/02_ruway/shuma/baremetal/matilda-core/src/service.rs b/02_ruway/shuma/baremetal/matilda-core/src/service.rs new file mode 100644 index 0000000..c0adc8c --- /dev/null +++ b/02_ruway/shuma/baremetal/matilda-core/src/service.rs @@ -0,0 +1,80 @@ +//! `Service` — la especificación declarativa de un servicio systemd. +//! +//! Como el resto del core, es sólo el *deseo*: qué unidad debe estar +//! habilitada (arrancar al boot) y/o activa (corriendo ahora). Ejecutar +//! `systemctl` es trabajo de capas superiores; aquí el servicio es un dato +//! comparable (`PartialEq`) para que el plan detecte cambios. + +use serde::{Deserialize, Serialize}; + +/// El estado deseado de un servicio systemd administrado por matilda. +/// Clave única: `unit` (`sshd.service`). +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct Service { + /// Nombre de la unidad — `sshd.service`. Se normaliza con sufijo + /// `.service` si no trae un sufijo de tipo systemd. + pub unit: String, + /// Debe arrancar en el boot (`systemctl enable`). + pub enabled: bool, + /// Debe estar corriendo ahora (`systemctl start`). + pub active: bool, +} + +impl Service { + /// Servicio mínimo: habilitado **y** activo (el caso normal — "que + /// este servicio esté prendido y arranque solo"). + pub fn new(unit: impl Into) -> Self { + Self { + unit: normalize_unit(unit.into()), + enabled: true, + active: true, + } + } + + /// Fija si debe arrancar al boot (encadenable). + pub fn with_enabled(mut self, enabled: bool) -> Self { + self.enabled = enabled; + self + } + + /// Fija si debe estar corriendo ahora (encadenable). + pub fn with_active(mut self, active: bool) -> Self { + self.active = active; + self + } +} + +/// Agrega `.service` si la unidad no trae ya un sufijo de tipo systemd — +/// así `Service::new("sshd")` y `Service::new("sshd.service")` son lo mismo. +fn normalize_unit(unit: String) -> String { + const SUFFIXES: [&str; 7] = [ + ".service", ".socket", ".timer", ".target", ".mount", ".path", ".slice", + ]; + if SUFFIXES.iter().any(|s| unit.ends_with(s)) { + unit + } else { + format!("{unit}.service") + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn new_normaliza_la_unidad_y_default_on() { + let s = Service::new("sshd"); + assert_eq!(s.unit, "sshd.service"); + assert!(s.enabled && s.active); + // Una unidad con sufijo explícito se respeta. + assert_eq!(Service::new("redis.socket").unit, "redis.socket"); + } + + #[test] + fn builders_y_equality() { + let a = Service::new("nginx").with_active(false); + let b = Service::new("nginx.service").with_active(false); + assert_eq!(a, b); + assert!(!a.active && a.enabled); + } +} diff --git a/02_ruway/shuma/baremetal/matilda-discover/src/lib.rs b/02_ruway/shuma/baremetal/matilda-discover/src/lib.rs index 4feb9d0..ba39661 100644 --- a/02_ruway/shuma/baremetal/matilda-discover/src/lib.rs +++ b/02_ruway/shuma/baremetal/matilda-discover/src/lib.rs @@ -15,9 +15,20 @@ #![forbid(unsafe_code)] -use matilda_core::{Container, Inventory, VHost}; +use matilda_core::{Container, Inventory, Service, VHost}; use serde::{Deserialize, Serialize}; +/// Estado declarativo observado de un servicio systemd administrado: +/// su unidad y si está habilitado/activo *ahora*. A diferencia de los +/// contenedores, sólo se observan los servicios **declarados** (matilda no +/// administra las cientos de unidades del sistema). +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct ObservedService { + pub unit: String, + pub enabled: bool, + pub active: bool, +} + /// El estado observado de un servidor — los nombres de lo que existe. #[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)] pub struct ServerState { @@ -25,6 +36,267 @@ pub struct ServerState { pub containers: Vec, /// Dominios de los vhosts presentes. pub vhosts: Vec, + /// Estado declarativo de los servicios administrados (sólo los + /// declarados; vacío en el discover remoto v1). + #[serde(default)] + pub services: Vec, +} + +/// Estado de ejecución observado de un contenedor — el campo `{{.State}}` +/// de Docker, normalizado. Lo que distingue "monitoreo" de "inventario": +/// no *qué debería haber* sino *qué está pasando ahora*. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "kebab-case")] +pub enum RunState { + Created, + Restarting, + Running, + Paused, + Exited, + Dead, + Unknown, +} + +impl RunState { + /// Mapea el `{{.State}}` de Docker/Podman a la variante. + pub fn from_docker(s: &str) -> Self { + match s.trim().to_ascii_lowercase().as_str() { + "created" => RunState::Created, + "restarting" => RunState::Restarting, + "running" | "up" => RunState::Running, + "paused" => RunState::Paused, + "exited" | "stopped" => RunState::Exited, + "dead" => RunState::Dead, + _ => RunState::Unknown, + } + } + + /// `true` si el contenedor está vivo (corriendo o reiniciándose). + pub fn is_up(self) -> bool { + matches!(self, RunState::Running | RunState::Restarting) + } + + /// Glifo de semáforo para la UI: ● vivo, ◐ transición, ○ parado. + pub fn glyph(self) -> char { + match self { + RunState::Running => '●', + RunState::Restarting | RunState::Paused | RunState::Created => '◐', + RunState::Exited | RunState::Dead => '○', + RunState::Unknown => '◌', + } + } +} + +/// Estado runtime observado de un contenedor — la fila de `docker ps` +/// rica (no sólo el nombre). Es la unidad del monitoreo en vivo. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct ContainerStatus { + pub name: String, + pub image: String, + pub state: RunState, + /// Texto crudo de Docker: `Up 2 hours`, `Exited (0) 3 days ago`. + pub status: String, + /// Mapeos de puerto tal como los reporta Docker. + pub ports: String, +} + +/// Estado `ACTIVE` de un servicio systemd, normalizado. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "kebab-case")] +pub enum ServiceState { + Active, + Inactive, + Activating, + Deactivating, + Failed, + Unknown, +} + +impl ServiceState { + pub fn from_systemd(s: &str) -> Self { + match s.trim().to_ascii_lowercase().as_str() { + "active" => ServiceState::Active, + "inactive" => ServiceState::Inactive, + "activating" => ServiceState::Activating, + "deactivating" => ServiceState::Deactivating, + "failed" => ServiceState::Failed, + _ => ServiceState::Unknown, + } + } + + pub fn is_active(self) -> bool { + matches!(self, ServiceState::Active | ServiceState::Activating) + } + + /// Glifo de semáforo: ● activo, ◐ transición, ✖ fallado, ○ parado. + pub fn glyph(self) -> char { + match self { + ServiceState::Active => '●', + ServiceState::Activating | ServiceState::Deactivating => '◐', + ServiceState::Failed => '✖', + ServiceState::Inactive => '○', + ServiceState::Unknown => '◌', + } + } +} + +/// Estado runtime de un servicio systemd (una fila de `systemctl +/// list-units --type=service`). +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct ServiceStatus { + /// Nombre de la unidad — `sshd.service`. + pub name: String, + pub state: ServiceState, + /// El campo `SUB` de systemd: `running`, `exited`, `dead`, `failed`. + pub sub: String, + pub description: String, +} + +/// Foto runtime del servidor: contenedores + servicios + vhosts. +/// Distinta del `Inventory` declarativo — esto es lo *observado vivo*. +#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)] +pub struct RuntimeState { + pub containers: Vec, + pub services: Vec, + pub vhosts: Vec, +} + +impl RuntimeState { + /// Cuenta de contenedores vivos. + pub fn up_count(&self) -> usize { + self.containers.iter().filter(|c| c.state.is_up()).count() + } + + /// Cuenta de contenedores parados/muertos. + pub fn down_count(&self) -> usize { + self.containers.iter().filter(|c| !c.state.is_up()).count() + } + + /// Busca el estado runtime de un contenedor por nombre. + pub fn container(&self, name: &str) -> Option<&ContainerStatus> { + self.containers.iter().find(|c| c.name == name) + } + + /// Cuenta de servicios activos. + pub fn services_active(&self) -> usize { + self.services.iter().filter(|s| s.state.is_active()).count() + } + + /// Cuenta de servicios fallados. + pub fn services_failed(&self) -> usize { + self.services + .iter() + .filter(|s| s.state == ServiceState::Failed) + .count() + } +} + +/// Parsea `systemctl list-units --type=service --no-legend --plain`: una +/// fila `UNIT LOAD ACTIVE SUB DESCRIPTION…` por servicio. La descripción +/// (resto de la línea) puede tener espacios. Puro y testeable. +pub fn parse_systemctl_units(text: &str) -> Vec { + text.lines() + .filter_map(|line| { + let line = line.trim(); + if line.is_empty() { + return None; + } + let mut f = line.split_whitespace(); + let name = f.next()?.to_string(); + let _load = f.next()?; + let active = f.next()?; + let sub = f.next()?.to_string(); + let description = f.collect::>().join(" "); + Some(ServiceStatus { + name, + state: ServiceState::from_systemd(active), + sub, + description, + }) + }) + .collect() +} + +/// Formato rico que pedimos a Docker/Podman para el monitoreo: una fila +/// tab-separada por contenedor. Reutilizable por el discover local y el +/// remoto (SSH). +pub const DOCKER_PS_FORMAT: &str = "{{.Names}}\t{{.Image}}\t{{.State}}\t{{.Status}}\t{{.Ports}}"; + +/// Parsea la salida de `docker ps -a --format DOCKER_PS_FORMAT`: una fila +/// tab-separada por contenedor. Tolera campos faltantes (los rellena +/// vacíos) y descarta líneas sin nombre. Puro y testeable. +pub fn parse_docker_ps(text: &str) -> Vec { + text.lines() + .filter_map(|line| { + let line = line.trim_end_matches(['\r', '\n']); + if line.trim().is_empty() { + return None; + } + let mut f = line.split('\t'); + let name = f.next()?.trim().to_string(); + if name.is_empty() { + return None; + } + let image = f.next().unwrap_or("").trim().to_string(); + let state = RunState::from_docker(f.next().unwrap_or("")); + let status = f.next().unwrap_or("").trim().to_string(); + let ports = f.next().unwrap_or("").trim().to_string(); + Some(ContainerStatus { name, image, state, status, ports }) + }) + .collect() +} + +/// Muestra de uso de un contenedor: CPU y memoria como porcentaje. La unidad +/// del histograma CPU/mem del monitoreo (M2). Numérico (no el texto crudo de +/// Docker) para alimentar la sparkline. +#[derive(Debug, Clone, Copy, PartialEq, Serialize, Deserialize)] +pub struct ContainerStats { + pub cpu_pct: f32, + pub mem_pct: f32, +} + +/// Formato que pedimos a `docker stats --no-stream` para el muestreo: nombre +/// + CPU% + MEM%. Tab-separado, reutilizable local y remoto (SSH). +pub const DOCKER_STATS_FORMAT: &str = "{{.Name}}\t{{.CPUPerc}}\t{{.MemPerc}}"; + +/// Parsea un porcentaje de Docker (`12.34%`, `0.00%`) a `f32`. Tolera el +/// sufijo `%`, espacios y `--` (sin dato → 0.0). +fn parse_percent(s: &str) -> f32 { + s.trim().trim_end_matches('%').trim().parse::().unwrap_or(0.0) +} + +/// Parsea la salida de `docker stats --no-stream --format DOCKER_STATS_FORMAT`: +/// una fila `nombrecpu%mem%` por contenedor. Devuelve un mapa +/// `nombre → ContainerStats`. Puro y testeable. +pub fn parse_docker_stats(text: &str) -> std::collections::BTreeMap { + text.lines() + .filter_map(|line| { + let line = line.trim_end_matches(['\r', '\n']); + if line.trim().is_empty() { + return None; + } + let mut f = line.split('\t'); + let name = f.next()?.trim().to_string(); + if name.is_empty() { + return None; + } + let cpu_pct = parse_percent(f.next().unwrap_or("")); + let mem_pct = parse_percent(f.next().unwrap_or("")); + Some((name, ContainerStats { cpu_pct, mem_pct })) + }) + .collect() +} + +/// Observa el uso CPU/mem de los contenedores corriendo en *esta* máquina +/// (`docker stats --no-stream`). Vacío si docker no está. Bloqueante (~1-2 s: +/// docker muestrea un intervalo), pensado para correr en un thread de polling. +pub fn discover_stats() -> std::collections::BTreeMap { + run_local( + "docker", + &["stats", "--no-stream", "--format", DOCKER_STATS_FORMAT], + ) + .map(|t| parse_docker_stats(&t)) + .unwrap_or_default() } /// Parsea la salida de `docker ps -a --format '{{.Names}}'` — un nombre @@ -37,6 +309,51 @@ pub fn parse_docker_names(text: &str) -> Vec { .collect() } +/// Comando shell que sondea el estado declarativo (`is-enabled`/`is-active`) +/// de cada unidad en **un solo round-trip** SSH: emite una línea +/// `unitenabled-stateactive-state` por unidad. Pareja de +/// [`parse_service_states`]. Cadena vacía si no hay unidades. Las unidades se +/// asumen tokens válidos (declaradas en el inventario, no entrada de usuario); +/// se les quitan comillas simples por las dudas para no romper el quoting. +pub fn remote_service_probe_command(units: &[&str]) -> String { + if units.is_empty() { + return String::new(); + } + let list = units + .iter() + .map(|u| format!("'{}'", u.replace('\'', ""))) + .collect::>() + .join(" "); + format!( + "for u in {list}; do printf '%s\\t%s\\t%s\\n' \"$u\" \ + \"$(systemctl is-enabled \"$u\" 2>/dev/null || echo disabled)\" \ + \"$(systemctl is-active \"$u\" 2>/dev/null || echo inactive)\"; done" + ) +} + +/// Parsea la salida del sondeo de servicios declarados (una línea +/// `unitenabled-stateactive-state`). `enabled` ⇔ estado `enabled`; +/// `active` ⇔ estado `active` (mismo criterio que el `is-enabled`/`is-active` +/// local). Puro y testeable. +pub fn parse_service_states(text: &str) -> Vec { + text.lines() + .filter_map(|line| { + let line = line.trim_end_matches(['\r', '\n']); + if line.trim().is_empty() { + return None; + } + let mut f = line.split('\t'); + let unit = f.next()?.trim().to_string(); + if unit.is_empty() { + return None; + } + let enabled = f.next().unwrap_or("").trim() == "enabled"; + let active = f.next().unwrap_or("").trim() == "active"; + Some(ObservedService { unit, enabled, active }) + }) + .collect() +} + /// Parsea un listado de `/etc/nginx/sites-enabled` — un archivo por /// línea; el sufijo `.conf` se quita para quedarse con el dominio. pub fn parse_nginx_sites(text: &str) -> Vec { @@ -68,6 +385,16 @@ pub fn observed_inventory(state: &ServerState, desired: &Inventory) -> Inventory None => inv.add_vhost(VHost::to_address(domain, "(desconocido)")), } } + // Servicios: sólo los declarados (matilda no administra todo systemd). + // Reflejamos su estado observado → el plan emite Update si difiere del + // deseado, o nada si coincide. + for svc in &state.services { + inv.add_service( + Service::new(svc.unit.as_str()) + .with_enabled(svc.enabled) + .with_active(svc.active), + ); + } inv } @@ -215,9 +542,32 @@ pub fn discover_inventory(desired: &Inventory) -> Inventory { None => inv.add_vhost(VHost::to_address(&domain, "(huérfano)")), } } + // Servicios: sólo los declarados — consultamos su estado actual + // (`is-enabled`/`is-active`) para que el plan emita Update si difieren. + for svc in desired.services() { + let (enabled, active) = service_actual_state(&svc.unit); + inv.add_service( + Service::new(svc.unit.as_str()) + .with_enabled(enabled) + .with_active(active), + ); + } inv } +/// Consulta el estado actual de un servicio systemd: `(enabled, active)`. +/// `systemctl is-enabled`/`is-active` salen con código 0 sólo cuando lo +/// están; si systemctl no existe, ambos son `false`. +fn service_actual_state(unit: &str) -> (bool, bool) { + let enabled = run_local("systemctl", &["is-enabled", unit]) + .map(|s| s.trim() == "enabled") + .unwrap_or(false); + let active = run_local("systemctl", &["is-active", unit]) + .map(|s| s.trim() == "active") + .unwrap_or(false); + (enabled, active) +} + /// Observa el estado de *esta* máquina: `docker ps` + los sitios de /// nginx. Si docker no está o el directorio no existe, esa parte queda /// vacía (no es un error — quizá el servidor aún no tiene nada). @@ -228,7 +578,55 @@ pub fn discover_local() -> ServerState { let vhosts = run_local("ls", &["-1", "/etc/nginx/sites-enabled"]) .map(|t| parse_nginx_sites(&t)) .unwrap_or_default(); - ServerState { containers, vhosts } + ServerState { containers, vhosts, services: Vec::new() } +} + +/// Observa el estado **runtime** de esta máquina: `docker ps -a` con el +/// formato rico (estado + status + puertos) y los sitios de nginx. Es la +/// fuente del monitoreo en vivo del bloque de matilda. Si docker no está, +/// la lista de contenedores queda vacía (no es error). +pub fn discover_runtime() -> RuntimeState { + let containers = run_local("docker", &["ps", "-a", "--format", DOCKER_PS_FORMAT]) + .map(|t| parse_docker_ps(&t)) + .unwrap_or_default(); + let services = discover_services(); + let vhosts = run_local("ls", &["-1", "/etc/nginx/sites-enabled"]) + .map(|t| parse_nginx_sites(&t)) + .unwrap_or_default(); + RuntimeState { containers, services, vhosts } +} + +/// Observa el estado **runtime** de un servidor **remoto** vía un `exec` +/// transport-agnóstico (típicamente SSH): `docker ps -a` + los sitios de nginx. +/// **Read-only**: sólo corre comandos de lectura. El caller provee `exec(cmd) -> +/// Option` (None si el comando falla / no hay conexión). No depende de +/// SSH ni de ningún transporte: el enlace lo pone quien llama (p.ej. matilda-linker). +pub fn discover_remote(exec: impl Fn(&str) -> Option) -> RuntimeState { + let containers = exec(&format!("docker ps -a --format '{DOCKER_PS_FORMAT}'")) + .map(|t| parse_docker_ps(&t)) + .unwrap_or_default(); + let vhosts = exec("ls -1 /etc/nginx/sites-enabled 2>/dev/null") + .map(|t| parse_nginx_sites(&t)) + .unwrap_or_default(); + RuntimeState { containers, services: Vec::new(), vhosts } +} + +/// Observa los servicios systemd **operativamente interesantes**: los que +/// están corriendo o fallaron (no las cientos de unidades inactivas). Es +/// la base del monitoreo de servicios. Vacío si no hay systemctl. +pub fn discover_services() -> Vec { + run_local( + "systemctl", + &[ + "list-units", + "--type=service", + "--state=running,failed", + "--no-legend", + "--plain", + ], + ) + .map(|t| parse_systemctl_units(&t)) + .unwrap_or_default() } #[cfg(test)] @@ -236,6 +634,143 @@ mod tests { use super::*; use matilda_plan::{plan, Op}; + #[test] + fn parse_docker_ps_rico() { + let text = "web\tnginx:1.27\trunning\tUp 2 hours\t0.0.0.0:80->80/tcp\n\ + db\tpostgres:16\texited\tExited (0) 3 days ago\t\n"; + let cs = parse_docker_ps(text); + assert_eq!(cs.len(), 2); + assert_eq!(cs[0].name, "web"); + assert_eq!(cs[0].state, RunState::Running); + assert!(cs[0].state.is_up()); + assert_eq!(cs[0].status, "Up 2 hours"); + assert_eq!(cs[0].ports, "0.0.0.0:80->80/tcp"); + assert_eq!(cs[1].state, RunState::Exited); + assert!(!cs[1].state.is_up()); + // Campos faltantes (sin ports) no rompen el parseo. + assert_eq!(cs[1].ports, ""); + } + + #[test] + fn remote_service_probe_command_y_parser() { + // Sin unidades → comando vacío. + assert_eq!(remote_service_probe_command(&[]), ""); + // Con unidades → loop que las menciona. + let cmd = remote_service_probe_command(&["nginx.service", "sshd.service"]); + assert!(cmd.contains("'nginx.service'")); + assert!(cmd.contains("'sshd.service'")); + assert!(cmd.contains("is-enabled") && cmd.contains("is-active")); + // El parser lee unit/enabled/active. + let out = "nginx.service\tenabled\tactive\nsshd.service\tdisabled\tinactive\n"; + let svcs = parse_service_states(out); + assert_eq!(svcs.len(), 2); + assert_eq!(svcs[0].unit, "nginx.service"); + assert!(svcs[0].enabled && svcs[0].active); + assert!(!svcs[1].enabled && !svcs[1].active); + } + + #[test] + fn servicio_remoto_coincidente_no_es_create_espurio() { + use matilda_core::{Inventory, Service}; + let mut desired = Inventory::new(); + desired.add_service(Service::new("nginx")); // nginx.service, enabled+active + // El sondeo remoto coincide con el deseo. + let services = parse_service_states("nginx.service\tenabled\tactive\n"); + let state = ServerState { containers: vec![], vhosts: vec![], services }; + let current = observed_inventory(&state, &desired); + let p = plan(&desired, ¤t); + assert_eq!(p.count(Op::Create), 0, "el servicio existe → no Create"); + assert_eq!(p.count(Op::Update), 0, "coincide → no Update"); + } + + #[test] + fn servicio_remoto_desviado_emite_update() { + use matilda_core::{Inventory, Service}; + let mut desired = Inventory::new(); + desired.add_service(Service::new("nginx")); // quiere enabled+active + // Observado enabled pero NO active → drift → Update (no Create). + let services = parse_service_states("nginx.service\tenabled\tinactive\n"); + let state = ServerState { containers: vec![], vhosts: vec![], services }; + let current = observed_inventory(&state, &desired); + let p = plan(&desired, ¤t); + assert_eq!(p.count(Op::Create), 0); + assert_eq!(p.count(Op::Update), 1); + } + + #[test] + fn parse_docker_stats_porcentajes() { + let text = "web\t12.34%\t5.67%\ndb\t0.00%\t40.10%\nbad\t--\t--\n"; + let m = parse_docker_stats(text); + assert_eq!(m.len(), 3); + assert!((m["web"].cpu_pct - 12.34).abs() < 0.01); + assert!((m["web"].mem_pct - 5.67).abs() < 0.01); + assert_eq!(m["db"].cpu_pct, 0.0); + // `--` (sin dato) cae a 0.0 sin romper. + assert_eq!(m["bad"].cpu_pct, 0.0); + assert_eq!(m["bad"].mem_pct, 0.0); + } + + #[test] + fn runtime_state_cuenta_up_down() { + let rs = RuntimeState { + containers: vec![ + ContainerStatus { + name: "a".into(), + image: "x".into(), + state: RunState::Running, + status: "Up".into(), + ports: String::new(), + }, + ContainerStatus { + name: "b".into(), + image: "y".into(), + state: RunState::Exited, + status: "Exited".into(), + ports: String::new(), + }, + ContainerStatus { + name: "c".into(), + image: "z".into(), + state: RunState::Restarting, + status: "Restarting".into(), + ports: String::new(), + }, + ], + services: vec![], + vhosts: vec![], + }; + assert_eq!(rs.up_count(), 2); // running + restarting + assert_eq!(rs.down_count(), 1); + assert_eq!(rs.container("b").unwrap().state, RunState::Exited); + assert!(rs.container("nope").is_none()); + } + + #[test] + fn parse_systemctl_units_y_conteos() { + let text = "sshd.service loaded active running OpenSSH server daemon\n\ + nginx.service loaded active running A high performance web server\n\ + backup.service loaded failed failed Nightly backup\n"; + let svcs = parse_systemctl_units(text); + assert_eq!(svcs.len(), 3); + assert_eq!(svcs[0].name, "sshd.service"); + assert_eq!(svcs[0].state, ServiceState::Active); + assert_eq!(svcs[0].description, "OpenSSH server daemon"); + assert_eq!(svcs[2].state, ServiceState::Failed); + let rs = RuntimeState { containers: vec![], services: svcs, vhosts: vec![] }; + assert_eq!(rs.services_active(), 2); + assert_eq!(rs.services_failed(), 1); + } + + #[test] + fn run_state_glyphs_y_mapeo() { + assert_eq!(RunState::from_docker("RUNNING"), RunState::Running); + assert_eq!(RunState::from_docker("up"), RunState::Running); + assert_eq!(RunState::from_docker("dead"), RunState::Dead); + assert_eq!(RunState::from_docker("???"), RunState::Unknown); + assert_eq!(RunState::Running.glyph(), '●'); + assert_eq!(RunState::Exited.glyph(), '○'); + } + #[test] fn parses_docker_names() { let names = parse_docker_names("web\napi\n\n db \n"); @@ -253,7 +788,7 @@ mod tests { // Un contenedor presente que también se desea → sin cambios. let mut desired = Inventory::new(); desired.add_container(Container::new("web", "nginx:1.27")); - let state = ServerState { containers: vec!["web".into()], vhosts: vec![] }; + let state = ServerState { containers: vec!["web".into()], vhosts: vec![], services: vec![] }; let current = observed_inventory(&state, &desired); let p = plan(¤t, &desired); assert!(p.is_empty(), "presente y deseado → sin acciones"); @@ -263,7 +798,7 @@ mod tests { fn observed_orphan_becomes_a_removal() { // Un contenedor presente que NO se desea → se elimina. let desired = Inventory::new(); - let state = ServerState { containers: vec!["viejo".into()], vhosts: vec![] }; + let state = ServerState { containers: vec!["viejo".into()], vhosts: vec![], services: vec![] }; let current = observed_inventory(&state, &desired); let p = plan(¤t, &desired); assert_eq!(p.count(Op::Remove), 1); @@ -284,7 +819,7 @@ mod tests { fn create_and_remove_together() { let mut desired = Inventory::new(); desired.add_container(Container::new("nuevo", "img:1")); - let state = ServerState { containers: vec!["viejo".into()], vhosts: vec![] }; + let state = ServerState { containers: vec!["viejo".into()], vhosts: vec![], services: vec![] }; let p = plan(&observed_inventory(&state, &desired), &desired); assert_eq!(p.count(Op::Create), 1); assert_eq!(p.count(Op::Remove), 1); diff --git a/02_ruway/shuma/baremetal/matilda-linker/src/lib.rs b/02_ruway/shuma/baremetal/matilda-linker/src/lib.rs index e1efc8a..88affe8 100644 --- a/02_ruway/shuma/baremetal/matilda-linker/src/lib.rs +++ b/02_ruway/shuma/baremetal/matilda-linker/src/lib.rs @@ -110,6 +110,26 @@ impl Linker { Ok(String::from_utf8_lossy(&out.stdout).into_owned()) } + /// Streamea la salida de un comando de larga vida (`docker logs -f`). + /// `on_data` recibe cada chunk apenas llega; `should_stop` se chequea + /// cada `poll` para poder cortar el stream cerrando el canal. Delegado a + /// [`ssh::SshSession::exec_streaming`]. + pub async fn exec_streaming( + &self, + cmd: &str, + poll: std::time::Duration, + on_data: F, + should_stop: S, + ) -> Result<(), SshError> + where + F: FnMut(&[u8]), + S: FnMut() -> bool, + { + self.session + .exec_streaming(cmd, poll, on_data, should_stop) + .await + } + /// Aplica los pasos en orden sobre el host remoto. Se detiene en el /// primero que falle (semántica `set -e`). pub async fn apply(&self, steps: &[ApplyStep]) -> ApplyReport { diff --git a/02_ruway/shuma/baremetal/matilda-plan/src/lib.rs b/02_ruway/shuma/baremetal/matilda-plan/src/lib.rs index 09de1ea..ff48f09 100644 --- a/02_ruway/shuma/baremetal/matilda-plan/src/lib.rs +++ b/02_ruway/shuma/baremetal/matilda-plan/src/lib.rs @@ -27,6 +27,7 @@ pub enum Resource { Host, Container, VHost, + Service, } impl Resource { @@ -35,6 +36,7 @@ impl Resource { Resource::Host => "host", Resource::Container => "contenedor", Resource::VHost => "vhost", + Resource::Service => "servicio", } } } @@ -127,6 +129,18 @@ pub fn plan(current: &Inventory, desired: &Inventory) -> Plan { } } + // --- Fase 2b: servicios a crear/actualizar (independientes de los + // contenedores; van tras ellos por prolijidad del orden) --- + for s in desired.services() { + match current.service(&s.unit) { + None => actions.push(Action::new(Op::Create, Resource::Service, &s.unit)), + Some(cur) if cur != s => { + actions.push(Action::new(Op::Update, Resource::Service, &s.unit)) + } + Some(_) => {} + } + } + // --- Fase 3: vhosts a crear/actualizar --- for v in desired.vhosts() { match current.vhost(&v.domain) { @@ -152,6 +166,13 @@ pub fn plan(current: &Inventory, desired: &Inventory) -> Plan { } } + // --- Fase 5b: servicios a eliminar (dejados de declarar) --- + for s in current.services() { + if desired.service(&s.unit).is_none() { + actions.push(Action::new(Op::Remove, Resource::Service, &s.unit)); + } + } + // --- Fase 6: hosts a eliminar --- for h in current.hosts() { if desired.host(&h.name).is_none() { @@ -265,4 +286,27 @@ mod tests { let a = Action::new(Op::Create, Resource::Container, "web"); assert_eq!(a.describe(), "crear contenedor «web»"); } + + #[test] + fn service_diff_create_update_remove() { + use matilda_core::Service; + // Crear: deseado tiene el servicio, current no. + let mut desired = Inventory::new(); + desired.add_service(Service::new("nginx")); + let p = plan(&Inventory::new(), &desired); + assert_eq!(p.actions, vec![Action::new(Op::Create, Resource::Service, "nginx.service")]); + + // Update: difiere el estado (active). + let mut current = Inventory::new(); + current.add_service(Service::new("nginx").with_active(false)); + let p = plan(¤t, &desired); + assert_eq!(p.actions, vec![Action::new(Op::Update, Resource::Service, "nginx.service")]); + + // Remove: current lo tiene, desired no. + let p = plan(¤t, &Inventory::new()); + assert_eq!(p.actions, vec![Action::new(Op::Remove, Resource::Service, "nginx.service")]); + + // Igual → sin acciones. + assert!(plan(&desired, &desired.clone()).is_empty()); + } } diff --git a/02_ruway/shuma/baremetal/shuma-consola-android/Cargo.toml b/02_ruway/shuma/baremetal/shuma-consola-android/Cargo.toml new file mode 100644 index 0000000..901ca48 --- /dev/null +++ b/02_ruway/shuma/baremetal/shuma-consola-android/Cargo.toml @@ -0,0 +1,50 @@ +[package] +name = "shuma-consola-android" +version.workspace = true +edition.workspace = true +rust-version.workspace = true +license.workspace = true +authors.workspace = true +publish.workspace = true +description = "La consola de claudes desde el bolsillo — frontend Android (Llimphi) que hospeda shuma-module-consola alimentándolo por HTTP desde el gateway (ConsolaList/Snapshot/Crear/Enviar/Kill). Muchos tabs persistentes de sesiones agénticas de Claude Code, re-adjuntables, con logs por etapas. El mismo módulo que el escritorio; sólo cambia el transporte (registro in-process → cliente del gateway) y el empaquetado." + +# NativeActivity carga la lib como .so vía dlopen: cdylib con `android_main`. +# rlib además para que el example desktop y los tests consuman la misma app. +[lib] +crate-type = ["cdylib", "rlib"] + +[dependencies] +llimphi-ui = { workspace = true } +llimphi-theme = { workspace = true } +shuma-module-consola = { path = "../../sandbox/shuma-module-consola" } +shuma-consola-core = { path = "../../sandbox/shuma-consola-core" } +shuma-consola-client = { path = "../../sandbox/shuma-consola-client" } +llimphi-widget-text-input = { workspace = true } +log = "0.4" + +[target.'cfg(target_os = "android")'.dependencies] +android-activity = { version = "0.6", features = ["native-activity"] } +android_logger = "0.14" +winit = { workspace = true, features = ["android-native-activity"] } + +[[example]] +name = "consola_movil_desktop" +path = "examples/consola_movil_desktop.rs" + +[package.metadata.android] +package = "net.tawasuyu.consola" +build_targets = ["aarch64-linux-android", "x86_64-linux-android"] +min_sdk_version = 24 +target_sdk_version = 34 + +[package.metadata.android.application] +label = "Claudes" +debuggable = true + +[[package.metadata.android.uses_permission]] +name = "android.permission.INTERNET" + +[package.metadata.android.application.activity] +config_changes = "orientation|screenSize|keyboardHidden" +launch_mode = "singleTop" +orientation = "unspecified" diff --git a/02_ruway/shuma/baremetal/shuma-consola-android/LEEME.md b/02_ruway/shuma/baremetal/shuma-consola-android/LEEME.md new file mode 100644 index 0000000..c400dbb --- /dev/null +++ b/02_ruway/shuma/baremetal/shuma-consola-android/LEEME.md @@ -0,0 +1,22 @@ +# shuma-consola-android + +*Read this in English: [README.md](README.md).* + +La consola de claudes desde el bolsillo. + +**No reimplementa nada**: el cerebro es `shuma_module_consola` +(`State`/`Msg`/`update`/`view`) — el MISMO módulo que corre en el escritorio. +Aquí cambia sólo el **transporte**: en vez de manejar un `ConsolaRegistro` +in-process, este chasis alimenta al módulo por **HTTP contra el gateway** +(`shuma_consola_client::GatewayClient`) — polling de `ConsolaList` + +`ConsolaSnapshot`, y `Crear/Enviar/Leida/Kill` al tocar. Igual que +matilda-android con el admin de servidores: una instancia, pantalla +completa. + +El polling y las mutaciones corren en **hilos** (`handle.spawn`) que al +volver reinyectan un `Msg` — el `GatewayClient` es bloqueante (ureq), sin +runtime async. + +--- + +Parte de **shuma** — ver [shuma](../../LEEME.md). diff --git a/02_ruway/shuma/baremetal/shuma-consola-android/README.md b/02_ruway/shuma/baremetal/shuma-consola-android/README.md new file mode 100644 index 0000000..05e9316 --- /dev/null +++ b/02_ruway/shuma/baremetal/shuma-consola-android/README.md @@ -0,0 +1,12 @@ +# shuma-consola-android + +The console of claudes from your pocket. + +It **reimplements nothing**: the brain is `shuma_module_consola` +(`State`/`Msg`/`update`/`view`) — the SAME module that runs on the desktop. Only +the **transport** changes: instead of driving an in-process `ConsolaRegistro`, this +chassis feeds the module over **HTTP against the gateway**. + +--- + +Part of **shuma** — see [shuma](../../README.md). diff --git a/02_ruway/shuma/baremetal/shuma-consola-android/examples/consola_movil_desktop.rs b/02_ruway/shuma/baremetal/shuma-consola-android/examples/consola_movil_desktop.rs new file mode 100644 index 0000000..a910426 --- /dev/null +++ b/02_ruway/shuma/baremetal/shuma-consola-android/examples/consola_movil_desktop.rs @@ -0,0 +1,10 @@ +//! Corre la consola móvil en **escritorio** (misma app, otro runner) para ver +//! la forma sobre un gateway real, antes de empaquetar el APK. +//! +//! Necesita un gateway corriendo. Config por env: +//! CONSOLA_GATEWAY=http://127.0.0.1:7391 CONSOLA_CWD=/tmp \ +//! cargo run -p shuma-consola-android --example consola_movil_desktop + +fn main() { + shuma_consola_android::correr(); +} diff --git a/02_ruway/shuma/baremetal/shuma-consola-android/manifest.yaml b/02_ruway/shuma/baremetal/shuma-consola-android/manifest.yaml new file mode 100644 index 0000000..8d4823a --- /dev/null +++ b/02_ruway/shuma/baremetal/shuma-consola-android/manifest.yaml @@ -0,0 +1,16 @@ +# Manifiesto xbuild — fuente de los permisos/label del APK. Sin INTERNET el +# cliente HTTP al gateway muere con Permission denied. +android: + manifest: + package: net.tawasuyu.consola + uses_permission: + - name: android.permission.INTERNET + application: + label: Claudes + activities: + - config_changes: orientation|screenSize|keyboardHidden + launch_mode: singleTop + sdk: + min_sdk_version: 24 + # xbuild 0.2.0 no soporta targetSdk 34 — 33 es el techo que empaqueta. + target_sdk_version: 33 diff --git a/02_ruway/shuma/baremetal/shuma-consola-android/src/android.rs b/02_ruway/shuma/baremetal/shuma-consola-android/src/android.rs new file mode 100644 index 0000000..6a48684 --- /dev/null +++ b/02_ruway/shuma/baremetal/shuma-consola-android/src/android.rs @@ -0,0 +1,30 @@ +//! Entry-point Android: NativeActivity dlopen-ea la cdylib y `android-activity` +//! invoca este `android_main`. Todo lo específico del target vive aquí — la app +//! ([`crate::ConsolaMovil`]) no sabe dónde corre. + +const TAG: &str = "consola"; + +#[no_mangle] +fn android_main(app: android_activity::AndroidApp) { + android_logger::init_once( + android_logger::Config::default() + .with_max_level(log::LevelFilter::Info) + .with_tag(TAG), + ); + // Sin esto un panic muere en silencio antes de flushear. El hook lo manda + // a logcat primero. + std::panic::set_hook(Box::new(|info| { + log::error!("PANIC: {info}"); + })); + + // HOME/CONSOLA_DIR → dir interno de la app: ahí vive `consola.json` + // (gateway + token + cwd), empujado una vez con `adb push`. + if let Some(dir) = app.internal_data_path() { + std::env::set_var("HOME", &dir); + std::env::set_var("CONSOLA_DIR", &dir); + log::info!("HOME/CONSOLA_DIR = {}", dir.display()); + } + + log::info!("android_main → llimphi_ui::run_android"); + llimphi_ui::run_android::(app); +} diff --git a/02_ruway/shuma/baremetal/shuma-consola-android/src/config.rs b/02_ruway/shuma/baremetal/shuma-consola-android/src/config.rs new file mode 100644 index 0000000..024d97a --- /dev/null +++ b/02_ruway/shuma/baremetal/shuma-consola-android/src/config.rs @@ -0,0 +1,82 @@ +//! De dónde saca el chasis el gateway al que hablar. +//! +//! Orden: variables de entorno (`CONSOLA_GATEWAY` / `CONSOLA_TOKEN` / +//! `CONSOLA_CWD`) y, si no están, un `consola.json` en el dir de datos de la +//! app (empujado con `adb push` una vez, como matilda con `matilda.json`). + +use std::path::PathBuf; + +/// Config resuelta del chasis. +#[derive(Clone, Debug)] +pub struct Config { + /// Base del gateway, sin barra final (p.ej. `http://192.168.1.20:7378`). + pub gateway: String, + /// Token bearer si el gateway lo exige. + pub token: Option, + /// Directorio de trabajo **en el server** donde corre cada claude nuevo. + pub cwd: String, +} + +impl Default for Config { + fn default() -> Self { + Self { + gateway: "http://127.0.0.1:7378".to_string(), + token: None, + cwd: ".".to_string(), + } + } +} + +/// Dir de datos de la app (Android) o `.` en desktop. +pub fn dir_datos() -> PathBuf { + std::env::var_os("CONSOLA_DIR") + .map(PathBuf::from) + .or_else(|| std::env::var_os("HOME").map(PathBuf::from)) + .unwrap_or_else(|| PathBuf::from(".")) +} + +/// Carga la config: env primero, `consola.json` después, defaults al final. +pub fn cargar() -> Config { + let mut cfg = leer_json(&dir_datos().join("consola.json")).unwrap_or_default(); + if let Ok(g) = std::env::var("CONSOLA_GATEWAY") { + if !g.trim().is_empty() { + cfg.gateway = g.trim_end_matches('/').to_string(); + } + } + if let Ok(t) = std::env::var("CONSOLA_TOKEN") { + if !t.trim().is_empty() { + cfg.token = Some(t); + } + } + if let Ok(c) = std::env::var("CONSOLA_CWD") { + if !c.trim().is_empty() { + cfg.cwd = c; + } + } + cfg +} + +/// Parseo minimalista de `consola.json` (sin serde: 3 campos string) — evita +/// arrastrar serde_json sólo para esto. `{"gateway":"…","token":"…","cwd":"…"}`. +fn leer_json(path: &std::path::Path) -> Option { + let texto = std::fs::read_to_string(path).ok()?; + let campo = |k: &str| -> Option { + let pat = format!("\"{k}\""); + let i = texto.find(&pat)? + pat.len(); + let resto = &texto[i..]; + let c = resto.find(':')? + 1; + let resto = resto[c..].trim_start(); + let resto = resto.strip_prefix('"')?; + let j = resto.find('"')?; + Some(resto[..j].to_string()) + }; + let mut cfg = Config::default(); + if let Some(g) = campo("gateway") { + cfg.gateway = g.trim_end_matches('/').to_string(); + } + cfg.token = campo("token").filter(|s| !s.is_empty()); + if let Some(c) = campo("cwd") { + cfg.cwd = c; + } + Some(cfg) +} diff --git a/02_ruway/shuma/baremetal/shuma-consola-android/src/lib.rs b/02_ruway/shuma/baremetal/shuma-consola-android/src/lib.rs new file mode 100644 index 0000000..28df770 --- /dev/null +++ b/02_ruway/shuma/baremetal/shuma-consola-android/src/lib.rs @@ -0,0 +1,209 @@ +//! `shuma-consola-android` — la consola de claudes desde el bolsillo. +//! +//! **No reimplementa nada**: el cerebro es [`shuma_module_consola`] +//! (`State`/`Msg`/`update`/`view`) — el MISMO módulo que corre en el escritorio. +//! Aquí cambia sólo el **transporte**: en vez de manejar un `ConsolaRegistro` +//! in-process, este chasis alimenta al módulo por **HTTP contra el gateway** +//! ([`shuma_consola_client::GatewayClient`]) — polling de `ConsolaList` + +//! `ConsolaSnapshot`, y `Crear/Enviar/Leida/Kill` al tocar. Igual que +//! matilda-android con el admin de servidores: una instancia, pantalla +//! completa. +//! +//! El polling y las mutaciones corren en **hilos** (`handle.spawn`) que al +//! volver reinyectan un `Msg` — el `GatewayClient` es bloqueante (ureq), sin +//! runtime async. + +use llimphi_ui::{App, Handle, Key, KeyEvent, KeyState, NamedKey, View}; +use llimphi_theme::Theme; +use llimphi_widget_text_input::TextInputEvent; +use shuma_consola_client::GatewayClient; +use shuma_consola_core::Sesion; +use shuma_module_consola::{self as consola, State, Tab}; + +pub mod config; + +#[cfg(target_os = "android")] +mod android; + +/// Cada cuánto se repolla el gateway. +const POLL_MS: u64 = 500; + +pub struct Modelo { + st: State, + theme: Theme, + cliente: GatewayClient, + cwd: String, +} + +#[derive(Clone, Debug)] +pub enum Msg { + /// Mensaje del módulo (lifteado). + M(consola::Msg), + /// Tick de polling: dispara un fetch en un hilo. + Tick, + /// Datos frescos del gateway (tabs + snapshot de la activa). + Datos { tabs: Vec, sesion: Option }, + /// Se creó una sesión: auto-seleccionarla. + Creada(String), + /// Ancho + alto del transcript tras un resize. + Medida(f32, f32), + /// Un fetch/comando falló (se muestra en el header luego; por ahora no-op). + Nada, +} + +impl App for ConsolaMovil { + type Model = Modelo; + type Msg = Msg; + + fn title() -> &'static str { + "Claudes" + } + + fn initial_size() -> (u32, u32) { + (420, 860) // proporción de teléfono vertical (Android lo ignora) + } + + fn init(handle: &Handle) -> Modelo { + let cfg = config::cargar(); + log::info!("consola → gateway {}", cfg.gateway); + handle.spawn_periodic(std::time::Duration::from_millis(POLL_MS), || Msg::Tick); + let mut st = State::new(); + st.fijar_vista_alto(860.0 - 98.0); + st.fijar_vista_ancho(420.0); + Modelo { + st, + theme: Theme::dark(), + cliente: GatewayClient::new(cfg.gateway, cfg.token), + cwd: cfg.cwd, + } + } + + fn update(mut m: Modelo, msg: Msg, handle: &Handle) -> Modelo { + match msg { + Msg::M(mm) => { + m.st = consola::update(m.st, mm); + drenar_intents(&mut m, handle); + } + Msg::Tick => { + let c = m.cliente.clone(); + let activa = m.st.activa.clone(); + handle.spawn(move || { + let tabs = c + .list() + .unwrap_or_default() + .into_iter() + .map(|r| Tab { id: r.id, titulo: r.titulo, atencion: r.atencion }) + .collect(); + let sesion = activa.and_then(|id| c.snapshot(&id).ok().flatten()); + Msg::Datos { tabs, sesion } + }); + } + Msg::Datos { tabs, sesion } => m.st.refrescar(tabs, sesion), + Msg::Creada(id) => m.st.set_activa(Some(id)), + Msg::Medida(w, h) => { + m.st.fijar_vista_ancho(w); + m.st.fijar_vista_alto(h); + } + Msg::Nada => {} + } + m + } + + fn view(m: &Modelo) -> View { + consola::view(&m.st, &m.theme, Msg::M) + } + + fn on_key(_m: &Modelo, ev: &KeyEvent) -> Option { + if ev.state == KeyState::Pressed && matches!(ev.key, Key::Named(NamedKey::Enter)) { + return Some(Msg::M(consola::Msg::Enviar)); + } + Some(Msg::M(consola::Msg::CampoInput(TextInputEvent::Key(ev.clone())))) + } + + fn on_resize(_m: &Modelo, w: u32, h: u32) -> Option { + Some(Msg::Medida(w as f32, (h as f32 - 98.0).max(120.0))) + } +} + +/// Drena los intents del módulo y los ejecuta en hilos contra el gateway. +fn drenar_intents(m: &mut Modelo, handle: &Handle) { + if let Some(texto) = m.st.take_crear() { + let (c, cwd) = (m.cliente.clone(), m.cwd.clone()); + handle.spawn(move || match c.crear(&cwd, &texto, None) { + Ok(id) => Msg::Creada(id), + Err(e) => { + log::warn!("crear: {e}"); + Msg::Nada + } + }); + } + if let Some((id, texto)) = m.st.take_enviar() { + let c = m.cliente.clone(); + handle.spawn(move || { + if let Err(e) = c.enviar(&id, &texto) { + log::warn!("enviar: {e}"); + } + Msg::Tick // repollear ya para ver el turno arrancar + }); + } + if let Some(id) = m.st.take_seleccion() { + let c = m.cliente.clone(); + handle.spawn(move || { + let _ = c.leida(&id); + Msg::Tick + }); + } + if let Some(id) = m.st.take_cerrar() { + let c = m.cliente.clone(); + handle.spawn(move || { + let _ = c.kill(&id); + Msg::Tick + }); + } +} + +/// La app. Su `View` es el módulo de consola; el chasis sólo cablea el +/// transporte HTTP. +pub struct ConsolaMovil; + +/// Corre en escritorio (example / debugging) — misma app, otro runner. +pub fn correr() { + llimphi_ui::run::(); +} + +#[cfg(test)] +mod tests { + use super::*; + + fn contar(v: &View) -> usize { + 1 + v.children.iter().map(contar).sum::() + } + + /// El árbol monta con estado inicial (sin datos aún) — smoke del chasis + /// completo sin GPU ni gateway. + #[test] + fn view_inicial_monta() { + let handle: Handle = Handle::for_test(); + let m = ConsolaMovil::init(&handle); + let v = ConsolaMovil::view(&m); + assert!(contar(&v) > 6, "árbol sospechosamente chico"); + } + + /// Enter emite Enviar; una tecla normal va al input. + #[test] + fn enter_envia() { + let ev = KeyEvent { + key: Key::Named(NamedKey::Enter), + state: KeyState::Pressed, + text: None, + modifiers: Default::default(), + repeat: false, + }; + let handle: Handle = Handle::for_test(); + let m = ConsolaMovil::init(&handle); + assert!(matches!( + ConsolaMovil::on_key(&m, &ev), + Some(Msg::M(consola::Msg::Enviar)) + )); + } +} diff --git a/02_ruway/shuma/pantallazo.png b/02_ruway/shuma/pantallazo.png new file mode 100644 index 0000000..228bfcb Binary files /dev/null and b/02_ruway/shuma/pantallazo.png differ diff --git a/02_ruway/shuma/sandbox/shuma-agente-host/Cargo.toml b/02_ruway/shuma/sandbox/shuma-agente-host/Cargo.toml new file mode 100644 index 0000000..a829703 --- /dev/null +++ b/02_ruway/shuma/sandbox/shuma-agente-host/Cargo.toml @@ -0,0 +1,14 @@ +[package] +name = "shuma-agente-host" +version.workspace = true +edition.workspace = true +license.workspace = true +authors.workspace = true +publish.workspace = true +description = "shuma — el lado host del núcleo shuma-agente: corre un turno de conversación con pluma-llm (resuelve backend del agente o fallback global, arma el ChatRequest, interpreta la salida en bloques). Lo de red/tokio que el núcleo agnóstico no toca." + +[dependencies] +shuma-agente = { path = "../shuma-agente" } +pluma-llm = { workspace = true } +wawa-config = { workspace = true } +tokio = { workspace = true } diff --git a/02_ruway/shuma/sandbox/shuma-agente-host/LEEME.md b/02_ruway/shuma/sandbox/shuma-agente-host/LEEME.md new file mode 100644 index 0000000..0f895ed --- /dev/null +++ b/02_ruway/shuma/sandbox/shuma-agente-host/LEEME.md @@ -0,0 +1,19 @@ +# shuma-agente-host + +*Read this in English: [README.md](README.md).* + +# shuma-agente-host — corre un turno de conversación + +El núcleo `shuma_agente` es sync y sin red: arma el `ChatRequest` e +interpreta la respuesta, pero no habla con ningún backend. Aquí vive ese +pegamento: resolver el backend (propio del agente, o el `[ai.llm]` global del +SO como fallback, o `from_env`), correr `pluma-llm` en un runtime efímero, y +devolver los `BloqueSalida` ya interpretados. + +Es **bloqueante** a propósito: el host lo llama en un thread aparte +(`Handle::spawn`), igual que el `run_llm_blocking` del shell — el bucle Elm +nunca se cuelga esperando la red. + +--- + +Parte de **shuma** — ver [shuma](../../LEEME.md). diff --git a/02_ruway/shuma/sandbox/shuma-agente-host/README.md b/02_ruway/shuma/sandbox/shuma-agente-host/README.md new file mode 100644 index 0000000..38ecaf4 --- /dev/null +++ b/02_ruway/shuma/sandbox/shuma-agente-host/README.md @@ -0,0 +1,11 @@ +# shuma-agente-host + +Runs one turn of a conversation. + +The `shuma_agente` core is sync and network-free. This is the glue: resolving the +backend (the agent's own, or the OS-wide `[ai.llm]` as a fallback, or `from_env`), +running `pluma-llm` in an ephemeral runtime, and handing the answer back. + +--- + +Part of **shuma** — see [shuma](../../README.md). diff --git a/02_ruway/shuma/sandbox/shuma-agente-host/src/lib.rs b/02_ruway/shuma/sandbox/shuma-agente-host/src/lib.rs new file mode 100644 index 0000000..5df2e2f --- /dev/null +++ b/02_ruway/shuma/sandbox/shuma-agente-host/src/lib.rs @@ -0,0 +1,132 @@ +//! # shuma-agente-host — corre un turno de conversación +//! +//! El núcleo [`shuma_agente`] es sync y sin red: arma el `ChatRequest` e +//! interpreta la respuesta, pero no habla con ningún backend. Aquí vive ese +//! pegamento: resolver el backend (propio del agente, o el `[ai.llm]` global del +//! SO como fallback, o `from_env`), correr `pluma-llm` en un runtime efímero, y +//! devolver los [`BloqueSalida`] ya interpretados. +//! +//! Es **bloqueante** a propósito: el host lo llama en un thread aparte +//! (`Handle::spawn`), igual que el `run_llm_blocking` del shell — el bucle Elm +//! nunca se cuelga esperando la red. + +use shuma_agente::{motor, Agente, BloqueSalida, Conversacion}; + +/// El desenlace de un turno: los bloques interpretados y, si el backend lo +/// reporta, el conteo de tokens (para mostrar costo en la UI). +#[derive(Debug, Clone)] +pub struct Respuesta { + pub bloques: Vec, + pub input_tokens: u32, + pub output_tokens: u32, +} + +/// Corre un turno: toma la conversación (con el último mensaje del usuario ya +/// agregado) + el agente + el backend global de fallback, y devuelve la +/// respuesta interpretada. Bloqueante. +/// +/// Resolución de backend: si `agente.backend` fija uno, se usa ese; si no, el +/// `fallback_global` (típicamente `WawaConfig::load().ai.llm`); si tampoco está +/// fijo, `pluma-llm::from_env` (Mock si no hay credenciales — nunca cuelga). +pub fn responder( + conv: &Conversacion, + agente: &Agente, + fallback_global: &wawa_config::LlmSettings, +) -> Result { + responder_streaming(conv, agente, fallback_global, |_| {}) +} + +/// Como [`responder`] pero **emitiendo la salida a medida que llega**: `on_delta` +/// se llama con cada fragmento de texto. Útil para que la UI pinte la respuesta +/// progresiva (paridad con Claude CLI). Devuelve la respuesta final interpretada. +/// +/// Sólo es incremental si el backend soporta streaming (hoy `claude-cli`); el +/// resto cae al default no-incremental del trait (emite todo al final). +pub fn responder_streaming( + conv: &Conversacion, + agente: &Agente, + fallback_global: &wawa_config::LlmSettings, + mut on_delta: impl FnMut(&str) + Send, +) -> Result { + use pluma_llm::pluma_llm_core::ChatClient; + + let req = motor::construir_request(conv, agente); + let backend = if agente.backend.is_set() { + &agente.backend + } else { + fallback_global + }; + + let rt = tokio::runtime::Builder::new_current_thread() + .enable_all() + .build() + .map_err(|e| format!("runtime: {e}"))?; + + let resp = rt.block_on(async { + let client: std::sync::Arc = + build_client(backend).map_err(|e| format!("sin backend LLM: {e}"))?; + client.stream(&req, &mut on_delta).await.map_err(|e| e.to_string()) + })?; + + let bloques = motor::interpretar_respuesta(&resp.content, agente); + let (input_tokens, output_tokens) = resp + .usage + .map(|u| (u.input_tokens, u.output_tokens)) + .unwrap_or((0, 0)); + Ok(Respuesta { bloques, input_tokens, output_tokens }) +} + +/// Traduce los `LlmSettings` planos al `LlmConfig` de pluma-llm y construye el +/// cliente. Idéntico criterio que el `build_llm_client` del shell — duplicado +/// mínimo a propósito (no vale acoplar shell-llimphi y este crate por una fn). +fn build_client( + s: &wawa_config::LlmSettings, +) -> Result, String> { + use pluma_llm::{build_client, BackendKind, LlmConfig}; + if !s.is_set() { + return pluma_llm::from_env().map_err(|e| e.to_string()); + } + let kind = match s.backend.trim().to_lowercase().as_str() { + "anthropic" => BackendKind::Anthropic, + "gemini" => BackendKind::Gemini, + "deepseek" => BackendKind::DeepSeek, + "cohere" => BackendKind::Cohere, + "ollama" => BackendKind::Ollama, + "claude-cli" | "claude-code" => BackendKind::ClaudeCli, + "mock" => BackendKind::Mock, + other => return Err(format!("backend LLM desconocido: «{other}»")), + }; + let none_if_empty = |v: &str| { + let v = v.trim(); + (!v.is_empty()).then(|| v.to_string()) + }; + let cfg = LlmConfig { + kind, + model: none_if_empty(&s.model), + api_key: none_if_empty(&s.api_key), + endpoint: none_if_empty(&s.endpoint), + }; + build_client(&cfg).map_err(|e| e.to_string()) +} + +#[cfg(test)] +mod tests { + use super::*; + + /// Round-trip completo contra el backend Mock (sin red ni credenciales): + /// usuario pregunta → el host corre pluma-llm → la respuesta se interpreta + /// en bloques. Prueba que el contrato núcleo↔host cierra de punta a punta. + #[test] + fn round_trip_con_mock() { + let mut backend = wawa_config::LlmSettings::default(); + backend.backend = "mock".into(); + let agente = Agente::nuevo("Asistente").con_backend(backend); + + let mut conv = Conversacion::nueva(&agente.id, 0); + conv.agregar_usuario("hola, ¿cómo estás?", 1); + + let global = wawa_config::LlmSettings::default(); + let resp = responder(&conv, &agente, &global).expect("mock no debería fallar"); + assert!(!resp.bloques.is_empty(), "el mock siempre devuelve algo"); + } +} diff --git a/02_ruway/shuma/sandbox/shuma-agente/Cargo.toml b/02_ruway/shuma/sandbox/shuma-agente/Cargo.toml new file mode 100644 index 0000000..1b75aad --- /dev/null +++ b/02_ruway/shuma/sandbox/shuma-agente/Cargo.toml @@ -0,0 +1,25 @@ +[package] +name = "shuma-agente" +version.workspace = true +edition.workspace = true +license.workspace = true +authors.workspace = true +publish.workspace = true +description = "shuma — núcleo agnóstico de la IA conversacional: agentes configurables (backend+persona+capacidades), conversaciones multi-turno persistidas, y el motor que arma el ChatRequest e interpreta la salida en bloques (texto/código/acción de control). Sin UI, sin red: el host corre pluma-llm." + +[dependencies] +serde = { workspace = true } +serde_json = { workspace = true } +uuid = { workspace = true } +sled = { workspace = true } +thiserror = { workspace = true } + +# Tipos del contrato LLM (ChatRequest/ChatMessage) — liviano, sin red. +pluma-llm-core = { workspace = true } +# Catálogo seguro de acciones de control (el agente propone, no inventa flags). +atipay = { workspace = true } +# El álgebra de creencia (Jøsang): el termostato epistémico lee la reputación +# DERIVADA del propio agente, no un flag de config (PLAN-AYLLU E3). +iniy-core = { workspace = true } +# Backend por agente (proveedor + modelo + API key + endpoint). +wawa-config = { workspace = true } diff --git a/02_ruway/shuma/sandbox/shuma-agente/LEEME.md b/02_ruway/shuma/sandbox/shuma-agente/LEEME.md new file mode 100644 index 0000000..3fc054a --- /dev/null +++ b/02_ruway/shuma/sandbox/shuma-agente/LEEME.md @@ -0,0 +1,37 @@ +# shuma-agente + +*Read this in English: [README.md](README.md).* + +# shuma-agente — núcleo de la IA conversacional de shuma + +Hoy la IA de shuma es **invocación atómica**: cada `:?`/`:haz`/`:explica` +arma un `ChatRequest::una_vuelta` (turno único, sin memoria) y vuelca la +respuesta al input o al bloque. Este crate sube un escalón: modela +**múltiples agentes** configurables y **conversaciones multi-turno** +persistidas — el modelo de las apps web de IA (un panel de charlas, cada una +contra un agente), pero embebido en la suite. + +## Reparto de responsabilidades (mismo patrón que el resto de shuma) + +El módulo/host **expresa la intención y corre la red**; este núcleo es +**sync, puro y testeable**, sin tocar sockets ni `tokio`: + +- `Agente` — identidad + backend (`wawa_config::LlmSettings` por agente) + + persona (`system_prompt`) + qué `Capacidades` de control puede proponer. +- `Conversacion` — hilo multi-turno (`Turno`s usuario/asistente), cada + turno del asistente desglosado en `BloqueSalida`s (texto, código, acción). +- `motor` — `construir_request` arma el `ChatRequest` con todo el + historial; `interpretar_respuesta` parte el texto crudo del modelo en + bloques (la **gama de outputs**). El host hace el `.complete()` con + `pluma-llm` en un thread, igual que con el `LlmRequest` del shell. +- `Almacen` — persistencia sled de agentes y conversaciones. + +Las acciones de control nunca se auto-ejecutan: el agente las **propone** +como `AccionPropuesta` validada por `atipay`, y el usuario aprueba — +exactamente la doctrina de `:haz`. + +`ChatRequest`: pluma_llm_core::ChatRequest + +--- + +Parte de **shuma** — ver [shuma](../../LEEME.md). diff --git a/02_ruway/shuma/sandbox/shuma-agente/README.md b/02_ruway/shuma/sandbox/shuma-agente/README.md new file mode 100644 index 0000000..a4a0824 --- /dev/null +++ b/02_ruway/shuma/sandbox/shuma-agente/README.md @@ -0,0 +1,15 @@ +# shuma-agente + +The core of shuma's conversational AI. + +Today shuma's AI is **atomic invocation**: each `:?`/`:haz`/`:explica` builds a +`ChatRequest::una_vuelta` (a single turn, no memory) and dumps the answer into the +input or the block. This crate raises that one step: it models **multiple +configurable agents** and **multi-turn conversations**. + +It is sync and network-free: it assembles the `ChatRequest` and interprets the +answer, but talks to no backend. + +--- + +Part of **shuma** — see [shuma](../../README.md). diff --git a/02_ruway/shuma/sandbox/shuma-agente/src/agente.rs b/02_ruway/shuma/sandbox/shuma-agente/src/agente.rs new file mode 100644 index 0000000..b836c9e --- /dev/null +++ b/02_ruway/shuma/sandbox/shuma-agente/src/agente.rs @@ -0,0 +1,130 @@ +//! El agente: una configuración de IA con identidad, backend y permisos. + +use atipay::Peligro; +use serde::{Deserialize, Serialize}; + +use crate::termostato::Autonomia; + +/// Un agente IA configurable. Es la unidad que el usuario crea/edita en el +/// wawapanel: a qué proveedor pega, con qué persona, y qué puede hacer. +/// +/// El `backend` es un [`wawa_config::LlmSettings`] **propio del agente** — así +/// se pueden mezclar proveedores (un agente Claude, otro Ollama local) sin +/// tocar el `[ai.llm]` global del SO. Si `backend.is_set()` es `false`, el host +/// hereda el backend global (resolución por `from_env`), igual que hoy. +#[derive(Clone, Debug, Serialize, Deserialize, PartialEq)] +pub struct Agente { + /// Identificador estable (uuid v4). No cambia al renombrar. + pub id: String, + /// Nombre visible: "Asistente", "DevOps", "Traductor"… + pub nombre: String, + /// Una línea de qué es/para qué sirve (se muestra en el selector). + #[serde(default)] + pub descripcion: String, + /// Backend propio: proveedor + modelo + API key + endpoint. `backend` + /// vacío = heredar el `[ai.llm]` global del SO. + #[serde(default)] + pub backend: wawa_config::LlmSettings, + /// Instrucción de sistema (persona/rol). Vacío = persona genérica. + #[serde(default)] + pub system_prompt: String, + /// Determinismo 0.0–1.0 (bajo para tareas técnicas, alto para creativo). + #[serde(default = "temperatura_default")] + pub temperatura: f32, + /// Tope de tokens de salida por turno. + #[serde(default = "max_tokens_default")] + pub max_tokens: u32, + /// Qué acciones de control puede **proponer** el agente. + #[serde(default)] + pub capacidades: Capacidades, + /// Color de acento (hex `#rrggbb`) para la UI; `None` = el del theme. + #[serde(default)] + pub color: Option, +} + +fn temperatura_default() -> f32 { + 0.4 +} +fn max_tokens_default() -> u32 { + 1024 +} + +impl Agente { + /// Un agente nuevo con `id` aleatorio y defaults razonables. + pub fn nuevo(nombre: impl Into) -> Self { + Self { + id: uuid::Uuid::new_v4().to_string(), + nombre: nombre.into(), + descripcion: String::new(), + backend: wawa_config::LlmSettings::default(), + system_prompt: String::new(), + temperatura: temperatura_default(), + max_tokens: max_tokens_default(), + capacidades: Capacidades::default(), + color: None, + } + } + + /// Encadenable: fija la persona (system prompt). + pub fn con_persona(mut self, system_prompt: impl Into) -> Self { + self.system_prompt = system_prompt.into(); + self + } + + /// Encadenable: fija el backend del agente. + pub fn con_backend(mut self, backend: wawa_config::LlmSettings) -> Self { + self.backend = backend; + self + } + + /// Encadenable: habilita las acciones de control del escritorio. + pub fn con_control(mut self) -> Self { + self.capacidades.control = true; + self + } + + /// Encadenable: descripción corta. + pub fn con_descripcion(mut self, d: impl Into) -> Self { + self.descripcion = d.into(); + self + } +} + +/// Qué acciones de control (atipay) puede **proponer** el agente. Nunca ejecuta +/// solo: propone una [`crate::AccionPropuesta`] y el usuario aprueba (la misma +/// doctrina de `:haz`). Sin `control`, el agente es sólo-charla. +#[derive(Clone, Debug, Default, Serialize, Deserialize, PartialEq, Eq)] +pub struct Capacidades { + /// Si `false`, el agente no propone acciones (charla pura). + #[serde(default)] + pub control: bool, + /// Lista blanca de superficies atipay permitidas, por prefijo + /// (`"mirada"`, `"sistema"`, `"sandokan"`, `"shuma"`). Vacío = todas las del + /// catálogo estándar. Una acción cuya superficie no esté aquí se rechaza al + /// interpretarla, aunque el modelo la haya elegido. + #[serde(default)] + pub superficies: Vec, +} + +impl Capacidades { + /// `true` si la superficie con este prefijo está permitida (lista blanca + /// vacía = todo permitido). + pub fn permite_superficie(&self, prefijo: &str) -> bool { + self.superficies.is_empty() || self.superficies.iter().any(|s| s == prefijo) + } + + /// ¿Propone esta acción de una, o conviene preguntar antes? Combina las dos + /// compuertas, que son de naturaleza distinta: la **estructural** (¿tengo + /// control?, ¿está la superficie en la lista blanca?) y la **epistémica** + /// (¿me alcanza la reputación derivada para atreverme a este peligro? — + /// PLAN-AYLLU E3, ver [`crate::termostato`]). La primera es un permiso; la + /// segunda, una consecuencia del historial del agente. + /// + /// `false` nunca significa "prohibido ejecutar" —eso sigue siendo del + /// humano, siempre—: significa "no lo sueltes como propuesta todavía". + pub fn propone(&self, prefijo: &str, peligro: Peligro, autonomia: Autonomia) -> bool { + self.control + && self.permite_superficie(prefijo) + && autonomia.propone_sin_preguntar(peligro) + } +} diff --git a/02_ruway/shuma/sandbox/shuma-agente/src/almacen.rs b/02_ruway/shuma/sandbox/shuma-agente/src/almacen.rs new file mode 100644 index 0000000..e32186d --- /dev/null +++ b/02_ruway/shuma/sandbox/shuma-agente/src/almacen.rs @@ -0,0 +1,201 @@ +//! Persistencia de agentes y conversaciones en sled. +//! +//! Dos árboles: `agentes` y `conversaciones`, ambos clave=`id` → +//! valor=JSON. JSON (no postcard) porque el contenido evoluciona campo a campo +//! con `serde(default)` y conviene poder inspeccionarlo a mano. El volumen es +//! chico (charlas de un usuario), así que listar deserializando todo el árbol +//! es de sobra. + +use crate::agente::Agente; +use crate::conversacion::Conversacion; +use std::path::Path; +use thiserror::Error; + +/// Errores de almacenamiento. +#[derive(Debug, Error)] +pub enum AlmacenError { + #[error("sled: {0}")] + Sled(#[from] sled::Error), + #[error("serializar/deserializar: {0}")] + Json(#[from] serde_json::Error), +} + +/// El almacén persistente de la IA conversacional. +pub struct Almacen { + agentes: sled::Tree, + conversaciones: sled::Tree, + _db: sled::Db, +} + +impl Almacen { + /// Abre (o crea) el almacén en `path`. + pub fn abrir(path: impl AsRef) -> Result { + let db = sled::open(path)?; + let agentes = db.open_tree("agentes")?; + let conversaciones = db.open_tree("conversaciones")?; + Ok(Self { agentes, conversaciones, _db: db }) + } + + // ── Agentes ────────────────────────────────────────────────────────── + + /// Inserta o actualiza un agente (clave = `agente.id`). + pub fn guardar_agente(&self, a: &Agente) -> Result<(), AlmacenError> { + self.agentes.insert(a.id.as_bytes(), serde_json::to_vec(a)?)?; + Ok(()) + } + + /// Lee un agente por id. + pub fn agente(&self, id: &str) -> Result, AlmacenError> { + match self.agentes.get(id.as_bytes())? { + Some(v) => Ok(Some(serde_json::from_slice(&v)?)), + None => Ok(None), + } + } + + /// Todos los agentes, ordenados por nombre. + pub fn agentes(&self) -> Result, AlmacenError> { + let mut out = Vec::new(); + for kv in self.agentes.iter() { + let (_, v) = kv?; + out.push(serde_json::from_slice::(&v)?); + } + out.sort_by(|a, b| a.nombre.to_lowercase().cmp(&b.nombre.to_lowercase())); + Ok(out) + } + + /// Borra un agente. Las conversaciones que lo apuntaban quedan huérfanas + /// (la UI las muestra como «agente eliminado»); no se borran en cascada. + pub fn borrar_agente(&self, id: &str) -> Result<(), AlmacenError> { + self.agentes.remove(id.as_bytes())?; + Ok(()) + } + + /// Si no hay ningún agente, siembra dos por defecto («Asistente» de charla y + /// «Control» con acciones del escritorio) y los devuelve. Idempotente: si ya + /// hay agentes, no toca nada y devuelve los existentes. + pub fn sembrar_defaults(&self) -> Result, AlmacenError> { + let existentes = self.agentes()?; + if !existentes.is_empty() { + return Ok(existentes); + } + // Por defecto pegan a Claude vía el CLI `claude` (Claude Code) — usa la + // suscripción Pro/Max del usuario sin API key. Si `claude` no está + // logueado, el host cae al `[ai.llm]` global o reporta el error. + let claude = || wawa_config::LlmSettings { + backend: "claude-cli".to_string(), + ..Default::default() + }; + let asistente = Agente::nuevo("Asistente") + .con_descripcion("Charla general; sin tocar el sistema.") + .con_backend(claude()); + let control = Agente::nuevo("Control") + .con_descripcion("Maneja el escritorio: propone acciones que vos aprobas.") + .con_persona( + "Eres el controlador del escritorio tawasuyu. Ayudás al usuario a manejar la \ + suite proponiendo acciones de control cuando hace falta.", + ) + .con_backend(claude()) + .con_control(); + self.guardar_agente(&asistente)?; + self.guardar_agente(&control)?; + self.agentes() + } + + // ── Conversaciones ─────────────────────────────────────────────────── + + /// Inserta o actualiza una conversación (clave = `conv.id`). + pub fn guardar_conversacion(&self, c: &Conversacion) -> Result<(), AlmacenError> { + self.conversaciones.insert(c.id.as_bytes(), serde_json::to_vec(c)?)?; + Ok(()) + } + + /// Lee una conversación por id. + pub fn conversacion(&self, id: &str) -> Result, AlmacenError> { + match self.conversaciones.get(id.as_bytes())? { + Some(v) => Ok(Some(serde_json::from_slice(&v)?)), + None => Ok(None), + } + } + + /// Todas las conversaciones, **más recientes primero** (por `actualizada`) — + /// el orden del sidebar de las apps de IA. + pub fn conversaciones(&self) -> Result, AlmacenError> { + let mut out = Vec::new(); + for kv in self.conversaciones.iter() { + let (_, v) = kv?; + out.push(serde_json::from_slice::(&v)?); + } + out.sort_by(|a, b| b.actualizada.cmp(&a.actualizada)); + Ok(out) + } + + /// Borra una conversación. + pub fn borrar_conversacion(&self, id: &str) -> Result<(), AlmacenError> { + self.conversaciones.remove(id.as_bytes())?; + Ok(()) + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::conversacion::{BloqueSalida, Conversacion}; + + fn almacen_tmp() -> Almacen { + // Path único por test sin tocar el reloj ni random: usa el nombre del + // árbol temporal del propio sled en memoria via `Config::temporary`. + let db = sled::Config::new().temporary(true).open().unwrap(); + let agentes = db.open_tree("agentes").unwrap(); + let conversaciones = db.open_tree("conversaciones").unwrap(); + Almacen { agentes, conversaciones, _db: db } + } + + #[test] + fn round_trip_agente() { + let a = almacen_tmp(); + let ag = Agente::nuevo("DevOps").con_control(); + a.guardar_agente(&ag).unwrap(); + let leido = a.agente(&ag.id).unwrap().unwrap(); + assert_eq!(leido, ag); + assert_eq!(a.agentes().unwrap().len(), 1); + a.borrar_agente(&ag.id).unwrap(); + assert!(a.agente(&ag.id).unwrap().is_none()); + } + + #[test] + fn sembrar_defaults_es_idempotente() { + let a = almacen_tmp(); + let primera = a.sembrar_defaults().unwrap(); + assert_eq!(primera.len(), 2); + let segunda = a.sembrar_defaults().unwrap(); + assert_eq!(segunda.len(), 2); // no duplica + } + + #[test] + fn conversaciones_ordenan_recientes_primero() { + let a = almacen_tmp(); + let mut vieja = Conversacion::nueva("ag", 100); + vieja.agregar_usuario("vieja", 100); + let mut nueva = Conversacion::nueva("ag", 200); + nueva.agregar_usuario("nueva", 200); + a.guardar_conversacion(&vieja).unwrap(); + a.guardar_conversacion(&nueva).unwrap(); + let lista = a.conversaciones().unwrap(); + assert_eq!(lista[0].id, nueva.id); + assert_eq!(lista[1].id, vieja.id); + } + + #[test] + fn round_trip_conversacion_con_bloques() { + let a = almacen_tmp(); + let mut c = Conversacion::nueva("ag", 1); + c.agregar_usuario("hola", 1); + c.agregar_asistente( + vec![BloqueSalida::Codigo { lenguaje: Some("rs".into()), codigo: "fn main(){}".into() }], + 2, + None, + ); + a.guardar_conversacion(&c).unwrap(); + assert_eq!(a.conversacion(&c.id).unwrap().unwrap(), c); + } +} diff --git a/02_ruway/shuma/sandbox/shuma-agente/src/conversacion.rs b/02_ruway/shuma/sandbox/shuma-agente/src/conversacion.rs new file mode 100644 index 0000000..45164c6 --- /dev/null +++ b/02_ruway/shuma/sandbox/shuma-agente/src/conversacion.rs @@ -0,0 +1,325 @@ +//! La conversación: un hilo multi-turno contra un agente, y la gama de bloques +//! de salida que un turno del asistente puede contener. + +use serde::{Deserialize, Serialize}; + +/// Quién habló en un turno. +#[derive(Clone, Copy, Debug, Serialize, Deserialize, PartialEq, Eq)] +pub enum Rol { + Usuario, + Asistente, +} + +/// Espejo local de `atipay::Peligro` — serializable y desacoplado del enum de +/// atipay (que el núcleo no necesita re-exportar). +#[derive(Clone, Copy, Debug, Serialize, Deserialize, PartialEq, Eq)] +pub enum Peligro { + Seguro, + Reversible, + Disruptivo, +} + +impl From for Peligro { + fn from(p: atipay::Peligro) -> Self { + match p { + atipay::Peligro::Seguro => Peligro::Seguro, + atipay::Peligro::Reversible => Peligro::Reversible, + atipay::Peligro::Disruptivo => Peligro::Disruptivo, + } + } +} + +impl Peligro { + /// Etiqueta corta para la UI. + pub fn etiqueta(self) -> &'static str { + match self { + Peligro::Seguro => "seguro", + Peligro::Reversible => "reversible", + Peligro::Disruptivo => "⚠ disruptivo", + } + } +} + +/// Ciclo de vida de una acción propuesta por el agente. Arranca `Propuesta`; el +/// usuario la aprueba/rechaza; el host la ejecuta y reporta el desenlace. +#[derive(Clone, Copy, Debug, Serialize, Deserialize, PartialEq, Eq)] +pub enum EstadoAccion { + /// El agente la propuso; espera revisión del usuario. + Propuesta, + /// El usuario la aprobó; el host puede ejecutarla. + Aprobada, + /// El usuario la descartó. + Rechazada, + /// El host la corrió OK. + Ejecutada, + /// El host la corrió y falló. + Fallida, +} + +/// Una acción de control que el agente quiere ejecutar. La **línea de comando +/// la arma y valida atipay** a partir del `id` + args elegidos por el modelo — +/// imposible que el modelo invente flags. Nunca se auto-ejecuta. +#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq)] +pub struct AccionPropuesta { + /// Id de la capacidad en el catálogo atipay. + pub id: String, + /// Línea de comando exacta, ya validada por atipay. + pub linea_comando: String, + /// Nivel de peligro reportado por el catálogo. + pub peligro: Peligro, + /// Estado del ciclo de vida. + pub estado: EstadoAccion, +} + +/// Un bloque de salida dentro de un turno. Es la **gama de outputs**: el texto +/// crudo del modelo se interpreta a esta lista (ver [`crate::motor`]). +#[derive(Clone, Debug, Serialize, Deserialize, PartialEq)] +pub enum BloqueSalida { + /// Prosa (markdown). El grueso de una respuesta conversacional. + Texto(String), + /// Bloque de código con lenguaje opcional (de un cerco ```lang). + Codigo { + lenguaje: Option, + codigo: String, + }, + /// Una acción de control propuesta (validada por atipay). + Accion(AccionPropuesta), + /// Una imagen adjunta (visión). Va en el turno del usuario; el `motor` la + /// manda al modelo como bloque de imagen. `data_base64` = bytes en base64. + Imagen { media_type: String, data_base64: String }, + /// Algo no se pudo interpretar (JSON de acción inválido, id desconocido…). + Error(String), +} + +impl BloqueSalida { + /// El texto que este bloque aporta al historial enviado al modelo en el + /// próximo turno (para que recuerde lo que dijo). Las acciones se serializan + /// de forma compacta y legible. + pub fn texto_para_historial(&self) -> String { + match self { + BloqueSalida::Texto(t) => t.clone(), + BloqueSalida::Codigo { lenguaje, codigo } => { + let l = lenguaje.as_deref().unwrap_or(""); + format!("```{l}\n{codigo}\n```") + } + BloqueSalida::Accion(a) => format!("[acción: {} → {}]", a.id, a.linea_comando), + BloqueSalida::Imagen { .. } => "[imagen adjunta]".to_string(), + BloqueSalida::Error(e) => format!("[error: {e}]"), + } + } +} + +/// Conteo de tokens de un turno del asistente (lo reporta el backend). Se +/// muestra en la UI como paridad con Claude CLI. +#[derive(Clone, Copy, Debug, Default, Serialize, Deserialize, PartialEq, Eq)] +pub struct Uso { + pub entrada: u32, + pub salida: u32, +} + +impl Uso { + /// `true` si hay algo que mostrar (algún backend reporta 0/0). + pub fn hay(&self) -> bool { + self.entrada > 0 || self.salida > 0 + } +} + +/// Un turno de la conversación. +#[derive(Clone, Debug, Serialize, Deserialize, PartialEq)] +pub struct Turno { + pub rol: Rol, + /// Para el usuario: normalmente un solo [`BloqueSalida::Texto`]. Para el + /// asistente: los bloques interpretados de su respuesta. + pub bloques: Vec, + /// Epoch en milisegundos. Lo fija el caller — el núcleo no lee el reloj. + pub ts: u64, + /// Tokens del turno del asistente, si el backend los reportó. `serde(default)` + /// para retrocompat con conversaciones persistidas sin el campo. + #[serde(default)] + pub uso: Option, +} + +impl Turno { + /// Turno de usuario con texto plano. + pub fn usuario(texto: impl Into, ts: u64) -> Self { + Self { + rol: Rol::Usuario, + bloques: vec![BloqueSalida::Texto(texto.into())], + ts, + uso: None, + } + } + + /// Turno del asistente con bloques ya interpretados. + pub fn asistente(bloques: Vec, ts: u64) -> Self { + Self { + rol: Rol::Asistente, + bloques, + ts, + uso: None, + } + } + + /// El texto plano del turno, para reconstruir el historial del próximo + /// `ChatRequest`. + pub fn texto_plano(&self) -> String { + self.bloques + .iter() + .map(|b| b.texto_para_historial()) + .collect::>() + .join("\n\n") + } + + /// Las acciones propuestas en este turno, con su índice de bloque (para que + /// el host pueda mutar su estado al aprobar/ejecutar). + pub fn acciones(&self) -> impl Iterator { + self.bloques + .iter() + .enumerate() + .filter_map(|(i, b)| match b { + BloqueSalida::Accion(a) => Some((i, a)), + _ => None, + }) + } +} + +/// Un hilo de conversación contra un agente. +#[derive(Clone, Debug, Serialize, Deserialize, PartialEq)] +pub struct Conversacion { + /// Id estable (uuid v4). + pub id: String, + /// Qué agente la responde. + pub agente_id: String, + /// Título visible (se auto-deriva del primer mensaje si queda vacío). + pub titulo: String, + /// Los turnos, en orden cronológico. + pub turnos: Vec, + /// Epoch ms de creación. + pub creada: u64, + /// Epoch ms del último turno. + pub actualizada: u64, +} + +impl Conversacion { + /// Conversación vacía contra `agente_id`, marcada con `ahora` (epoch ms). + pub fn nueva(agente_id: impl Into, ahora: u64) -> Self { + Self { + id: uuid::Uuid::new_v4().to_string(), + agente_id: agente_id.into(), + titulo: String::new(), + turnos: Vec::new(), + creada: ahora, + actualizada: ahora, + } + } + + /// Agrega un turno de usuario. Si la conversación no tenía título, lo deriva + /// del texto (primeras palabras). Devuelve el índice del turno. + pub fn agregar_usuario(&mut self, texto: impl Into, ts: u64) -> usize { + let texto = texto.into(); + if self.titulo.trim().is_empty() { + self.titulo = derivar_titulo(&texto); + } + self.turnos.push(Turno::usuario(texto, ts)); + self.actualizada = ts; + self.turnos.len() - 1 + } + + /// Como [`Self::agregar_usuario`] pero con imágenes adjuntas (visión): el + /// turno lleva los bloques `Imagen` antes del texto. + pub fn agregar_usuario_con_imagenes( + &mut self, + texto: impl Into, + imagenes: Vec<(String, String)>, + ts: u64, + ) -> usize { + let texto = texto.into(); + if self.titulo.trim().is_empty() { + self.titulo = derivar_titulo(if texto.trim().is_empty() { "(imagen)" } else { &texto }); + } + let mut bloques: Vec = imagenes + .into_iter() + .map(|(media_type, data_base64)| BloqueSalida::Imagen { media_type, data_base64 }) + .collect(); + if !texto.trim().is_empty() { + bloques.push(BloqueSalida::Texto(texto)); + } + self.turnos.push(Turno { rol: Rol::Usuario, bloques, ts, uso: None }); + self.actualizada = ts; + self.turnos.len() - 1 + } + + /// Agrega un turno del asistente con sus bloques ya interpretados y, si el + /// backend lo reportó, su conteo de tokens. + pub fn agregar_asistente(&mut self, bloques: Vec, ts: u64, uso: Option) -> usize { + let mut t = Turno::asistente(bloques, ts); + t.uso = uso.filter(|u| u.hay()); + self.turnos.push(t); + self.actualizada = ts; + self.turnos.len() - 1 + } +} + +/// Deriva un título corto de la primera línea de texto (hasta ~6 palabras). +fn derivar_titulo(texto: &str) -> String { + let limpio = texto.trim().lines().next().unwrap_or("").trim(); + let recorte: String = limpio.split_whitespace().take(6).collect::>().join(" "); + if recorte.is_empty() { + "Conversación".to_string() + } else if recorte.chars().count() < limpio.chars().count() { + format!("{recorte}…") + } else { + recorte + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn titulo_se_deriva_del_primer_mensaje() { + let mut c = Conversacion::nueva("a1", 0); + c.agregar_usuario("hola quiero listar archivos grandes del home", 10); + assert_eq!(c.titulo, "hola quiero listar archivos grandes del…"); + assert_eq!(c.actualizada, 10); + // El segundo mensaje no pisa el título. + c.agregar_usuario("y ahora borralos", 20); + assert_eq!(c.titulo, "hola quiero listar archivos grandes del…"); + } + + #[test] + fn texto_plano_reconstruye_bloques() { + let t = Turno::asistente( + vec![ + BloqueSalida::Texto("prueba esto:".into()), + BloqueSalida::Codigo { + lenguaje: Some("sh".into()), + codigo: "ls -la".into(), + }, + ], + 0, + ); + assert_eq!(t.texto_plano(), "prueba esto:\n\n```sh\nls -la\n```"); + } + + #[test] + fn acciones_se_enumeran_con_indice() { + let t = Turno::asistente( + vec![ + BloqueSalida::Texto("subo el brillo".into()), + BloqueSalida::Accion(AccionPropuesta { + id: "mirada.brillo".into(), + linea_comando: "mirada-ctl brillo 80".into(), + peligro: Peligro::Seguro, + estado: EstadoAccion::Propuesta, + }), + ], + 0, + ); + let acc: Vec<_> = t.acciones().collect(); + assert_eq!(acc.len(), 1); + assert_eq!(acc[0].0, 1); + assert_eq!(acc[0].1.id, "mirada.brillo"); + } +} diff --git a/02_ruway/shuma/sandbox/shuma-agente/src/lib.rs b/02_ruway/shuma/sandbox/shuma-agente/src/lib.rs new file mode 100644 index 0000000..f41b331 --- /dev/null +++ b/02_ruway/shuma/sandbox/shuma-agente/src/lib.rs @@ -0,0 +1,42 @@ +//! # shuma-agente — núcleo de la IA conversacional de shuma +//! +//! Hoy la IA de shuma es **invocación atómica**: cada `:?`/`:haz`/`:explica` +//! arma un `ChatRequest::una_vuelta` (turno único, sin memoria) y vuelca la +//! respuesta al input o al bloque. Este crate sube un escalón: modela +//! **múltiples agentes** configurables y **conversaciones multi-turno** +//! persistidas — el modelo de las apps web de IA (un panel de charlas, cada una +//! contra un agente), pero embebido en la suite. +//! +//! ## Reparto de responsabilidades (mismo patrón que el resto de shuma) +//! +//! El módulo/host **expresa la intención y corre la red**; este núcleo es +//! **sync, puro y testeable**, sin tocar sockets ni `tokio`: +//! +//! - [`Agente`] — identidad + backend ([`wawa_config::LlmSettings`] por agente) +//! + persona (`system_prompt`) + qué [`Capacidades`] de control puede proponer. +//! - [`Conversacion`] — hilo multi-turno ([`Turno`]s usuario/asistente), cada +//! turno del asistente desglosado en [`BloqueSalida`]s (texto, código, acción). +//! - [`motor`] — `construir_request` arma el [`ChatRequest`] con todo el +//! historial; `interpretar_respuesta` parte el texto crudo del modelo en +//! bloques (la **gama de outputs**). El host hace el `.complete()` con +//! `pluma-llm` en un thread, igual que con el `LlmRequest` del shell. +//! - [`Almacen`] — persistencia sled de agentes y conversaciones. +//! +//! Las acciones de control nunca se auto-ejecutan: el agente las **propone** +//! como [`AccionPropuesta`] validada por [`atipay`], y el usuario aprueba — +//! exactamente la doctrina de `:haz`. +//! +//! [`ChatRequest`]: pluma_llm_core::ChatRequest + +mod agente; +mod almacen; +mod conversacion; +pub mod motor; +pub mod termostato; + +pub use agente::{Agente, Capacidades}; +pub use termostato::{termostato, Autonomia}; +pub use almacen::{Almacen, AlmacenError}; +pub use conversacion::{ + AccionPropuesta, BloqueSalida, Conversacion, EstadoAccion, Peligro, Rol, Turno, Uso, +}; diff --git a/02_ruway/shuma/sandbox/shuma-agente/src/motor.rs b/02_ruway/shuma/sandbox/shuma-agente/src/motor.rs new file mode 100644 index 0000000..4af54df --- /dev/null +++ b/02_ruway/shuma/sandbox/shuma-agente/src/motor.rs @@ -0,0 +1,328 @@ +//! El motor: dos funciones puras que el host envuelve con la red. +//! +//! 1. [`construir_request`] — `Conversacion` + `Agente` → [`ChatRequest`] +//! multi-turno (con system prompt y, si el agente tiene control, el menú de +//! capacidades atipay). El host hace el `.complete()` con `pluma-llm`. +//! 2. [`interpretar_respuesta`] — el texto crudo del modelo → `Vec` +//! (texto / código / acción de control validada). Es la **gama de outputs**. +//! +//! Ninguna toca sockets ni `tokio`: son sync y testeables. Mismo reparto que el +//! `LlmRequest`/`LlmResult` del shell. + +use crate::agente::{Agente, Capacidades}; +use crate::conversacion::{AccionPropuesta, BloqueSalida, Conversacion, EstadoAccion, Rol}; +use pluma_llm_core::{ChatMessage, ChatRequest}; + +/// Persona por defecto si el agente no fija `system_prompt`. +const SYSTEM_DEFAULT: &str = "Eres un asistente del escritorio tawasuyu. Responde claro y conciso, \ + en el idioma del usuario. Usa bloques de código cercados (```) para comandos o código."; + +/// Arma el [`ChatRequest`] multi-turno desde la conversación + el agente. +/// +/// El `system` es la persona del agente (o [`SYSTEM_DEFAULT`]); si el agente +/// tiene `capacidades.control`, se le anexan las instrucciones para proponer +/// acciones del catálogo atipay. Los `messages` son **todo el historial** de la +/// conversación traducido a `user`/`assistant` — así el modelo tiene memoria. +pub fn construir_request(conv: &Conversacion, agente: &Agente) -> ChatRequest { + let mut system = if agente.system_prompt.trim().is_empty() { + SYSTEM_DEFAULT.to_string() + } else { + agente.system_prompt.clone() + }; + if agente.capacidades.control { + system.push_str(&instrucciones_control(&agente.capacidades)); + } + + let messages = conv + .turnos + .iter() + .map(|t| { + let content = t.texto_plano(); + match t.rol { + Rol::Asistente => ChatMessage::assistant(content), + Rol::Usuario => { + // Imágenes adjuntas → mensaje de usuario con visión. + let imgs: Vec = t + .bloques + .iter() + .filter_map(|b| match b { + BloqueSalida::Imagen { media_type, data_base64 } => Some( + pluma_llm_core::ChatImage::new(media_type.clone(), data_base64.clone()), + ), + _ => None, + }) + .collect(); + if imgs.is_empty() { + ChatMessage::user(content) + } else { + ChatMessage::user_con_imagenes(content, imgs) + } + } + } + }) + .collect::>(); + + ChatRequest { + system: Some(system), + messages, + max_tokens: agente.max_tokens, + temperature: agente.temperatura.clamp(0.0, 1.0), + } +} + +/// Instrucciones que se anexan al system de un agente con control: cómo proponer +/// una acción (bloque cercado `accion` con JSON `{"id","args"}`) y el menú de +/// ids válidos del catálogo. El usuario aprueba; el agente nunca ejecuta. +fn instrucciones_control(_cap: &Capacidades) -> String { + // El catálogo se identifica por `id`; el modelo elige UNO. La línea de + // comando la arma `atipay` (validada) — el modelo no puede inventar flags. + let menu = atipay::Catalogo::estandar().prompt_menu_ids(); + format!( + "\n\nAdemás de charlar, puedes PROPONER acciones de control del escritorio. \ + Cuando quieras ejecutar una, incluí en tu respuesta un bloque cercado con \ + la etiqueta `accion` que contenga SÓLO un objeto JSON \ + {{\"id\":\"\",\"args\":{{\"\":\"\"}}}} — sin \ + markdown adentro. Puedes acompañarlo de texto explicando qué hace. El usuario \ + revisa y aprueba: vos NUNCA la ejecutas. Usa EXACTAMENTE estos ids:\n{menu}" + ) +} + +/// Parte el texto crudo del asistente en [`BloqueSalida`]s. +/// +/// Reglas: +/// - Bloque cercado ```` ```accion ```` / ```` ```atipay ```` → se resuelve con +/// atipay a una [`AccionPropuesta`] validada (o un `Error` si no encaja). +/// - Cualquier otro bloque cercado → [`BloqueSalida::Codigo`] (con su lenguaje). +/// - El texto fuera de cercos → [`BloqueSalida::Texto`] (se descartan los vacíos). +/// - Tolerancia: si el agente tiene control y la respuesta entera es un objeto +/// JSON suelto, se interpreta como acción (como hace hoy `:haz`). +pub fn interpretar_respuesta(texto: &str, agente: &Agente) -> Vec { + let crudo = texto.trim(); + if crudo.is_empty() { + return Vec::new(); + } + + // Fallback: control + JSON suelto (sin cercos) → acción. + if agente.capacidades.control && crudo.starts_with('{') && crudo.ends_with('}') { + return vec![resolver_accion(crudo, agente)]; + } + + let mut bloques = Vec::new(); + let mut texto_acc: Vec<&str> = Vec::new(); + let mut en_cerco = false; + let mut info = String::new(); + let mut cuerpo: Vec<&str> = Vec::new(); + + let flush_texto = |acc: &mut Vec<&str>, bloques: &mut Vec| { + let t = acc.join("\n"); + let t = t.trim(); + if !t.is_empty() { + bloques.push(BloqueSalida::Texto(t.to_string())); + } + acc.clear(); + }; + + for linea in crudo.lines() { + let trimmed = linea.trim_start(); + if let Some(resto) = trimmed.strip_prefix("```") { + if en_cerco { + // Cierra el cerco actual. + let etiqueta = info.trim().to_lowercase(); + let contenido = cuerpo.join("\n"); + if etiqueta == "accion" || etiqueta == "atipay" { + bloques.push(resolver_accion(&contenido, agente)); + } else { + let lenguaje = (!etiqueta.is_empty()).then(|| etiqueta.clone()); + bloques.push(BloqueSalida::Codigo { + lenguaje, + codigo: contenido, + }); + } + cuerpo.clear(); + info.clear(); + en_cerco = false; + } else { + // Abre un cerco: primero descarga el texto acumulado. + flush_texto(&mut texto_acc, &mut bloques); + info = resto.trim().to_string(); + en_cerco = true; + } + } else if en_cerco { + cuerpo.push(linea); + } else { + texto_acc.push(linea); + } + } + + // Cerco sin cerrar: rescata el cuerpo como código para no perder contenido. + if en_cerco && !cuerpo.is_empty() { + let lenguaje = (!info.trim().is_empty()).then(|| info.trim().to_lowercase()); + bloques.push(BloqueSalida::Codigo { + lenguaje, + codigo: cuerpo.join("\n"), + }); + } + flush_texto(&mut texto_acc, &mut bloques); + + if bloques.is_empty() { + bloques.push(BloqueSalida::Texto(crudo.to_string())); + } + bloques +} + +/// Resuelve un fragmento JSON `{"id","args"}` a una [`AccionPropuesta`] validada +/// por atipay, o a un [`BloqueSalida::Error`] legible. Respeta la lista blanca de +/// superficies del agente. +fn resolver_accion(fragmento: &str, agente: &Agente) -> BloqueSalida { + let raw = fragmento.trim(); + if raw.is_empty() || raw.eq_ignore_ascii_case("nada") { + return BloqueSalida::Error("ninguna acción de control encaja".to_string()); + } + // El modelo puede colar texto alrededor; quédate con el objeto JSON. + let json = match (raw.find('{'), raw.rfind('}')) { + (Some(i), Some(j)) if j > i => &raw[i..=j], + _ => return BloqueSalida::Error("no entendí la elección del modelo".to_string()), + }; + let inv: atipay::Invocacion = match serde_json::from_str(json) { + Ok(inv) => inv, + Err(_) => return BloqueSalida::Error("JSON de acción inválido".to_string()), + }; + + // Lista blanca por superficie (prefijo del id: "mirada.brillo" → "mirada"). + let prefijo = inv.id.split('.').next().unwrap_or(""); + if !agente.capacidades.permite_superficie(prefijo) { + return BloqueSalida::Error(format!( + "el agente no tiene permitida la superficie «{prefijo}»" + )); + } + + match atipay::Catalogo::estandar().plan(&inv) { + Ok(plan) => BloqueSalida::Accion(AccionPropuesta { + id: plan.id.clone(), + linea_comando: plan.linea_comando(), + peligro: plan.peligro.into(), + estado: EstadoAccion::Propuesta, + }), + Err(e) => BloqueSalida::Error(e.to_string()), + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::conversacion::Peligro; + use pluma_llm_core::Role; + + fn agente_charla() -> Agente { + Agente::nuevo("Asistente") + } + fn agente_control() -> Agente { + Agente::nuevo("Control").con_control() + } + + #[test] + fn request_lleva_persona_e_historial() { + let mut conv = Conversacion::nueva("a1", 0); + conv.agregar_usuario("hola", 1); + conv.agregar_asistente(vec![BloqueSalida::Texto("¡hola!".into())], 2, None); + conv.agregar_usuario("¿qué hora es?", 3); + + let ag = agente_charla().con_persona("Eres pirata."); + let req = construir_request(&conv, &ag); + assert_eq!(req.system.as_deref(), Some("Eres pirata.")); + assert_eq!(req.messages.len(), 3); + assert_eq!(req.messages[0].role, Role::User); + assert_eq!(req.messages[1].role, Role::Assistant); + assert_eq!(req.messages[2].content, "¿qué hora es?"); + } + + #[test] + fn imagen_en_turno_usuario_va_como_vision() { + let mut conv = Conversacion::nueva("a1", 0); + conv.agregar_usuario_con_imagenes( + "¿qué ves?", + vec![("image/png".into(), "QUJD".into())], + 1, + ); + let req = construir_request(&conv, &agente_charla()); + assert_eq!(req.messages.len(), 1); + assert_eq!(req.messages[0].images.len(), 1); + assert_eq!(req.messages[0].images[0].media_type, "image/png"); + assert!(req.messages[0].content.contains("¿qué ves?")); + } + + #[test] + fn control_anexa_menu_al_system() { + let conv = Conversacion::nueva("a1", 0); + let req_charla = construir_request(&conv, &agente_charla()); + let req_control = construir_request(&conv, &agente_control()); + assert!(!req_charla.system.as_deref().unwrap().contains("PROPONER acciones")); + assert!(req_control.system.as_deref().unwrap().contains("PROPONER acciones")); + } + + #[test] + fn interpreta_texto_y_codigo() { + let bloques = interpretar_respuesta( + "Prueba esto:\n```sh\nls -la\n```\nY listo.", + &agente_charla(), + ); + assert_eq!(bloques.len(), 3); + assert_eq!(bloques[0], BloqueSalida::Texto("Prueba esto:".into())); + assert_eq!( + bloques[1], + BloqueSalida::Codigo { lenguaje: Some("sh".into()), codigo: "ls -la".into() } + ); + assert_eq!(bloques[2], BloqueSalida::Texto("Y listo.".into())); + } + + #[test] + fn interpreta_accion_cercada_valida() { + // `sistema.brillo` existe en el catálogo estándar (Sistema). + let resp = "Subo el brillo.\n```accion\n{\"id\":\"sistema.brillo\",\"args\":{\"nivel\":\"80\"}}\n```"; + let bloques = interpretar_respuesta(resp, &agente_control()); + assert_eq!(bloques.len(), 2); + assert!(matches!(bloques[0], BloqueSalida::Texto(_))); + match &bloques[1] { + BloqueSalida::Accion(a) => { + assert_eq!(a.id, "sistema.brillo"); + assert_eq!(a.estado, EstadoAccion::Propuesta); + assert!(a.linea_comando.contains("80")); + } + otro => panic!("esperaba Accion, vino {otro:?}"), + } + } + + #[test] + fn accion_con_id_desconocido_es_error() { + let resp = "```accion\n{\"id\":\"inventada.cosa\"}\n```"; + let bloques = interpretar_respuesta(resp, &agente_control()); + assert!(matches!(bloques[0], BloqueSalida::Error(_))); + } + + #[test] + fn superficie_no_permitida_se_rechaza() { + let mut ag = agente_control(); + ag.capacidades.superficies = vec!["mirada".into()]; // sólo mirada + let resp = "```accion\n{\"id\":\"sistema.brillo\",\"args\":{\"nivel\":\"50\"}}\n```"; + let bloques = interpretar_respuesta(resp, &ag); + match &bloques[0] { + BloqueSalida::Error(e) => assert!(e.contains("sistema")), + otro => panic!("esperaba Error, vino {otro:?}"), + } + } + + #[test] + fn json_suelto_en_agente_control_es_accion() { + let resp = "{\"id\":\"sistema.brillo\",\"args\":{\"nivel\":\"30\"}}"; + let bloques = interpretar_respuesta(resp, &agente_control()); + assert_eq!(bloques.len(), 1); + assert!(matches!(&bloques[0], BloqueSalida::Accion(a) if a.peligro == Peligro::Seguro || a.peligro == Peligro::Reversible || a.peligro == Peligro::Disruptivo)); + } + + #[test] + fn json_suelto_sin_control_es_texto() { + let resp = "{\"id\":\"sistema.brillo\"}"; + let bloques = interpretar_respuesta(resp, &agente_charla()); + assert!(matches!(bloques[0], BloqueSalida::Texto(_))); + } +} diff --git a/02_ruway/shuma/sandbox/shuma-agente/src/termostato.rs b/02_ruway/shuma/sandbox/shuma-agente/src/termostato.rs new file mode 100644 index 0000000..8bb7844 --- /dev/null +++ b/02_ruway/shuma/sandbox/shuma-agente/src/termostato.rs @@ -0,0 +1,163 @@ +//! El termostato epistémico — shuma pondera lo que propone (PLAN-AYLLU E3). +//! +//! El agente que fue revertido muchas veces **propone menos y pregunta más**. +//! Lo importante es de dónde sale ese freno: no de un flag de configuración ni +//! de un contador interno, sino de la **creencia derivada sobre la propia +//! máquina** — la que cualquier lector obtiene de su bitácora +//! (`iniy_emisores::propuesta`) pasándola por `iniy_derive::derive` con SU +//! `TrustPolicy`. Por eso el termostato es epistémico: la autonomía del agente +//! es una consecuencia de su historial verificable, no un permiso otorgado. +//! +//! Dos corolarios del diseño, deliberados: +//! +//! - **Un agente sin historial no hereda autonomía.** Una opinión vacua (mucha +//! incertidumbre) cae en [`Autonomia::Propone`], el default de siempre: la +//! confianza se gana con actos, no se presume. +//! - **La aprobación humana no se toca.** El termostato modula QUÉ tan +//! arriesgado es lo que el agente se atreve a proponer, nunca si se ejecuta +//! solo: la doctrina "la máquina propone, el humano firma" sigue entera. + +use atipay::Peligro; +use iniy_core::Opinion; +use serde::{Deserialize, Serialize}; + +/// Cuánto se atreve a proponer el agente, según su reputación derivada. +#[derive(Clone, Copy, Debug, Default, Serialize, Deserialize, PartialEq, Eq)] +pub enum Autonomia { + /// Fue rechazado/revertido seguido: sólo propone lo **seguro**; para + /// cualquier otra cosa pregunta antes en vez de proponer de una. + Cautelosa, + /// El default (y el de todo agente sin historial): propone hasta lo + /// reversible; lo disruptivo lo consulta primero. + #[default] + Propone, + /// Historial sólido: se atreve a proponer también lo disruptivo — que el + /// humano seguirá teniendo que aprobar. + Suelta, +} + +/// Incertidumbre máxima para conceder [`Autonomia::Suelta`]: sin evidencia +/// suficiente no hay soltura, por alta que sea la probabilidad esperada (una +/// opinión vacua con base rate optimista no es un historial). +const MAX_INCERTIDUMBRE_SUELTA: f32 = 0.30; +/// Probabilidad esperada desde la cual el historial se considera sólido. +const UMBRAL_SUELTA: f32 = 0.75; +/// Por debajo de esto, el agente se repliega a proponer sólo lo seguro. +const UMBRAL_CAUTELA: f32 = 0.40; + +/// Lee la autonomía desde la creencia derivada sobre el propio agente. +/// +/// Usa la **probabilidad esperada** de Jøsang (`b + u·a`), que ya integra la +/// incertidumbre, y exige además poca incertidumbre para soltar la rienda. Un +/// agente con dos reversiones recientes ve caer su creencia y baja solo a +/// [`Autonomia::Cautelosa`] — sin que nadie toque una config. +pub fn termostato(opinion: &Opinion) -> Autonomia { + let p = opinion.probabilidad_esperada(); + if p >= UMBRAL_SUELTA && opinion.incertidumbre <= MAX_INCERTIDUMBRE_SUELTA { + Autonomia::Suelta + } else if p < UMBRAL_CAUTELA { + Autonomia::Cautelosa + } else { + Autonomia::Propone + } +} + +impl Autonomia { + /// ¿Se atreve a proponer una acción de este peligro sin preguntar antes? + /// `false` no prohíbe la acción: dice que el agente debería **consultar** + /// en vez de soltar la propuesta de una. + pub fn propone_sin_preguntar(self, peligro: Peligro) -> bool { + match self { + Autonomia::Suelta => true, + Autonomia::Propone => !matches!(peligro, Peligro::Disruptivo), + Autonomia::Cautelosa => matches!(peligro, Peligro::Seguro), + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + + /// Una opinión con creencia `b` y descreencia `d` (el resto, incertidumbre). + fn op(b: f32, d: f32) -> Opinion { + Opinion::nueva(b, d, 1.0 - b - d, 0.5).expect("opinión válida") + } + + #[test] + fn sin_historial_el_agente_no_hereda_autonomia() { + // Opinión vacua: toda incertidumbre. Cae en el default, no en Suelta. + let vacua = Opinion::vacua(0.5).unwrap(); + assert_eq!(termostato(&vacua), Autonomia::Propone); + // Ni siquiera con un base rate optimista: falta evidencia. + let optimista = Opinion::vacua(0.95).unwrap(); + assert_eq!(termostato(&optimista), Autonomia::Propone); + } + + #[test] + fn historial_solido_suelta_la_rienda() { + let bueno = op(0.9, 0.02); + assert_eq!(termostato(&bueno), Autonomia::Suelta); + assert!(bueno.probabilidad_esperada() >= UMBRAL_SUELTA); + } + + #[test] + fn el_revertido_se_vuelve_cauteloso() { + // Muchas contradicciones (rechazos/reversiones) → descreencia alta. + let malo = op(0.05, 0.9); + assert_eq!(termostato(&malo), Autonomia::Cautelosa); + } + + #[test] + fn la_cautela_solo_propone_lo_seguro() { + let c = Autonomia::Cautelosa; + assert!(c.propone_sin_preguntar(Peligro::Seguro)); + assert!(!c.propone_sin_preguntar(Peligro::Reversible)); + assert!(!c.propone_sin_preguntar(Peligro::Disruptivo)); + } + + #[test] + fn el_default_propone_hasta_lo_reversible() { + let p = Autonomia::default(); + assert_eq!(p, Autonomia::Propone); + assert!(p.propone_sin_preguntar(Peligro::Seguro)); + assert!(p.propone_sin_preguntar(Peligro::Reversible)); + assert!(!p.propone_sin_preguntar(Peligro::Disruptivo)); + } + + #[test] + fn la_soltura_se_atreve_con_todo_pero_el_humano_sigue_firmando() { + let s = Autonomia::Suelta; + assert!(s.propone_sin_preguntar(Peligro::Disruptivo)); + // Nota de doctrina: esto es "se atreve a PROPONERLO". La ejecución sigue + // exigiendo aprobación humana — ver `EstadoAccion::Aprobada`. + } + + #[test] + fn las_dos_compuertas_son_independientes() { + use crate::Capacidades; + // Compuerta estructural cerrada: sin `control` no propone nada, por + // buena que sea su reputación. + let sin_control = Capacidades { control: false, superficies: vec![] }; + assert!(!sin_control.propone("mirada", Peligro::Seguro, Autonomia::Suelta)); + + // Con control y superficie permitida, manda la compuerta epistémica. + let con_control = Capacidades { control: true, superficies: vec!["mirada".into()] }; + assert!(con_control.propone("mirada", Peligro::Disruptivo, Autonomia::Suelta)); + assert!(!con_control.propone("mirada", Peligro::Disruptivo, Autonomia::Cautelosa)); + assert!(con_control.propone("mirada", Peligro::Seguro, Autonomia::Cautelosa)); + // Superficie fuera de la lista blanca: ni con reputación intachable. + assert!(!con_control.propone("sistema", Peligro::Seguro, Autonomia::Suelta)); + } + + #[test] + fn el_termostato_es_monotono_en_la_creencia() { + // A más reversiones (más descreencia), nunca más autonomía. + let escala = [op(0.9, 0.02), op(0.5, 0.3), op(0.05, 0.9)]; + let niveles: Vec = escala.iter().map(termostato).collect(); + assert_eq!( + niveles, + vec![Autonomia::Suelta, Autonomia::Propone, Autonomia::Cautelosa] + ); + } +} diff --git a/02_ruway/shuma/sandbox/shuma-card/src/lib.rs b/02_ruway/shuma/sandbox/shuma-card/src/lib.rs index 1972c63..879840a 100644 --- a/02_ruway/shuma/sandbox/shuma-card/src/lib.rs +++ b/02_ruway/shuma/sandbox/shuma-card/src/lib.rs @@ -266,7 +266,7 @@ pub struct DiscernPolicy { pub enrich_producer: bool, /// Chunks que el FlowChannel guarda en replay buffer para subscribers /// tarde. Default 32. Subir si los productores escriben en ráfagas y - /// querés que los consumidores tardíos vean toda la salida. + /// quieres que los consumidores tardíos vean toda la salida. #[serde(default = "default_replay_chunks")] pub replay_chunks: usize, /// Tope adicional por **bytes** acumulados en el replay buffer. Lo @@ -418,6 +418,13 @@ fn intersect_soma(child: &SomaSpec, ws: &SomaSpec) -> SomaSpec { out.rlimits.mem_bytes = min_opt(out.rlimits.mem_bytes, ws.rlimits.mem_bytes); out.rlimits.nproc = min_opt(out.rlimits.nproc, ws.rlimits.nproc); out.rlimits.nofile = min_opt(out.rlimits.nofile, ws.rlimits.nofile); + // Las elevaciones (rtprio/memlock) siguen la MISMA regla: el menor gana. + // Para un techo «menor» = menos recursos; para una elevación «menor» = + // menos privilegio. En ambos casos el mínimo es lo más restrictivo, así + // que un workspace no puede ganar RT sólo por fusionarse con otra Card. + out.rlimits.rtprio = min_opt(out.rlimits.rtprio, ws.rlimits.rtprio); + out.rlimits.memlock_bytes = min_opt(out.rlimits.memlock_bytes, ws.rlimits.memlock_bytes); + out.rlimits.nice_rlimit = min_opt(out.rlimits.nice_rlimit, ws.rlimits.nice_rlimit); out } diff --git a/02_ruway/shuma/sandbox/shuma-config/Cargo.toml b/02_ruway/shuma/sandbox/shuma-config/Cargo.toml index 816b07d..71ea8bb 100644 --- a/02_ruway/shuma/sandbox/shuma-config/Cargo.toml +++ b/02_ruway/shuma/sandbox/shuma-config/Cargo.toml @@ -10,6 +10,7 @@ description = "shuma — fichero de configuración (.shumarc.toml): aliases, pro [dependencies] serde = { workspace = true } +serde_json = { workspace = true } toml = { workspace = true } directories = { workspace = true } diff --git a/02_ruway/shuma/sandbox/shuma-config/completions.example/cargo.toml b/02_ruway/shuma/sandbox/shuma-config/completions.example/cargo.toml index eef0d1b..ba3348e 100644 --- a/02_ruway/shuma/sandbox/shuma-config/completions.example/cargo.toml +++ b/02_ruway/shuma/sandbox/shuma-config/completions.example/cargo.toml @@ -1,14 +1,14 @@ # ============================================================ # cargo.toml — completions extra para `cargo` en shuma-shell. # -# Copiá este fichero a `~/.config/shuma/completions/cargo.toml` +# Copia este fichero a `~/.config/shuma/completions/cargo.toml` # (creando el directorio si no existe). Los flags listados se SUMAN al # catálogo built-in de shuma-line; no lo reemplazan. Ideal para flags # personalizados o nuevos que aún no estén en el catálogo. # # Convenciones: # - Un flag por entrada; el motor de completion los filtra por prefijo. -# - Si el flag espera valor, terminá en `=` (p.ej. `--manifest-path=`): +# - Si el flag espera valor, termina en `=` (p.ej. `--manifest-path=`): # tras `=` shuma-shell pasa a completar paths. # - El array `flags` es lo único soportado hoy; en el futuro se # sumarán `subcommands` y `args` con tipo (path/host/etc.). diff --git a/02_ruway/shuma/sandbox/shuma-config/shumarc.example.toml b/02_ruway/shuma/sandbox/shuma-config/shumarc.example.toml index 38388f8..66041c6 100644 --- a/02_ruway/shuma/sandbox/shuma-config/shumarc.example.toml +++ b/02_ruway/shuma/sandbox/shuma-config/shumarc.example.toml @@ -1,8 +1,8 @@ # ============================================================ # shumarc.toml — configuración personal de `shuma-shell`. # -# Copiá este fichero a `~/.config/shuma/shumarc.toml` (o el -# equivalente XDG que use tu SO) y editá lo que quieras. Cualquier +# Copia este fichero a `~/.config/shuma/shumarc.toml` (o el +# equivalente XDG que use tu SO) y edita lo que quieras. Cualquier # sección omitida cae a los valores por defecto. # ============================================================ @@ -53,7 +53,7 @@ spill = false # ---- Completion de flags ---- # El catálogo built-in de shuma-line cubre ~40 comandos típicos. Para -# ampliarlo, dejá un archivo por comando en +# ampliarlo, deja un archivo por comando en # `$XDG_CONFIG_HOME/shuma/completions/.toml` con la forma: # # flags = ["--mi-flag", "--otro=", "-x"] diff --git a/02_ruway/shuma/sandbox/shuma-config/src/lib.rs b/02_ruway/shuma/sandbox/shuma-config/src/lib.rs index 9524ec0..53b8f88 100644 --- a/02_ruway/shuma/sandbox/shuma-config/src/lib.rs +++ b/02_ruway/shuma/sandbox/shuma-config/src/lib.rs @@ -44,6 +44,9 @@ use std::path::{Path, PathBuf}; use serde::{Deserialize, Serialize}; +/// Absorción del entorno de una terminal normal (login shell) al proceso. +pub mod login_env; + /// Política de deduplicación, paralela a la de `shuma-history` pero /// codificada como string en el fichero TOML para que el rc sea legible. #[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Serialize, Deserialize)] @@ -56,10 +59,25 @@ pub enum DedupPolicy { } /// Configuración del historial durable. -#[derive(Debug, Clone, PartialEq, Eq, Default, Serialize, Deserialize)] +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] pub struct HistoryConfig { #[serde(default)] pub dedup: DedupPolicy, + /// Absorber los historiales de bash/zsh (`~/.bash_history`, + /// `~/.zsh_history`) al historial propio en el arranque. Incremental: + /// sólo lo nuevo desde la última vez. `true` por defecto. + #[serde(default = "default_import_shells")] + pub import_shells: bool, +} + +fn default_import_shells() -> bool { + true +} + +impl Default for HistoryConfig { + fn default() -> Self { + Self { dedup: DedupPolicy::default(), import_shells: true } + } } /// Configuración de la política de captura de salida por sesión. @@ -100,6 +118,38 @@ impl Default for PromptConfig { } } +/// Configuración del scrollback del surface (Fase 5.7+ del SDD-TERMINAL). +/// `limit_mb` cap en memoria, `spill` activa el archivo de archive para +/// líneas que se recortan del frente. `spill_path` vacío = elegido +/// automáticamente bajo `$XDG_RUNTIME_DIR/shuma-.spill`. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct ScrollbackConfig { + /// Cap del scrollback en MiB. `0` = sin cap (peligroso para sesiones + /// largas — la memoria crece sin tope). + #[serde(default = "default_scrollback_mb")] + pub limit_mb: usize, + /// Si las líneas recortadas se archivan a un spill file en disco. + #[serde(default)] + pub spill: bool, + /// Path del spill file. Vacío = elegido automáticamente. + #[serde(default)] + pub spill_path: String, +} + +fn default_scrollback_mb() -> usize { + 4 +} + +impl Default for ScrollbackConfig { + fn default() -> Self { + Self { + limit_mb: default_scrollback_mb(), + spill: false, + spill_path: String::new(), + } + } +} + /// Configuración completa cargada del `.shumarc.toml`. #[derive(Debug, Clone, PartialEq, Eq, Default, Serialize, Deserialize)] pub struct Config { @@ -115,6 +165,165 @@ pub struct Config { pub history: HistoryConfig, #[serde(default)] pub capture: CaptureConfig, + #[serde(default)] + pub scrollback: ScrollbackConfig, + /// Reglas declarativas — el plano de control determinista (E3). Lo que + /// el nerdo habitual acepta con un click, el extremo lo gobierna aquí. + #[serde(default)] + pub rules: RulesConfig, +} + +/// `[rules]` del shumarc: gatillos deterministas que el shell evalúa en +/// `update` (sin DSL turing-completo). Todo opcional; vacío = sin reglas. +/// +/// ```toml +/// [rules] +/// on_exit_nonzero = ":jobs" # qué correr cuando un comando falla +/// on_pattern_score = 3 # umbral de oferta de coreografía (A1) +/// on_long_command_secs = 30 # umbral de "comando largo" +/// +/// [rules.on_enter_cwd] +/// "~/proyectos/wawa" = ":env RUST_BACKTRACE=1" +/// ``` +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct RulesConfig { + /// Comando a correr cuando un comando externo cierra con exit ≠ 0. + #[serde(default)] + pub on_exit_nonzero: Option, + /// Mapa prefijo-de-cwd → comando a correr al entrar a ese directorio + /// (o un hijo). El prefijo admite `~` (se expande a `$HOME`). + #[serde(default)] + pub on_enter_cwd: HashMap, + /// Overlays de entorno **por-comando**: patrón → variables a inyectar + /// SÓLO cuando la línea a ejecutar matchea el patrón. Es la + /// generalización del "seteo `http_proxy` a mano antes de `claude`": + /// se declara una vez y el shell se lo da a ese comando, *scoped* al + /// spawn (no toca el entorno del resto ni del sistema). El patrón + /// matchea contra la línea con glob simple (`*` = cualquier cosa); un + /// patrón sin `*` matchea si es la **primera palabra** del comando. + /// + /// ```toml + /// [rules.on_command] + /// "claude" = { http_proxy = "http://127.0.0.1:8080", https_proxy = "http://127.0.0.1:8080" } + /// "cargo *" = { RUST_BACKTRACE = "1" } + /// ``` + #[serde(default)] + pub on_command: HashMap>, + /// Umbral de ocurrencias para que el shell ofrezca guardar una + /// coreografía (A1). `0` = nunca ofrecer. + #[serde(default = "default_pattern_score")] + pub on_pattern_score: u32, + /// Segundos a partir de los cuales un comando se considera "largo" (A6). + #[serde(default = "default_long_secs")] + pub on_long_command_secs: u64, +} + +fn default_pattern_score() -> u32 { + 3 +} + +fn default_long_secs() -> u64 { + 30 +} + +impl Default for RulesConfig { + fn default() -> Self { + Self { + on_exit_nonzero: None, + on_enter_cwd: HashMap::new(), + on_command: HashMap::new(), + on_pattern_score: default_pattern_score(), + on_long_command_secs: default_long_secs(), + } + } +} + +/// Glob mínimo: `*` matchea cualquier secuencia (incl. vacía), el resto es +/// literal. Sin `?`, sin clases — alcanza para patrones de comando (`cargo *`, +/// `git commit*`). Recursivo con backtracking; los patrones son cortos. +fn glob_match(pat: &str, text: &str) -> bool { + match pat.split_once('*') { + None => pat == text, + Some((head, rest)) => { + if !text.starts_with(head) { + return false; + } + let mut resto = &text[head.len()..]; + // `*` prueba cada punto de corte del resto del texto. + loop { + if glob_match(rest, resto) { + return true; + } + match resto.char_indices().nth(1) { + Some((i, _)) => resto = &resto[i..], + None => return glob_match(rest, ""), + } + } + } + } +} + +impl RulesConfig { + /// Resuelve el comando a correr al entrar a `cwd`, si algún prefijo + /// declarado lo matchea. `home` expande el `~` de los prefijos. Elige + /// el prefijo **más largo** que matchee (el más específico gana). + pub fn command_for_cwd(&self, cwd: &str, home: &str) -> Option<&str> { + let mut best: Option<(&str, usize)> = None; + for (prefix, cmd) in &self.on_enter_cwd { + let expanded = if let Some(rest) = prefix.strip_prefix('~') { + format!("{home}{rest}") + } else { + prefix.clone() + }; + if cwd == expanded || cwd.starts_with(&format!("{expanded}/")) { + let len = expanded.len(); + if best.map(|(_, l)| len > l).unwrap_or(true) { + best = Some((cmd.as_str(), len)); + } + } + } + best.map(|(cmd, _)| cmd) + } + + /// Resuelve el overlay de entorno para la línea `line` juntando todas las + /// reglas `on_command` que la matchean. Un patrón sin `*` matchea si es la + /// **primera palabra** (así `claude` no pega en `claudette`); uno con `*` + /// se evalúa como glob sobre la línea entera. Ante colisión de una misma + /// variable, gana el patrón **más específico** (el más largo). El valor se + /// pasa por [`expand_env`] para permitir `$VAR`. Vacío = sin overlay. + pub fn env_for_command(&self, line: &str) -> Vec<(String, String)> { + let line = line.trim(); + let first = line.split_whitespace().next().unwrap_or(""); + // Recolecta (specificidad, clave, valor) de cada regla que matchea. + let mut acc: HashMap = HashMap::new(); + // Orden estable por patrón para que el resultado sea determinista. + let mut keys: Vec<&String> = self.on_command.keys().collect(); + keys.sort(); + for pat in keys { + let matches = if pat.contains('*') { + glob_match(pat, line) + } else { + pat == first + }; + if !matches { + continue; + } + let espec = pat.len(); + for (k, v) in &self.on_command[pat] { + let val = expand_env(v); + match acc.get(k) { + Some((prev, _)) if *prev >= espec => {} + _ => { + acc.insert(k.clone(), (espec, val)); + } + } + } + } + let mut out: Vec<(String, String)> = + acc.into_iter().map(|(k, (_, v))| (k, v)).collect(); + out.sort(); + out + } } impl Config { @@ -195,6 +404,199 @@ impl Config { } } +// ─── Grupos de environment (la config del sidebar del shell) ─────────── +// +// Un grupo nombrado de variables que se activa/desactiva en bloque desde +// la UI (`env.json` en el config dir). El builtin `:env` escribe al grupo +// «general»; la app puede definir grupos por proyecto/credenciales/etc. + +/// Grupo de variables de entorno activable en bloque. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct EnvGroup { + pub name: String, + /// Si el grupo está aplicado al proceso (los hijos lo heredan). + #[serde(default)] + pub active: bool, + /// Pares `(NOMBRE, valor)` en orden estable. + #[serde(default)] + pub vars: Vec<(String, String)>, +} + +impl EnvGroup { + pub fn new(name: impl Into) -> Self { + Self { name: name.into(), active: true, vars: Vec::new() } + } + + /// Inserta o reemplaza una variable del grupo. + pub fn upsert(&mut self, name: &str, value: &str) { + match self.vars.iter_mut().find(|(n, _)| n == name) { + Some((_, v)) => *v = value.to_string(), + None => self.vars.push((name.to_string(), value.to_string())), + } + } + + /// Borra una variable. Devuelve `true` si existía. + pub fn remove(&mut self, name: &str) -> bool { + let antes = self.vars.len(); + self.vars.retain(|(n, _)| n != name); + self.vars.len() != antes + } +} + +/// `$XDG_CONFIG_HOME/shuma/env.json` — el archivo de grupos. +pub fn env_groups_path() -> Option { + directories::ProjectDirs::from("", "", "shuma").map(|d| d.config_dir().join("env.json")) +} + +/// `$XDG_CONFIG_HOME/shuma/macros.toml` — el libro de macros (`:macro`). El +/// tipo (`shuma_intent::MacroBook`) vive en otro crate; aquí sólo la ruta. +pub fn macros_path() -> Option { + directories::ProjectDirs::from("", "", "shuma").map(|d| d.config_dir().join("macros.toml")) +} + +/// Lee los grupos. Archivo ausente o corrupto → lista vacía (sin error: +/// es config de conveniencia, el shell arranca igual). +pub fn load_env_groups() -> Vec { + env_groups_path() + .and_then(|p| std::fs::read_to_string(p).ok()) + .and_then(|s| serde_json::from_str(&s).ok()) + .unwrap_or_default() +} + +/// Persiste los grupos (atómico: tmp + rename). +pub fn save_env_groups(groups: &[EnvGroup]) -> std::io::Result<()> { + let Some(path) = env_groups_path() else { + return Ok(()); + }; + let json = serde_json::to_string_pretty(groups) + .map_err(|e| std::io::Error::new(std::io::ErrorKind::InvalidData, e))?; + if let Some(dir) = path.parent() { + std::fs::create_dir_all(dir)?; + } + let tmp = path.with_extension("json.tmp"); + std::fs::write(&tmp, json)?; + std::fs::rename(&tmp, path) +} + +/// Aplica/levanta un grupo del ambiente del proceso. `on = true` exporta +/// todas sus variables; `false` las remueve. Los hijos nuevos lo heredan. +pub fn apply_env_group(group: &EnvGroup, on: bool) { + for (k, v) in &group.vars { + if on { + std::env::set_var(k, v); + } else { + std::env::remove_var(k); + } + } +} + +/// Upsert **quirúrgico** de `key = value_raw` en la sección `[section]` +/// del archivo TOML en `path`: edita el TEXTO (preserva comentarios y el +/// resto de las secciones), crea el archivo y/o la sección si faltan. +/// `value_raw` va literal — el caller decide el formato TOML (`"texto"` +/// con [`toml_string`], `true`, `64`). +pub fn upsert_key(path: &Path, section: &str, key: &str, value_raw: &str) -> std::io::Result<()> { + let text = std::fs::read_to_string(path).unwrap_or_default(); + let header = format!("[{section}]"); + let mut lines: Vec = text.lines().map(str::to_string).collect(); + let nueva = format!("{key} = {value_raw}"); + + // Buscar la sección. + let sec_idx = lines.iter().position(|l| l.trim() == header); + match sec_idx { + Some(si) => { + // Rango de la sección: desde si+1 hasta el próximo header. + let fin = lines[si + 1..] + .iter() + .position(|l| l.trim_start().starts_with('[')) + .map(|o| si + 1 + o) + .unwrap_or(lines.len()); + // ¿La clave ya existe adentro? → reemplazo in-place. + for l in lines[si + 1..fin].iter_mut() { + let lt = l.trim_start(); + if let Some(eq) = lt.find('=') { + if lt[..eq].trim() == key { + *l = nueva; + let out = lines.join("\n") + "\n"; + return write_atomico(path, &out); + } + } + } + // No existe: insertar al final de la sección (antes de líneas + // en blanco que la separen de la próxima). + let mut ins = fin; + while ins > si + 1 && lines[ins - 1].trim().is_empty() { + ins -= 1; + } + lines.insert(ins, nueva); + } + None => { + if !lines.is_empty() && !lines.last().map(|l| l.is_empty()).unwrap_or(true) { + lines.push(String::new()); + } + lines.push(header); + lines.push(nueva); + } + } + let out = lines.join("\n") + "\n"; + write_atomico(path, &out) +} + +/// Borra `key` de la sección `[section]`. Devuelve `true` si existía. +pub fn remove_key(path: &Path, section: &str, key: &str) -> std::io::Result { + let Ok(text) = std::fs::read_to_string(path) else { + return Ok(false); + }; + let header = format!("[{section}]"); + let mut lines: Vec = text.lines().map(str::to_string).collect(); + let Some(si) = lines.iter().position(|l| l.trim() == header) else { + return Ok(false); + }; + let fin = lines[si + 1..] + .iter() + .position(|l| l.trim_start().starts_with('[')) + .map(|o| si + 1 + o) + .unwrap_or(lines.len()); + let antes = lines.len(); + let mut i = si + 1; + let mut fin = fin; + while i < fin { + let lt = lines[i].trim_start(); + let es_clave = lt + .find('=') + .map(|eq| lt[..eq].trim() == key) + .unwrap_or(false); + if es_clave { + lines.remove(i); + fin -= 1; + } else { + i += 1; + } + } + if lines.len() == antes { + return Ok(false); + } + let out = lines.join("\n") + "\n"; + write_atomico(path, &out)?; + Ok(true) +} + +/// Serializa un string como TOML basic string (comillas + escapes). +pub fn toml_string(s: &str) -> String { + toml::Value::String(s.to_string()).to_string() +} + +/// Escritura atómica: tmp + rename, para no dejar un rc a medias si el +/// proceso muere en medio del write. +fn write_atomico(path: &Path, contenido: &str) -> std::io::Result<()> { + if let Some(dir) = path.parent() { + std::fs::create_dir_all(dir)?; + } + let tmp = path.with_extension("toml.tmp"); + std::fs::write(&tmp, contenido)?; + std::fs::rename(&tmp, path) +} + impl From for &'static str { fn from(p: DedupPolicy) -> Self { match p { @@ -339,6 +741,99 @@ mod tests { assert_eq!(c, Config::default()); } + #[test] + fn glob_match_basico() { + assert!(glob_match("cargo *", "cargo build --release")); + assert!(glob_match("git commit*", "git commit -m x")); + assert!(glob_match("*", "cualquier cosa")); + assert!(glob_match("claude", "claude")); + assert!(!glob_match("cargo *", "cargol")); + assert!(!glob_match("claude", "claudette")); + } + + #[test] + fn on_command_inyecta_solo_al_match() { + let mut r = RulesConfig::default(); + let mut proxy = HashMap::new(); + proxy.insert("http_proxy".to_string(), "http://127.0.0.1:8080".to_string()); + r.on_command.insert("claude".to_string(), proxy); + + // `claude` (primera palabra) matchea aunque lleve args. + let env = r.env_for_command("claude --model x"); + assert_eq!(env, vec![("http_proxy".into(), "http://127.0.0.1:8080".into())]); + // Un comando ajeno no recibe nada — no se filtra el proxy. + assert!(r.env_for_command("git status").is_empty()); + // `claudette` no es `claude`: patrón sin `*` matchea la primera palabra exacta. + assert!(r.env_for_command("claudette").is_empty()); + } + + #[test] + fn on_command_glob_y_especificidad() { + let mut r = RulesConfig::default(); + let mut ancha = HashMap::new(); + ancha.insert("RUST_BACKTRACE".to_string(), "1".to_string()); + r.on_command.insert("cargo *".to_string(), ancha); + let mut angosta = HashMap::new(); + angosta.insert("RUST_BACKTRACE".to_string(), "full".to_string()); + r.on_command.insert("cargo test*".to_string(), angosta); + + // `cargo build` sólo pega la regla ancha. + assert_eq!( + r.env_for_command("cargo build"), + vec![("RUST_BACKTRACE".into(), "1".into())] + ); + // `cargo test` pega ambas; gana el patrón más específico (más largo). + assert_eq!( + r.env_for_command("cargo test -- --nocapture"), + vec![("RUST_BACKTRACE".into(), "full".into())] + ); + } + + #[test] + fn on_command_parsea_del_toml() { + let toml = r#" +[rules.on_command] +"claude" = { http_proxy = "http://127.0.0.1:8080", https_proxy = "http://127.0.0.1:8080" } +"cargo *" = { RUST_BACKTRACE = "1" } +"#; + let c: Config = toml::from_str(toml).unwrap(); + assert_eq!(c.rules.on_command.len(), 2); + assert_eq!(c.rules.env_for_command("claude").len(), 2); + } + + #[test] + fn rules_defaults_and_cwd_matching() { + // Defaults sin sección [rules]. + let r = RulesConfig::default(); + assert_eq!(r.on_pattern_score, 3); + assert_eq!(r.on_long_command_secs, 30); + assert!(r.on_exit_nonzero.is_none()); + + let toml = r#" +[rules] +on_exit_nonzero = ":jobs" +on_pattern_score = 5 + +[rules.on_enter_cwd] +"~/proy/wawa" = ":env RUST_BACKTRACE=1" +"~/proy" = ":env GENERAL=1" +"#; + let c: Config = toml::from_str(toml).unwrap(); + assert_eq!(c.rules.on_exit_nonzero.as_deref(), Some(":jobs")); + assert_eq!(c.rules.on_pattern_score, 5); + // El prefijo más específico (más largo) gana. + assert_eq!( + c.rules.command_for_cwd("/home/u/proy/wawa/sub", "/home/u"), + Some(":env RUST_BACKTRACE=1") + ); + assert_eq!( + c.rules.command_for_cwd("/home/u/proy/otro", "/home/u"), + Some(":env GENERAL=1") + ); + // Fuera de todo prefijo → nada. + assert_eq!(c.rules.command_for_cwd("/tmp", "/home/u"), None); + } + #[test] fn parses_a_full_example() { let d = tempdir().unwrap(); @@ -471,4 +966,65 @@ spill = true assert!(all.contains_key("good")); assert!(!all.contains_key("bad")); } + + #[test] + fn upsert_key_crea_archivo_y_seccion() { + let d = tempdir().unwrap(); + let p = d.path().join("rc.toml"); + upsert_key(&p, "env", "EDITOR", &toml_string("hx")).unwrap(); + let c = Config::load(&p).unwrap(); + assert_eq!(c.env.get("EDITOR").map(String::as_str), Some("hx")); + } + + #[test] + fn upsert_key_reemplaza_sin_tocar_el_resto() { + let d = tempdir().unwrap(); + let p = d.path().join("rc.toml"); + std::fs::write( + &p, + "# mi rc\n[aliases]\ngs = \"git status\"\n\n[env]\n# comentario\nEDITOR = \"vi\"\nPAGER = \"less\"\n", + ) + .unwrap(); + upsert_key(&p, "env", "EDITOR", &toml_string("hx")).unwrap(); + let texto = std::fs::read_to_string(&p).unwrap(); + assert!(texto.contains("# mi rc"), "preserva comentarios"); + assert!(texto.contains("# comentario")); + assert!(texto.contains("gs = \"git status\"")); + let c = Config::load(&p).unwrap(); + assert_eq!(c.env.get("EDITOR").map(String::as_str), Some("hx")); + assert_eq!(c.env.get("PAGER").map(String::as_str), Some("less")); + } + + #[test] + fn upsert_key_agrega_a_seccion_existente() { + let d = tempdir().unwrap(); + let p = d.path().join("rc.toml"); + std::fs::write(&p, "[env]\nA = \"1\"\n\n[history]\nmax = 10\n").unwrap(); + upsert_key(&p, "env", "B", &toml_string("2")).unwrap(); + let c = Config::load(&p).unwrap(); + assert_eq!(c.env.get("A").map(String::as_str), Some("1")); + assert_eq!(c.env.get("B").map(String::as_str), Some("2")); + } + + #[test] + fn remove_key_borra_y_reporta() { + let d = tempdir().unwrap(); + let p = d.path().join("rc.toml"); + std::fs::write(&p, "[env]\nA = \"1\"\nB = \"2\"\n").unwrap(); + assert!(remove_key(&p, "env", "A").unwrap()); + assert!(!remove_key(&p, "env", "A").unwrap()); + let c = Config::load(&p).unwrap(); + assert!(c.env.get("A").is_none()); + assert_eq!(c.env.get("B").map(String::as_str), Some("2")); + } + + #[test] + fn toml_string_escapa() { + assert_eq!(toml_string("hola"), "\"hola\""); + // El formato exacto puede variar (basic vs literal string); lo que + // importa es que el TOML resultante parsea de vuelta al mismo valor. + let raw = toml_string("con \"comillas\""); + let parsed: toml::Value = format!("v = {raw}").parse().unwrap(); + assert_eq!(parsed["v"].as_str(), Some("con \"comillas\"")); + } } diff --git a/02_ruway/shuma/sandbox/shuma-config/src/login_env.rs b/02_ruway/shuma/sandbox/shuma-config/src/login_env.rs new file mode 100644 index 0000000..e70eeb6 --- /dev/null +++ b/02_ruway/shuma/sandbox/shuma-config/src/login_env.rs @@ -0,0 +1,354 @@ +//! Absorción del **entorno de una terminal normal** al proceso de shuma. +//! +//! El problema que resuelve: cuando shuma se lanza desde un compositor +//! (pata/mirada) o un launcher —no desde una shell de login— hereda el +//! entorno *magro* de esa sesión, no el de tu terminal. Todo lo que +//! configuras en `~/.zshrc`/`~/.bashrc`/`~/.profile` (el `PATH` donde vive +//! `claude` vía npm/nvm/`~/.local/bin`, un `http_proxy`, `EDITOR`, etc.) no +//! existe. Resultado típico: `claude: command not found` en shuma aunque en +//! tu terminal ande. +//! +//! La cura, hermana de la absorción de historiales ([`crate`] no la trae; la +//! trae `shuma-history::foreign`): al arrancar, correr **una vez** tu shell +//! de login interactivo, capturar su entorno ya materializado (`env`), y +//! aplicar al proceso las variables nuevas o cambiadas. Así shuma queda con +//! **paridad de entorno** con tu terminal. Es idempotente y no destructivo: +//! sólo *agrega/actualiza*, nunca borra (las variables propias de shuma — +//! `SUDO_ASKPASS`, `SHUMA_*`, …— sobreviven). +//! +//! Se dispara automático al arrancar el frontend, y a pedido con el builtin +//! `:env sync` (útil si instalaste algo nuevo o tocaste el `.zshrc`). + +use std::collections::HashMap; +use std::path::PathBuf; +use std::sync::mpsc; +use std::time::Duration; + +/// Cuánto esperar a que la login shell imprima su entorno antes de rendirse. +/// Un `.zshrc`/`.bashrc` con nvm/conda/pyenv puede tardar; pasado el tope se +/// mata el proceso y se sigue sin bloquear el arranque. +const CAPTURE_TIMEOUT: Duration = Duration::from_secs(8); + +/// Reporte de una sincronización — para que el builtin `:env sync` lo muestre. +#[derive(Debug, Clone, Default)] +pub struct LoginEnvReport { + /// La shell de login que se consultó (basename), si se detectó. + pub shell: Option, + /// Cuántas variables trajo la captura en total (antes del filtro/diff). + pub captured: usize, + /// Variables efectivamente aplicadas (nuevas o con valor cambiado). + pub applied: Vec<(String, String)>, + /// `true` si `PATH` fue una de las aplicadas (el caller refresca el + /// escaneo de binarios del autocompletado). + pub path_changed: bool, + /// Motivo de falla si la captura no se pudo hacer (shell ausente, timeout). + pub failed: Option, +} + +impl LoginEnvReport { + pub fn is_noop(&self) -> bool { + self.applied.is_empty() && self.failed.is_none() + } +} + +/// Variables que NUNCA se importan de la login shell: identidad del proceso, +/// estado efímero de la shell, o cosas que shuma gobierna por su cuenta. +fn es_denegada(name: &str) -> bool { + name.starts_with("SHUMA_") + || matches!( + name, + "_" | "SHLVL" + | "PWD" + | "OLDPWD" + | "TERM" + | "HOME" + | "USER" + | "LOGNAME" + | "HOSTNAME" + | "LINES" + | "COLUMNS" + ) +} + +fn es_nombre_valido(name: &str) -> bool { + !name.is_empty() + && name + .chars() + .enumerate() + .all(|(i, c)| c == '_' || c.is_ascii_alphabetic() || (i > 0 && c.is_ascii_digit())) +} + +/// Busca un ejecutable en el `PATH` del proceso (sin depender de `which`). +fn en_path(bin: &str) -> Option { + let path = std::env::var_os("PATH")?; + std::env::split_paths(&path) + .map(|d| d.join(bin)) + .find(|p| p.exists()) +} + +/// Detecta la shell de login del usuario: `$SHELL` si apunta a algo real, si +/// no la primera de zsh/bash/sh que exista. Devuelve `(path, es_posix_puro)`. +fn detectar_shell() -> Option { + if let Some(sh) = std::env::var_os("SHELL") { + let p = PathBuf::from(sh); + if p.exists() { + return Some(p); + } + } + for cand in ["zsh", "bash", "sh"] { + if let Some(p) = en_path(cand) { + return Some(p); + } + } + None +} + +/// Flags para que la shell sourcee la config de una terminal real. zsh/bash +/// leen su rc **interactivo** (`.zshrc`/`.bashrc`) con `-i`, que es donde la +/// gente suele agregar el `PATH` — no sólo el de login (`-l`). Las shells +/// POSIX puras (dash) no soportan `-i` útilmente: sólo `-l`. +fn flags_para(shell: &std::path::Path) -> &'static str { + let base = shell + .file_name() + .and_then(|n| n.to_str()) + .unwrap_or(""); + if base.contains("zsh") || base.contains("bash") { + "-lic" + } else { + "-lc" + } +} + +/// Marcador que la shell imprime **justo antes** de volcar su entorno. Todo +/// lo anterior (un `fastfetch`/`neofetch`/motd que el `.zshrc` interactivo +/// escupa a stdout) se descarta: sin esto, ese ruido se pegaría al primer +/// registro de env y perdería esa variable (a menudo `PATH`). `0x1e` (RS) no +/// aparece en valores de entorno normales. +const SENTINEL: &[u8] = b"\x1eSHUMAENV\x1e"; + +/// Índice de la primera aparición de `needle` en `hay` (búsqueda ingenua; +/// entradas cortas). +fn find_subslice(hay: &[u8], needle: &[u8]) -> Option { + if needle.is_empty() || needle.len() > hay.len() { + return None; + } + (0..=hay.len() - needle.len()).find(|&i| &hay[i..i + needle.len()] == needle) +} + +/// Descarta el preámbulo previo al [`SENTINEL`] (salida interactiva del rc). +/// Sin marcador (la shell no llegó a imprimirlo), devuelve todo — best effort. +fn strip_preamble(bytes: &[u8]) -> &[u8] { + match find_subslice(bytes, SENTINEL) { + Some(i) => &bytes[i + SENTINEL.len()..], + None => bytes, + } +} + +/// Parsea la salida de `env`/`env -0`. Si trae NULs (GNU `env -0`) parte por +/// NUL —robusto ante valores con newline—; si no, por líneas. Descarta +/// registros sin `=` o con nombre inválido (tolerante, nunca entra en pánico). +pub fn parse_env(bytes: &[u8]) -> Vec<(String, String)> { + let text = String::from_utf8_lossy(bytes); + let registros: Vec<&str> = if bytes.contains(&0) { + text.split('\0').collect() + } else { + text.lines().collect() + }; + let mut out = Vec::new(); + for rec in registros { + if rec.is_empty() { + continue; + } + let Some((name, value)) = rec.split_once('=') else { + continue; + }; + if es_nombre_valido(name) { + out.push((name.to_string(), value.to_string())); + } + } + out +} + +/// Corre la login shell y captura su entorno. Bloquea hasta [`CAPTURE_TIMEOUT`]; +/// si se pasa, mata el proceso y devuelve error (no cuelga el arranque). +fn capturar(shell: &std::path::Path) -> Result, String> { + use std::io::Read; + use std::process::{Command, Stdio}; + + // `command env` evita un alias/función `env`; `-0` (GNU) preserva valores + // multilínea, con fallback al `env` clásico si la plataforma no lo tiene. + // El `printf` del centinela va **antes**: marca dónde empieza el env real + // y deja atrás lo que el `.zshrc` interactivo haya escupido (fastfetch…). + let script = "printf '\\036SHUMAENV\\036'; command env -0 2>/dev/null || command env"; + let mut child = Command::new(shell) + .arg(flags_para(shell)) + .arg("-c") + .arg(script) + .stdin(Stdio::null()) + .stdout(Stdio::piped()) + .stderr(Stdio::null()) + .spawn() + .map_err(|e| format!("no se pudo lanzar {}: {e}", shell.display()))?; + + // Leemos stdout en un hilo para poder aplicar timeout desde aquí (el Child + // queda de este lado para matarlo si se pasa). + let mut stdout = child + .stdout + .take() + .ok_or_else(|| "sin stdout de la login shell".to_string())?; + let (tx, rx) = mpsc::channel(); + std::thread::spawn(move || { + let mut buf = Vec::new(); + let _ = stdout.read_to_end(&mut buf); + let _ = tx.send(buf); + }); + + match rx.recv_timeout(CAPTURE_TIMEOUT) { + Ok(buf) => { + let _ = child.wait(); + Ok(parse_env(strip_preamble(&buf))) + } + Err(_) => { + let _ = child.kill(); + let _ = child.wait(); + Err(format!( + "la login shell no respondió en {}s (¿rc lento o interactivo?)", + CAPTURE_TIMEOUT.as_secs() + )) + } + } +} + +/// `$XDG_DATA_HOME/shuma/shell_env.json` — cache de la última captura, para +/// transparencia/diagnóstico (`:env sync --show` la puede contrastar). +pub fn cache_path() -> Option { + directories::ProjectDirs::from("", "", "shuma") + .map(|d| d.data_dir().join("shell_env.json")) +} + +fn guardar_cache(env: &[(String, String)]) { + let Some(path) = cache_path() else { + return; + }; + if let Some(dir) = path.parent() { + let _ = std::fs::create_dir_all(dir); + } + if let Ok(json) = serde_json::to_string_pretty(env) { + let tmp = path.with_extension("json.tmp"); + if std::fs::write(&tmp, json).is_ok() { + let _ = std::fs::rename(&tmp, path); + } + } +} + +/// Absorbe el entorno de la login shell al proceso actual: aplica las +/// variables nuevas o con valor distinto al vigente. Idempotente y aditivo +/// (nunca remueve). Devuelve el reporte de lo aplicado. +/// +/// **Seguridad de hilos:** llama a `std::env::set_var`, que no es seguro con +/// otros hilos leyendo `getenv` a la vez. Igual que [`crate::Config::apply_env`], +/// se espera invocarla **una vez, en el hilo principal, antes de spawnear +/// subprocesos**. La captura corre en un hilo aparte pero sólo lee su propio +/// stdout; el `set_var` ocurre aquí, en el hilo del caller. +pub fn sync_into_process() -> LoginEnvReport { + let mut report = LoginEnvReport::default(); + let Some(shell) = detectar_shell() else { + report.failed = Some("no se encontró una shell de login (zsh/bash/sh)".into()); + return report; + }; + report.shell = shell + .file_name() + .and_then(|n| n.to_str()) + .map(|s| s.to_string()); + + let captured = match capturar(&shell) { + Ok(v) => v, + Err(e) => { + report.failed = Some(e); + return report; + } + }; + report.captured = captured.len(); + guardar_cache(&captured); + + // Snapshot del entorno vigente para el diff. + let actual: HashMap = std::env::vars().collect(); + for (k, v) in captured { + if es_denegada(&k) { + continue; + } + // Sólo aplicar si es nueva o cambió — evita ruido y trabajo inútil. + if actual.get(&k).map(|cur| cur == &v).unwrap_or(false) { + continue; + } + std::env::set_var(&k, &v); + if k == "PATH" { + report.path_changed = true; + } + report.applied.push((k, v)); + } + report.applied.sort(); + report +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn parse_env_lineas() { + let v = parse_env(b"PATH=/bin:/usr/bin\nEDITOR=hx\n"); + assert_eq!(v.len(), 2); + assert_eq!(v[0], ("PATH".into(), "/bin:/usr/bin".into())); + assert_eq!(v[1], ("EDITOR".into(), "hx".into())); + } + + #[test] + fn parse_env_nul_preserva_multilinea() { + // `env -0`: registros separados por NUL; un valor con newline sobrevive. + let v = parse_env(b"A=uno\ndos\0B=x\0"); + assert_eq!(v.len(), 2); + assert_eq!(v[0], ("A".into(), "uno\ndos".into())); + assert_eq!(v[1], ("B".into(), "x".into())); + } + + #[test] + fn parse_env_descarta_basura() { + // Línea sin `=` y nombre inválido se saltean sin paniquear. + let v = parse_env(b"ruido sin igual\n1MALA=x\nBIEN=y\n"); + assert_eq!(v, vec![("BIEN".into(), "y".into())]); + } + + #[test] + fn strip_preamble_descarta_ruido_del_rc() { + // Un fastfetch en el .zshrc escupe basura antes del env; el centinela + // la corta para que la PRIMERA variable (aquí PATH) no se pierda. + let mut raw = Vec::new(); + raw.extend_from_slice(b"\x1b[38;2;1;2;3m fastfetch banner \x1b[0m\n"); + raw.extend_from_slice(SENTINEL); + raw.extend_from_slice(b"PATH=/home/x/.local/bin:/usr/bin\0EDITOR=hx\0"); + let env = parse_env(strip_preamble(&raw)); + assert_eq!( + env, + vec![ + ("PATH".into(), "/home/x/.local/bin:/usr/bin".into()), + ("EDITOR".into(), "hx".into()), + ] + ); + } + + #[test] + fn strip_preamble_sin_marcador_devuelve_todo() { + // Sin centinela (shell rara que no corrió el printf): best effort. + assert_eq!(strip_preamble(b"A=1\nB=2\n"), b"A=1\nB=2\n"); + } + + #[test] + fn denylist_cubre_efimeras_y_propias() { + assert!(es_denegada("PWD")); + assert!(es_denegada("SHLVL")); + assert!(es_denegada("SHUMA_DOCK")); + assert!(!es_denegada("PATH")); + assert!(!es_denegada("http_proxy")); + } +} diff --git a/02_ruway/shuma/sandbox/shuma-consola-client/Cargo.toml b/02_ruway/shuma/sandbox/shuma-consola-client/Cargo.toml new file mode 100644 index 0000000..4a8d099 --- /dev/null +++ b/02_ruway/shuma/sandbox/shuma-consola-client/Cargo.toml @@ -0,0 +1,14 @@ +[package] +name = "shuma-consola-client" +version.workspace = true +edition.workspace = true +license.workspace = true +authors.workspace = true +publish.workspace = true +description = "shuma — cliente HTTP de la consola de claudes contra el /rpc del gateway. Bloqueante (ureq), pensado para correr en hilos de polling del chasis móvil. Reusa los tipos del protocolo serializados como JSON (el gateway habla JSON externally-tagged)." + +[dependencies] +shuma-protocol = { path = "../shuma-protocol" } +shuma-consola-core = { path = "../shuma-consola-core" } +ureq = { workspace = true } +serde_json = { workspace = true } diff --git a/02_ruway/shuma/sandbox/shuma-consola-client/LEEME.md b/02_ruway/shuma/sandbox/shuma-consola-client/LEEME.md new file mode 100644 index 0000000..718df38 --- /dev/null +++ b/02_ruway/shuma/sandbox/shuma-consola-client/LEEME.md @@ -0,0 +1,18 @@ +# shuma-consola-client + +*Read this in English: [README.md](README.md).* + +El cliente HTTP de la consola contra el gateway. + +El gateway expone `POST /rpc` con un `shuma_protocol::Request` como **JSON +externally-tagged** y devuelve el `shuma_protocol::Response` igual. Este +cliente reusa esos tipos (una sola fuente de verdad del contrato) y los +manda con **ureq bloqueante** — pensado para correr dentro de un hilo de +polling del chasis móvil (`handle.spawn`), sin runtime async. + +Auth: si el gateway tiene `SHIPOTE_GATEWAY_TOKEN`, se manda por +`Authorization: Bearer `. + +--- + +Parte de **shuma** — ver [shuma](../../LEEME.md). diff --git a/02_ruway/shuma/sandbox/shuma-consola-client/README.md b/02_ruway/shuma/sandbox/shuma-consola-client/README.md new file mode 100644 index 0000000..a47f262 --- /dev/null +++ b/02_ruway/shuma/sandbox/shuma-consola-client/README.md @@ -0,0 +1,12 @@ +# shuma-consola-client + +The console's HTTP client against the gateway. + +The gateway exposes `POST /rpc` with a `shuma_protocol::Request` as +**externally-tagged JSON** and returns the `shuma_protocol::Response` the same way. +This client reuses those types (one single source of truth for the contract) and +sends them with **blocking ureq** — meant to run inside a thread. + +--- + +Part of **shuma** — see [shuma](../../README.md). diff --git a/02_ruway/shuma/sandbox/shuma-consola-client/examples/e2e_gateway.rs b/02_ruway/shuma/sandbox/shuma-consola-client/examples/e2e_gateway.rs new file mode 100644 index 0000000..7cdbec2 --- /dev/null +++ b/02_ruway/shuma/sandbox/shuma-consola-client/examples/e2e_gateway.rs @@ -0,0 +1,46 @@ +//! E2E del cliente contra un gateway vivo (gasta un turno chico de claude). +//! Necesita daemon + gateway corriendo. Config por `CONSOLA_GATEWAY`. +//! +//! Certifica el camino completo del teléfono: `GatewayClient` → HTTP `/rpc` +//! → gateway → daemon → registro → claude → `Sesion` reducida de vuelta. + +use shuma_consola_client::GatewayClient; +use shuma_consola_core::{EstadoSesion, Etapa}; + +fn main() { + let base = std::env::var("CONSOLA_GATEWAY").unwrap_or_else(|_| "http://127.0.0.1:7391".into()); + let cwd = std::env::var("CONSOLA_CWD").unwrap_or_else(|_| "/tmp".into()); + let c = GatewayClient::new(base, std::env::var("CONSOLA_TOKEN").ok()); + + let n0 = c.list().expect("list inicial").len(); + let id = c.crear(&cwd, "Responde sólo con la palabra: kiwi", None).expect("crear"); + println!("creada: {id}"); + + let hasta = std::time::Instant::now() + std::time::Duration::from_secs(90); + let sesion = loop { + assert!(std::time::Instant::now() < hasta, "timeout"); + if let Some(s) = c.snapshot(&id).expect("snapshot") { + if matches!(s.estado, EstadoSesion::Idle | EstadoSesion::Fallida(_)) { + break s; + } + } + std::thread::sleep(std::time::Duration::from_millis(500)); + }; + + let texto = sesion + .turnos + .iter() + .rev() + .flat_map(|t| t.etapas.iter().rev()) + .find_map(|e| if let Etapa::Texto(t) = e { Some(t.clone()) } else { None }) + .unwrap_or_default(); + println!("respuesta: {texto}"); + assert!(texto.to_lowercase().contains("kiwi"), "el turno no respondió por HTTP"); + + let tabs = c.list().expect("list final"); + assert_eq!(tabs.len(), n0 + 1, "debía haber un tab más"); + assert_eq!(tabs.last().unwrap().id, id); + + assert!(c.kill(&id).expect("kill")); + println!("✓ e2e gateway client OK — el teléfono controla claudes por HTTP"); +} diff --git a/02_ruway/shuma/sandbox/shuma-consola-client/src/lib.rs b/02_ruway/shuma/sandbox/shuma-consola-client/src/lib.rs new file mode 100644 index 0000000..91c2c0a --- /dev/null +++ b/02_ruway/shuma/sandbox/shuma-consola-client/src/lib.rs @@ -0,0 +1,157 @@ +//! `shuma-consola-client` — el cliente HTTP de la consola contra el gateway. +//! +//! El gateway expone `POST /rpc` con un [`shuma_protocol::Request`] como **JSON +//! externally-tagged** y devuelve el [`shuma_protocol::Response`] igual. Este +//! cliente reusa esos tipos (una sola fuente de verdad del contrato) y los +//! manda con **ureq bloqueante** — pensado para correr dentro de un hilo de +//! polling del chasis móvil (`handle.spawn`), sin runtime async. +//! +//! Auth: si el gateway tiene `SHIPOTE_GATEWAY_TOKEN`, se manda por +//! `Authorization: Bearer `. + +#![forbid(unsafe_code)] + +use shuma_consola_core::Sesion; +use shuma_protocol::{ConsolaResumen, Request, Response}; + +/// Cliente contra un gateway (`base` = p.ej. `http://192.168.1.20:7378`). +#[derive(Clone)] +pub struct GatewayClient { + base: String, + token: Option, + agent: ureq::Agent, +} + +impl GatewayClient { + /// Cliente contra `base` (sin barra final), con token opcional. + pub fn new(base: impl Into, token: Option) -> Self { + let agent = ureq::AgentBuilder::new() + .timeout(std::time::Duration::from_secs(30)) + .build(); + Self { + base: base.into().trim_end_matches('/').to_string(), + token, + agent, + } + } + + /// Manda una request y devuelve la response (o un error legible). Serializa + /// a JSON a mano (no usa la feature `json` de ureq): el gateway habla el + /// mismo JSON que `serde_json` produce para los tipos del protocolo. + fn rpc(&self, req: &Request) -> Result { + let url = format!("{}/rpc", self.base); + let body = serde_json::to_vec(req).map_err(|e| format!("serializar request: {e}"))?; + let mut r = self.agent.post(&url).set("Content-Type", "application/json"); + if let Some(t) = &self.token { + r = r.set("Authorization", &format!("Bearer {t}")); + } + let resp = r + .send_bytes(&body) + .map_err(|e| format!("gateway {url}: {e}"))?; + let text = resp + .into_string() + .map_err(|e| format!("leer respuesta del gateway: {e}"))?; + serde_json::from_str::(&text) + .map_err(|e| format!("respuesta no-JSON del gateway: {e}")) + } + + /// Desenvuelve un `Response::Error` como `Err`. + fn no_error(resp: Response) -> Result { + match resp { + Response::Error { message } => Err(message), + otra => Ok(otra), + } + } + + /// Lista de sesiones (para la tira de tabs). + pub fn list(&self) -> Result, String> { + match Self::no_error(self.rpc(&Request::ConsolaList)?)? { + Response::ConsolaList { sesiones } => Ok(sesiones), + otra => Err(inesperada("ConsolaList", &otra)), + } + } + + /// Snapshot del historial reducido de una sesión. + pub fn snapshot(&self, id: &str) -> Result, String> { + let req = Request::ConsolaSnapshot { id: id.to_string() }; + match Self::no_error(self.rpc(&req)?)? { + Response::ConsolaSnapshot { sesion } => Ok(sesion), + otra => Err(inesperada("ConsolaSnapshot", &otra)), + } + } + + /// Crea una sesión y arranca su primer turno. Devuelve el id. + pub fn crear(&self, cwd: &str, prompt: &str, model: Option) -> Result { + let req = Request::ConsolaCrear { + cwd: cwd.to_string(), + prompt: prompt.to_string(), + model, + }; + match Self::no_error(self.rpc(&req)?)? { + Response::ConsolaCreada { id } => Ok(id), + otra => Err(inesperada("ConsolaCreada", &otra)), + } + } + + /// Manda el próximo mensaje (reanuda). `Ok(false)` si no existe la sesión. + pub fn enviar(&self, id: &str, prompt: &str) -> Result { + let req = Request::ConsolaEnviar { + id: id.to_string(), + prompt: prompt.to_string(), + }; + self.ack(req) + } + + /// Marca una sesión como vista. + pub fn leida(&self, id: &str) -> Result { + self.ack(Request::ConsolaLeida { id: id.to_string() }) + } + + /// Mata una sesión. `Ok(false)` si no existía. + pub fn kill(&self, id: &str) -> Result { + self.ack(Request::ConsolaKill { id: id.to_string() }) + } + + fn ack(&self, req: Request) -> Result { + match Self::no_error(self.rpc(&req)?)? { + Response::ConsolaOk { existed } => Ok(existed), + otra => Err(inesperada("ConsolaOk", &otra)), + } + } +} + +fn inesperada(esperada: &str, got: &Response) -> String { + format!("esperaba {esperada}, llegó {got:?}") +} + +#[cfg(test)] +mod tests { + use super::*; + + /// El cuerpo que el cliente manda es el JSON externally-tagged que el + /// gateway espera (`serde_json::from_slice::`). Si esto cambia, + /// el gateway deja de entender al cliente. + #[test] + fn request_serializa_al_json_del_gateway() { + let req = Request::ConsolaCrear { + cwd: "/srv/proyecto".into(), + prompt: "arregla el bug".into(), + model: None, + }; + let j = serde_json::to_string(&req).unwrap(); + assert_eq!( + j, + r#"{"ConsolaCrear":{"cwd":"/srv/proyecto","prompt":"arregla el bug","model":null}}"# + ); + // Variante unitaria = string desnudo. + assert_eq!(serde_json::to_string(&Request::ConsolaList).unwrap(), r#""ConsolaList""#); + } + + /// Una `Response` JSON del gateway se deserializa a los tipos del dominio. + #[test] + fn response_deserializa_del_json_del_gateway() { + let j = r#"{"ConsolaCreada":{"id":"consola-7"}}"#; + let r: Response = serde_json::from_str(j).unwrap(); + assert!(matches!(r, Response::ConsolaCreada { id } if id == "consola-7")); + } +} diff --git a/02_ruway/shuma/sandbox/shuma-consola-core/Cargo.toml b/02_ruway/shuma/sandbox/shuma-consola-core/Cargo.toml new file mode 100644 index 0000000..ac792be --- /dev/null +++ b/02_ruway/shuma/sandbox/shuma-consola-core/Cargo.toml @@ -0,0 +1,16 @@ +[package] +name = "shuma-consola-core" +version.workspace = true +edition.workspace = true +license.workspace = true +authors.workspace = true +publish.workspace = true +description = "shuma — núcleo puro de la consola de claudes agénticos: una sesión = una sesión de Claude Code viva (reanudable por session_id), cuyo stream-json se reduce a etapas desplegables (pensamiento/herramienta/texto). Sin red, sin proceso, sin reloj: el host corre `claude` y le pasa las líneas. Estado y atención derivados para los tabs del móvil." + +[dependencies] +serde = { workspace = true } +serde_json = { workspace = true } + +[dev-dependencies] +# Certifica que Sesion cruza el framing del daemon (u32 + postcard). +postcard = { workspace = true } diff --git a/02_ruway/shuma/sandbox/shuma-consola-core/LEEME.md b/02_ruway/shuma/sandbox/shuma-consola-core/LEEME.md new file mode 100644 index 0000000..50f80a5 --- /dev/null +++ b/02_ruway/shuma/sandbox/shuma-consola-core/LEEME.md @@ -0,0 +1,27 @@ +# shuma-consola-core + +*Read this in English: [README.md](README.md).* + +El núcleo puro de la **consola de claudes**. + +Cada **sesión** es una sesión agéntica real de **Claude Code** viva en el +server: se arranca con `claude -p … --output-format stream-json` y se +**reanuda** turno a turno con `--resume `. La sesión durable la +posee Claude Code (su store en disco); este núcleo sólo modela lo que la UI +necesita: el **historial reducido a etapas desplegables** (pensamiento / +herramienta / texto — «logs por etapas, no un terminal crudo») y el +**estado + atención** de cada tab. + +Como el resto de los núcleos de shuma: **sin red, sin proceso, sin reloj**. +El host corre `claude`, lee su stdout NDJSON y va empujando cada línea con +`Sesion::aplicar_linea`; el `ts` lo fija el caller. Todo aquí es puro y +testeable — y se certifica contra un **fixture real** capturado del CLI +(`tests/reduce_real.rs`), no contra uno inventado. + +El transporte (daemon que mantiene N sesiones vivas + gateway que las +adjunta al móvil) se apoya en este núcleo, espejando el registro de +sesiones PTY del daemon pero con **frames de `Cambio`** en vez de bytes. + +--- + +Parte de **shuma** — ver [shuma](../../LEEME.md). diff --git a/02_ruway/shuma/sandbox/shuma-consola-core/README.md b/02_ruway/shuma/sandbox/shuma-consola-core/README.md new file mode 100644 index 0000000..45f9e0b --- /dev/null +++ b/02_ruway/shuma/sandbox/shuma-consola-core/README.md @@ -0,0 +1,12 @@ +# shuma-consola-core + +The pure core of the **console of claudes**. + +Each **session** is a real agentic Claude Code session alive on the server: it is +started with `claude -p … --output-format stream-json` and **resumed** turn by turn +with `--resume `. The durable session is owned by Claude Code (its own +on-disk store); this core only models what the UI needs. + +--- + +Part of **shuma** — see [shuma](../../README.md). diff --git a/02_ruway/shuma/sandbox/shuma-consola-core/src/lib.rs b/02_ruway/shuma/sandbox/shuma-consola-core/src/lib.rs new file mode 100644 index 0000000..c778783 --- /dev/null +++ b/02_ruway/shuma/sandbox/shuma-consola-core/src/lib.rs @@ -0,0 +1,627 @@ +//! `shuma-consola-core` — el núcleo puro de la **consola de claudes**. +//! +//! Cada **sesión** es una sesión agéntica real de **Claude Code** viva en el +//! server: se arranca con `claude -p … --output-format stream-json` y se +//! **reanuda** turno a turno con `--resume `. La sesión durable la +//! posee Claude Code (su store en disco); este núcleo sólo modela lo que la UI +//! necesita: el **historial reducido a etapas desplegables** (pensamiento / +//! herramienta / texto — «logs por etapas, no un terminal crudo») y el +//! **estado + atención** de cada tab. +//! +//! Como el resto de los núcleos de shuma: **sin red, sin proceso, sin reloj**. +//! El host corre `claude`, lee su stdout NDJSON y va empujando cada línea con +//! [`Sesion::aplicar_linea`]; el `ts` lo fija el caller. Todo aquí es puro y +//! testeable — y se certifica contra un **fixture real** capturado del CLI +//! (`tests/reduce_real.rs`), no contra uno inventado. +//! +//! El transporte (daemon que mantiene N sesiones vivas + gateway que las +//! adjunta al móvil) se apoya en este núcleo, espejando el registro de +//! sesiones PTY del daemon pero con **frames de [`Cambio`]** en vez de bytes. + +#![forbid(unsafe_code)] + +use serde::{Deserialize, Serialize}; + +// ───────────────────────────── Etapas ────────────────────────────── +// El vocabulario "desplegable por etapas": lo que un turno del asistente +// produce, en el orden en que Claude Code lo emite. + +/// Ciclo de vida de una llamada a herramienta dentro de un turno. +#[derive(Clone, Copy, Debug, Serialize, Deserialize, PartialEq, Eq)] +pub enum EstadoHerramienta { + /// La pidió el modelo; aún no llegó su `tool_result`. + EnCurso, + /// Terminó bien. + Ok, + /// El `tool_result` vino marcado `is_error`. + Error, +} + +/// Una llamada a herramienta (Bash/Read/Edit/…) — la fila colapsable: el +/// `resumen` es el one-liner visible, y al desplegar se ven `input` y +/// `resultado`. +#[derive(Clone, Debug, Serialize, Deserialize, PartialEq)] +pub struct Herramienta { + /// `tool_use_id` de Claude Code — enlaza el `tool_use` con su `tool_result`. + pub tool_id: String, + /// Nombre de la herramienta (`Bash`, `Read`, `Edit`, …). + pub nombre: String, + /// One-liner para la fila colapsada (el comando, la ruta, el patrón…). + pub resumen: String, + /// El input completo del modelo, serializado a JSON compacto (**String**, + /// no `serde_json::Value`: el tipo viaja por postcard, que no deserializa + /// `Value` — no es self-describing). Para desplegar el detalle crudo. + pub input_json: String, + /// La salida, cuando llegó su `tool_result`. + pub resultado: Option, + /// Estado del ciclo de vida. + pub estado: EstadoHerramienta, +} + +/// Una etapa dentro de un turno del asistente. +#[derive(Clone, Debug, Serialize, Deserialize, PartialEq)] +pub enum Etapa { + /// Razonamiento (colapsado por defecto en la UI). Sólo se guarda si trae + /// texto — en modo `-p` suele venir vacío. + Pensamiento(String), + /// Una llamada a herramienta y su resultado. + Herramienta(Herramienta), + /// Prosa visible del modelo (markdown). + Texto(String), + /// Algo que el turno reportó como error (p. ej. el `result` final con + /// `is_error`). + Error(String), +} + +// ──────────────────────────── Turno / Uso ────────────────────────── + +/// Quién habló en un turno. +#[derive(Clone, Copy, Debug, Serialize, Deserialize, PartialEq, Eq)] +pub enum Rol { + Usuario, + Asistente, +} + +/// Conteo de tokens de un turno (lo reporta el evento `result`). +#[derive(Clone, Copy, Debug, Default, Serialize, Deserialize, PartialEq, Eq)] +pub struct Uso { + pub entrada: u32, + pub salida: u32, +} + +impl Uso { + /// `true` si hay algo que mostrar. + pub fn hay(&self) -> bool { + self.entrada > 0 || self.salida > 0 + } +} + +/// Un turno de la sesión. El del usuario suele ser un solo [`Etapa::Texto`]; +/// el del asistente, la secuencia de etapas de ese turno agéntico. +#[derive(Clone, Debug, Serialize, Deserialize, PartialEq)] +pub struct Turno { + pub rol: Rol, + pub etapas: Vec, + pub uso: Option, + /// Epoch ms fijado por el caller. + pub ts: u64, +} + +// ─────────────────────── Estado + atención ───────────────────────── + +/// Estado de la sesión. Una sesión de Claude Code es **reanudable +/// indefinidamente**: un turno corre hasta terminar y vuelve a `Idle` +/// esperando el próximo mensaje; la sesión sólo "termina" cuando el operador +/// la mata (análogo a `PtyKill`). +#[derive(Clone, Debug, Serialize, Deserialize, PartialEq)] +pub enum EstadoSesion { + /// Recién creada; aún no llegó el `system/init`. + Arrancando, + /// Hay un turno en curso (proceso `claude` vivo). + Corriendo, + /// Sin turno en curso, esperando el próximo mensaje del usuario. + Idle, + /// El último turno falló. + Fallida(String), +} + +/// El badge de atención de un tab — derivado, nunca se setea a mano (salvo +/// `pide_algo`, que lo prende un aviso externo del hook `Notification`). +#[derive(Clone, Copy, Debug, Serialize, Deserialize, PartialEq, Eq)] +pub enum Atencion { + /// Nada que reclamar. + Nada, + /// Turno en curso (spinner). + Corriendo, + /// Salió texto/herramientas nuevas sin ver (N etapas). + SinLeer(u32), + /// El claude pide algo (permiso / input) — lo prende un aviso externo. + PideAlgo, +} + +// ─────────────────────────── Cambio (delta) ──────────────────────── + +/// Qué cambió al aplicar una línea — el frame que el transporte transmite en +/// vivo a los clientes adjuntos (análogo a `SessionEvent::Bytes` del registro +/// PTY, pero estructurado). El cliente re-lee la `Sesion` en los índices dados. +#[derive(Clone, Debug, Serialize, Deserialize, PartialEq)] +pub enum Cambio { + /// Se aprendió metadata (`claude_session_id` / modelo / cwd). + Meta, + /// Cambió el estado de la sesión. + Estado(EstadoSesion), + /// Se agregó o actualizó `turnos[turno].etapas[idx]`. + Etapa { turno: usize, idx: usize }, + /// El turno cerró con este uso de tokens. + Fin { uso: Option }, +} + +// ──────────────────────────── Sesión ─────────────────────────────── + +/// Una sesión de la consola: el historial reducido + su estado + su atención. +#[derive(Clone, Debug, Serialize, Deserialize, PartialEq)] +pub struct Sesion { + /// Handle propio de la consola (lo genera el host; el núcleo no lee reloj + /// ni azar). + pub id: String, + /// `session_id` de Claude Code, para `--resume`. Se aprende del + /// `system/init` (y se confirma en el `result`). + pub claude_session_id: Option, + /// Título visible (se deriva del primer mensaje). + pub titulo: String, + /// Directorio de trabajo del claude. + pub cwd: String, + /// Modelo reportado por Claude Code. + pub modelo: Option, + pub estado: EstadoSesion, + pub turnos: Vec, + pub creada: u64, + pub actualizada: u64, + /// Etapas nuevas desde el último [`Sesion::marcar_leido`] (badge). + pub sin_leer: u32, + /// Lo prende un aviso externo (hook `Notification`): el claude pide algo. + pub pide_algo: bool, +} + +impl Sesion { + /// Sesión vacía, `Arrancando`. `id`/`cwd` los provee el host. + pub fn nueva(id: impl Into, cwd: impl Into, ahora: u64) -> Self { + Self { + id: id.into(), + claude_session_id: None, + titulo: String::new(), + cwd: cwd.into(), + modelo: None, + estado: EstadoSesion::Arrancando, + turnos: Vec::new(), + creada: ahora, + actualizada: ahora, + sin_leer: 0, + pide_algo: false, + } + } + + /// Registra el mensaje del usuario que abre un turno y marca la sesión + /// `Corriendo`. El host, en paralelo, spawnea `claude` (`--resume` si ya + /// hay `claude_session_id`) y va empujando su salida con + /// [`Self::aplicar_linea`]. + pub fn enviar(&mut self, texto: impl Into, ts: u64) { + let texto = texto.into(); + if self.titulo.trim().is_empty() { + self.titulo = derivar_titulo(&texto); + } + self.turnos.push(Turno { + rol: Rol::Usuario, + etapas: vec![Etapa::Texto(texto)], + uso: None, + ts, + }); + self.estado = EstadoSesion::Corriendo; + self.actualizada = ts; + } + + /// Marca todo como visto: apaga el badge de sin-leer y de "pide algo". + pub fn marcar_leido(&mut self) { + self.sin_leer = 0; + self.pide_algo = false; + } + + /// El badge del tab, derivado del estado. Prioridad: pide-algo → corriendo + /// → sin-leer → nada. + pub fn atencion(&self) -> Atencion { + if self.pide_algo { + return Atencion::PideAlgo; + } + match self.estado { + EstadoSesion::Corriendo | EstadoSesion::Arrancando => Atencion::Corriendo, + _ if self.sin_leer > 0 => Atencion::SinLeer(self.sin_leer), + _ => Atencion::Nada, + } + } + + /// Aplica una línea NDJSON del stream-json de `claude`. Ignora en silencio + /// lo que no modela (status, stream_event de deltas, rate_limit_event, JSON + /// inválido) — defensivo por diseño. Devuelve los [`Cambio`]s para + /// retransmitir en vivo. + pub fn aplicar_linea(&mut self, linea: &str, ts: u64) -> Vec { + match parse_evento(linea) { + Some(ev) => self.aplicar(ev, ts), + None => Vec::new(), + } + } + + fn aplicar(&mut self, ev: Evento, ts: u64) -> Vec { + self.actualizada = ts; + match ev { + Evento::Init { session_id, model, cwd } => { + if !session_id.is_empty() { + self.claude_session_id = Some(session_id); + } + if self.modelo.is_none() { + self.modelo = model; + } + if let Some(c) = cwd { + if self.cwd.is_empty() { + self.cwd = c; + } + } + if matches!(self.estado, EstadoSesion::Arrancando) { + self.estado = EstadoSesion::Corriendo; + } + vec![Cambio::Meta] + } + Evento::Asistente(bloques) => { + let mut cambios = Vec::new(); + for b in bloques { + let etapa = match b { + Bloque::Pensamiento(t) if t.trim().is_empty() => continue, + Bloque::Pensamiento(t) => Etapa::Pensamiento(t), + Bloque::Texto(t) => Etapa::Texto(t), + Bloque::Herramienta { id, nombre, input } => Etapa::Herramienta(Herramienta { + resumen: resumen_herramienta(&nombre, &input), + tool_id: id, + nombre, + input_json: input.to_string(), + resultado: None, + estado: EstadoHerramienta::EnCurso, + }), + }; + if let Some(c) = self.empujar_etapa(etapa, ts) { + cambios.push(c); + } + } + cambios + } + Evento::Usuario(resultados) => { + let mut cambios = Vec::new(); + for r in resultados { + if let Some(c) = self.completar_herramienta(&r) { + cambios.push(c); + } + } + cambios + } + Evento::Fin { is_error, texto, uso, session_id } => { + if let Some(sid) = session_id { + if !sid.is_empty() { + self.claude_session_id = Some(sid); + } + } + // Fija el uso en el turno del asistente abierto. + if let Some(t) = self.turno_asistente_mut() { + t.uso = uso.filter(Uso::hay); + } + if is_error { + let msg = texto.unwrap_or_else(|| "el turno falló".to_string()); + // Deja rastro visible en el turno. + let _ = self.empujar_etapa(Etapa::Error(msg.clone()), ts); + self.estado = EstadoSesion::Fallida(msg); + } else { + self.estado = EstadoSesion::Idle; + } + vec![Cambio::Fin { uso: uso.filter(Uso::hay) }] + } + } + } + + /// Empuja una etapa al turno del asistente abierto (lo crea si el último + /// turno no es del asistente). Cuenta como no-leída. + fn empujar_etapa(&mut self, etapa: Etapa, ts: u64) -> Option { + let abrir = !matches!(self.turnos.last(), Some(t) if t.rol == Rol::Asistente); + if abrir { + self.turnos.push(Turno { + rol: Rol::Asistente, + etapas: Vec::new(), + uso: None, + ts, + }); + } + let turno = self.turnos.len() - 1; + let t = self.turnos.last_mut()?; + t.etapas.push(etapa); + let idx = t.etapas.len() - 1; + self.sin_leer = self.sin_leer.saturating_add(1); + Some(Cambio::Etapa { turno, idx }) + } + + /// Cierra una herramienta `EnCurso` con su `tool_result` (busca por + /// `tool_use_id` de atrás para adelante). + fn completar_herramienta(&mut self, r: &ResultadoHerramienta) -> Option { + for (ti, turno) in self.turnos.iter_mut().enumerate().rev() { + for (ei, etapa) in turno.etapas.iter_mut().enumerate() { + if let Etapa::Herramienta(h) = etapa { + if h.tool_id == r.tool_use_id { + h.resultado = Some(r.contenido.clone()); + h.estado = if r.es_error { + EstadoHerramienta::Error + } else { + EstadoHerramienta::Ok + }; + self.sin_leer = self.sin_leer.saturating_add(1); + return Some(Cambio::Etapa { turno: ti, idx: ei }); + } + } + } + } + None + } + + /// El turno del asistente abierto (el último, si es suyo). + fn turno_asistente_mut(&mut self) -> Option<&mut Turno> { + match self.turnos.last_mut() { + Some(t) if t.rol == Rol::Asistente => Some(t), + _ => None, + } + } +} + +// ─────────────────────── Parseo del stream-json ──────────────────── +// Modelo mínimo de los eventos que nos importan del `--output-format +// stream-json --verbose` de Claude Code. Todo lo demás (status, stream_event +// de deltas incrementales, rate_limit_event) se ignora: los bloques completos +// del evento `assistant` bastan para las etapas. + +enum Evento { + Init { + session_id: String, + model: Option, + cwd: Option, + }, + Asistente(Vec), + Usuario(Vec), + Fin { + is_error: bool, + texto: Option, + uso: Option, + session_id: Option, + }, +} + +enum Bloque { + Pensamiento(String), + Herramienta { + id: String, + nombre: String, + input: serde_json::Value, + }, + Texto(String), +} + +struct ResultadoHerramienta { + tool_use_id: String, + contenido: String, + es_error: bool, +} + +fn parse_evento(linea: &str) -> Option { + let linea = linea.trim(); + if linea.is_empty() { + return None; + } + let v: serde_json::Value = serde_json::from_str(linea).ok()?; + match v.get("type").and_then(|t| t.as_str())? { + "system" if v.get("subtype").and_then(|s| s.as_str()) == Some("init") => Some(Evento::Init { + session_id: v.get("session_id").and_then(|s| s.as_str()).unwrap_or("").to_string(), + model: v.get("model").and_then(|s| s.as_str()).map(str::to_string), + cwd: v.get("cwd").and_then(|s| s.as_str()).map(str::to_string), + }), + "assistant" => { + let content = v.get("message").and_then(|m| m.get("content")).and_then(|c| c.as_array())?; + let bloques = content.iter().filter_map(parse_bloque).collect(); + Some(Evento::Asistente(bloques)) + } + "user" => { + let content = v.get("message").and_then(|m| m.get("content")).and_then(|c| c.as_array())?; + let resultados = content.iter().filter_map(parse_tool_result).collect(); + Some(Evento::Usuario(resultados)) + } + "result" => Some(Evento::Fin { + is_error: v.get("is_error").and_then(|b| b.as_bool()).unwrap_or(false), + texto: v.get("result").and_then(|r| r.as_str()).map(str::to_string), + uso: v.get("usage").map(parse_uso), + session_id: v.get("session_id").and_then(|s| s.as_str()).map(str::to_string), + }), + _ => None, + } +} + +fn parse_bloque(b: &serde_json::Value) -> Option { + match b.get("type").and_then(|t| t.as_str())? { + "thinking" => Some(Bloque::Pensamiento( + b.get("thinking").and_then(|t| t.as_str()).unwrap_or("").to_string(), + )), + "text" => Some(Bloque::Texto( + b.get("text").and_then(|t| t.as_str()).unwrap_or("").to_string(), + )), + "tool_use" => Some(Bloque::Herramienta { + id: b.get("id").and_then(|i| i.as_str()).unwrap_or("").to_string(), + nombre: b.get("name").and_then(|n| n.as_str()).unwrap_or("").to_string(), + input: b.get("input").cloned().unwrap_or(serde_json::Value::Null), + }), + _ => None, + } +} + +fn parse_tool_result(b: &serde_json::Value) -> Option { + if b.get("type").and_then(|t| t.as_str()) != Some("tool_result") { + return None; + } + Some(ResultadoHerramienta { + tool_use_id: b.get("tool_use_id").and_then(|i| i.as_str()).unwrap_or("").to_string(), + contenido: contenido_a_texto(b.get("content")), + es_error: b.get("is_error").and_then(|e| e.as_bool()).unwrap_or(false), + }) +} + +/// El `content` de un `tool_result` puede ser un string o un array de bloques +/// `{type:"text",text:…}`. Lo aplana a texto. +fn contenido_a_texto(c: Option<&serde_json::Value>) -> String { + match c { + Some(serde_json::Value::String(s)) => s.clone(), + Some(serde_json::Value::Array(a)) => a + .iter() + .filter_map(|x| x.get("text").and_then(|t| t.as_str())) + .collect::>() + .join("\n"), + _ => String::new(), + } +} + +fn parse_uso(u: &serde_json::Value) -> Uso { + let g = |k: &str| u.get(k).and_then(|x| x.as_u64()).unwrap_or(0) as u32; + Uso { + entrada: g("input_tokens"), + salida: g("output_tokens"), + } +} + +/// One-liner para la fila colapsada de una herramienta: el argumento más +/// significativo según el nombre, o el primer campo string del input. +fn resumen_herramienta(nombre: &str, input: &serde_json::Value) -> String { + let s = |k: &str| input.get(k).and_then(|v| v.as_str()); + let r = match nombre { + "Bash" => s("command"), + "Read" | "Write" | "Edit" | "NotebookEdit" => s("file_path"), + "Grep" => s("pattern"), + "Glob" => s("pattern"), + "WebFetch" | "WebSearch" => s("url").or_else(|| s("query")), + "Task" => s("description"), + _ => None, + }; + r.map(str::to_string).unwrap_or_else(|| { + input + .as_object() + .and_then(|o| o.values().find_map(|v| v.as_str())) + .unwrap_or("") + .to_string() + }) +} + +/// Título corto de la primera línea (hasta ~6 palabras). +fn derivar_titulo(texto: &str) -> String { + let limpio = texto.trim().lines().next().unwrap_or("").trim(); + let recorte: String = limpio.split_whitespace().take(6).collect::>().join(" "); + if recorte.is_empty() { + "Consola".to_string() + } else if recorte.chars().count() < limpio.chars().count() { + format!("{recorte}…") + } else { + recorte + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn titulo_se_deriva_del_primer_mensaje() { + let mut s = Sesion::nueva("s1", "/tmp", 0); + s.enviar("arregla el bug de layout en pata", 10); + assert_eq!(s.titulo, "arregla el bug de layout en…"); + assert_eq!(s.estado, EstadoSesion::Corriendo); + } + + #[test] + fn resumen_de_bash_es_el_comando() { + let input = serde_json::json!({"command": "echo hola", "description": "x"}); + assert_eq!(resumen_herramienta("Bash", &input), "echo hola"); + let input = serde_json::json!({"file_path": "/a/b.rs"}); + assert_eq!(resumen_herramienta("Read", &input), "/a/b.rs"); + } + + #[test] + fn atencion_prioriza_corriendo_luego_sin_leer() { + let mut s = Sesion::nueva("s1", "/tmp", 0); + assert_eq!(s.atencion(), Atencion::Corriendo); // Arrancando + s.estado = EstadoSesion::Idle; + s.sin_leer = 3; + assert_eq!(s.atencion(), Atencion::SinLeer(3)); + s.pide_algo = true; + assert_eq!(s.atencion(), Atencion::PideAlgo); + s.marcar_leido(); + assert_eq!(s.atencion(), Atencion::Nada); + } + + #[test] + fn tool_use_y_su_result_se_enlazan_por_id() { + let mut s = Sesion::nueva("s1", "/tmp", 0); + s.enviar("corre echo", 0); + let asis = serde_json::json!({ + "type": "assistant", + "message": {"role": "assistant", "content": [ + {"type": "tool_use", "id": "toolu_1", "name": "Bash", + "input": {"command": "echo hi"}} + ]} + }) + .to_string(); + let cambios = s.aplicar_linea(&asis, 1); + assert_eq!(cambios.len(), 1); + // Herramienta en curso, sin resultado. + let user = serde_json::json!({ + "type": "user", + "message": {"role": "user", "content": [ + {"type": "tool_result", "tool_use_id": "toolu_1", + "content": "hi", "is_error": false} + ]} + }) + .to_string(); + s.aplicar_linea(&user, 2); + let Etapa::Herramienta(h) = &s.turnos.last().unwrap().etapas[0] else { + panic!("esperaba una herramienta"); + }; + assert_eq!(h.estado, EstadoHerramienta::Ok); + assert_eq!(h.resultado.as_deref(), Some("hi")); + } + + #[test] + fn sesion_cruza_postcard() { + // El transporte del daemon es u32+postcard (no self-describing): la + // Sesion NO puede llevar serde_json::Value. Con input_json:String, + // round-trip exacto. + let mut s = Sesion::nueva("s1", "/tmp", 0); + s.enviar("corre echo", 0); + s.aplicar_linea( + &serde_json::json!({ + "type": "assistant", + "message": {"role": "assistant", "content": [ + {"type": "tool_use", "id": "t1", "name": "Bash", + "input": {"command": "echo hi"}} + ]} + }) + .to_string(), + 1, + ); + let bytes = postcard::to_allocvec(&s).expect("serializa"); + let back: Sesion = postcard::from_bytes(&bytes).expect("deserializa"); + assert_eq!(s, back); + } + + #[test] + fn lineas_no_modeladas_se_ignoran() { + let mut s = Sesion::nueva("s1", "/tmp", 0); + assert!(s.aplicar_linea("no es json", 0).is_empty()); + assert!(s.aplicar_linea(r#"{"type":"stream_event","event":{}}"#, 0).is_empty()); + assert!(s.aplicar_linea(r#"{"type":"system","subtype":"status"}"#, 0).is_empty()); + } +} diff --git a/02_ruway/shuma/sandbox/shuma-consola-core/tests/fixtures/turno_bash.jsonl b/02_ruway/shuma/sandbox/shuma-consola-core/tests/fixtures/turno_bash.jsonl new file mode 100644 index 0000000..f8130de --- /dev/null +++ b/02_ruway/shuma/sandbox/shuma-consola-core/tests/fixtures/turno_bash.jsonl @@ -0,0 +1,35 @@ +{"type":"system","subtype":"init","cwd":"/tmp/claude-1000/-home-sergio-tawasuyu/2c7ac347-8716-4062-b7c2-6ecbfbc7eeb2/scratchpad/fixcap","session_id":"44454608-b34c-4d38-b9be-a843482dfdb9","tools":["Task","Bash","CronCreate","CronDelete","CronList","DesignSync","Edit","EnterWorktree","ExitWorktree","LSP","NotebookEdit","Read","ReportFindings","ScheduleWakeup","SendMessage","Skill","TaskCreate","TaskGet","TaskList","TaskOutput","TaskStop","TaskUpdate","ToolSearch","WebFetch","WebSearch","Workflow","Write"],"mcp_servers":[],"model":"claude-fable-5","permissionMode":"bypassPermissions","slash_commands":["deep-research","design-sync","dataviz","update-config","verify","debug","code-review","simplify","batch","fewer-permission-prompts","doctor","loop","claude-api","run","run-skill-generator","agents","clear","color","compact","config","context","effort","fast","heapdump","init","mcp","model","__remote-workflow","reload-skills","rename","review","security-review","usage-credits","extra-usage","usage","insights","recap","goal","design","design-consent","design-revoke","team-onboarding"],"apiKeySource":"none","claude_code_version":"2.1.205","output_style":"default","agents":["claude","Explore","general-purpose","Plan","statusline-setup"],"skills":["deep-research","design-sync","dataviz","update-config","verify","debug","code-review","simplify","batch","fewer-permission-prompts","doctor","loop","claude-api","run","run-skill-generator"],"plugins":[{"name":"rust-analyzer-lsp","path":"/home/sergio/.claude/plugins/cache/claude-plugins-official/rust-analyzer-lsp/1.0.0","source":"rust-analyzer-lsp@claude-plugins-official"},{"name":"clangd-lsp","path":"/home/sergio/.claude/plugins/cache/claude-plugins-official/clangd-lsp/1.0.0","source":"clangd-lsp@claude-plugins-official"}],"capabilities":["interrupt_receipt_v1"],"analytics_disabled":false,"product_feedback_disabled":false,"uuid":"e2056c7a-39f3-4dbe-86b8-a627b1d83a27","memory_paths":{"auto":"/home/sergio/.claude/projects/-tmp-claude-1000--home-sergio-tawasuyu-2c7ac347-8716-4062-b7c2-6ecbfbc7eeb2-scratchpad-fixcap/memory/"},"fast_mode_state":"off"} +{"type":"system","subtype":"status","status":"requesting","uuid":"4a671d96-38c2-4eca-b8ef-800c4f7d6419","session_id":"44454608-b34c-4d38-b9be-a843482dfdb9"} +{"type":"rate_limit_event","rate_limit_info":{"status":"allowed","resetsAt":1783641600,"rateLimitType":"five_hour","overageStatus":"rejected","overageDisabledReason":"org_level_disabled","isUsingOverage":false},"uuid":"7577822f-ac4c-437c-b264-2fc1d079daf9","session_id":"44454608-b34c-4d38-b9be-a843482dfdb9"} +{"type":"stream_event","event":{"type":"message_start","message":{"model":"claude-fable-5","id":"msg_011CcsJsg5Wp94K6Ds2MNJkU","type":"message","role":"assistant","content":[],"stop_reason":null,"stop_sequence":null,"stop_details":null,"usage":{"input_tokens":2808,"cache_creation_input_tokens":3196,"cache_read_input_tokens":13652,"cache_creation":{"ephemeral_5m_input_tokens":0,"ephemeral_1h_input_tokens":3196},"output_tokens":3,"service_tier":"standard","inference_geo":"not_available"}}},"session_id":"44454608-b34c-4d38-b9be-a843482dfdb9","parent_tool_use_id":null,"uuid":"16fae3b2-6166-436b-afde-69bfdc93e162","ttft_ms":11687} +{"type":"stream_event","event":{"type":"content_block_start","index":0,"content_block":{"type":"thinking","thinking":"","signature":""}},"session_id":"44454608-b34c-4d38-b9be-a843482dfdb9","parent_tool_use_id":null,"uuid":"fb5ffc58-7947-4031-9ca7-1d04cb86a0f9"} +{"type":"stream_event","event":{"type":"content_block_delta","index":0,"delta":{"type":"signature_delta","signature":"CAISjgIKiAEIDxgCKkBby339bqB2UO0MB2H8+TXo9mbGYyQDhXEvBA82Ie99DkcXson/IhzrHa9xeZmx3qyUkCM5tAnZ+ewtlXoDc+UzMg5jbGF1ZGUtZmFibGUtNTgBQgh0aGlua2luZ1okYTNmMzlmNzEtNDI0Zi00ZjdlLWJlZWEtZDg2ZWFmNWE2OWZiEgwP0Z2PFj8YbNfYFxsaDLfdXq37R232uFaCtiIwJ0+PmE6hwE3qdantKxJ2kj+Sl8tshHNacyGW8VZpglYhuCAN0l3WAQiSVeIR6sIqKjMEYmIKknPw8H7zL95MjnS2u5fxEfCe9edB/8cjxwKhQ9pOeLFVHPDAtz2+F4I9M6OeEbIYAQ=="}},"session_id":"44454608-b34c-4d38-b9be-a843482dfdb9","parent_tool_use_id":null,"uuid":"daaf533c-01b4-472c-b8b6-e4c40ac61dd3"} +{"type":"assistant","message":{"model":"claude-fable-5","id":"msg_011CcsJsg5Wp94K6Ds2MNJkU","type":"message","role":"assistant","content":[{"type":"thinking","thinking":"","signature":"CAISjgIKiAEIDxgCKkBby339bqB2UO0MB2H8+TXo9mbGYyQDhXEvBA82Ie99DkcXson/IhzrHa9xeZmx3qyUkCM5tAnZ+ewtlXoDc+UzMg5jbGF1ZGUtZmFibGUtNTgBQgh0aGlua2luZ1okYTNmMzlmNzEtNDI0Zi00ZjdlLWJlZWEtZDg2ZWFmNWE2OWZiEgwP0Z2PFj8YbNfYFxsaDLfdXq37R232uFaCtiIwJ0+PmE6hwE3qdantKxJ2kj+Sl8tshHNacyGW8VZpglYhuCAN0l3WAQiSVeIR6sIqKjMEYmIKknPw8H7zL95MjnS2u5fxEfCe9edB/8cjxwKhQ9pOeLFVHPDAtz2+F4I9M6OeEbIYAQ=="}],"stop_reason":null,"stop_sequence":null,"stop_details":null,"usage":{"input_tokens":2808,"cache_creation_input_tokens":3196,"cache_read_input_tokens":13652,"cache_creation":{"ephemeral_5m_input_tokens":0,"ephemeral_1h_input_tokens":3196},"output_tokens":3,"service_tier":"standard","inference_geo":"not_available"},"context_management":null},"parent_tool_use_id":null,"session_id":"44454608-b34c-4d38-b9be-a843482dfdb9","uuid":"15d951f0-d421-4326-8252-2f7e920e14d5","request_id":"req_011CcsJsewYnbUGJFAJ61J7v"} +{"type":"stream_event","event":{"type":"content_block_stop","index":0},"session_id":"44454608-b34c-4d38-b9be-a843482dfdb9","parent_tool_use_id":null,"uuid":"39eb659b-9f07-465f-8d19-2d3576c56154"} +{"type":"stream_event","event":{"type":"content_block_start","index":1,"content_block":{"type":"tool_use","id":"toolu_018dZLztpizDrfsEeF7yttJA","name":"Bash","input":{},"caller":{"type":"direct"}}},"session_id":"44454608-b34c-4d38-b9be-a843482dfdb9","parent_tool_use_id":null,"uuid":"d9ac5f5c-5bfb-42cd-9a55-b5d0b30f4c9e"} +{"type":"stream_event","event":{"type":"content_block_delta","index":1,"delta":{"type":"input_json_delta","partial_json":""}},"session_id":"44454608-b34c-4d38-b9be-a843482dfdb9","parent_tool_use_id":null,"uuid":"8ccb7381-058d-4d0a-aa8f-a6ffab161786"} +{"type":"stream_event","event":{"type":"content_block_delta","index":1,"delta":{"type":"input_json_delta","partial_json":"{\"co"}},"session_id":"44454608-b34c-4d38-b9be-a843482dfdb9","parent_tool_use_id":null,"uuid":"4c0c8902-5e99-4f24-98ea-e47f45c96981"} +{"type":"stream_event","event":{"type":"content_block_delta","index":1,"delta":{"type":"input_json_delta","partial_json":"mmand\": "}},"session_id":"44454608-b34c-4d38-b9be-a843482dfdb9","parent_tool_use_id":null,"uuid":"adff6a42-3382-488e-9286-4acceac7b129"} +{"type":"stream_event","event":{"type":"content_block_delta","index":1,"delta":{"type":"input_json_delta","partial_json":"\"echo "}},"session_id":"44454608-b34c-4d38-b9be-a843482dfdb9","parent_tool_use_id":null,"uuid":"7af09c51-77e2-4deb-988e-7282e48ff190"} +{"type":"stream_event","event":{"type":"content_block_delta","index":1,"delta":{"type":"input_json_delta","partial_json":"hola-fixture"}},"session_id":"44454608-b34c-4d38-b9be-a843482dfdb9","parent_tool_use_id":null,"uuid":"fcf6515c-d227-453b-b546-28f9ff0d1ff3"} +{"type":"stream_event","event":{"type":"content_block_delta","index":1,"delta":{"type":"input_json_delta","partial_json":"-42\""}},"session_id":"44454608-b34c-4d38-b9be-a843482dfdb9","parent_tool_use_id":null,"uuid":"43f50b93-dc7f-4ed6-8593-eb94dcc42a4f"} +{"type":"stream_event","event":{"type":"content_block_delta","index":1,"delta":{"type":"input_json_delta","partial_json":", \"descr"}},"session_id":"44454608-b34c-4d38-b9be-a843482dfdb9","parent_tool_use_id":null,"uuid":"b37fe0ff-6d6c-4c3a-84ab-c2390e0b4830"} +{"type":"stream_event","event":{"type":"content_block_delta","index":1,"delta":{"type":"input_json_delta","partial_json":"iption\": \""}},"session_id":"44454608-b34c-4d38-b9be-a843482dfdb9","parent_tool_use_id":null,"uuid":"a400cb7d-e81b-46b4-ae6c-17eb5abb6258"} +{"type":"stream_event","event":{"type":"content_block_delta","index":1,"delta":{"type":"input_json_delta","partial_json":"Print hola"}},"session_id":"44454608-b34c-4d38-b9be-a843482dfdb9","parent_tool_use_id":null,"uuid":"7900cc14-54a5-4e16-9517-550ff781f3a0"} +{"type":"stream_event","event":{"type":"content_block_delta","index":1,"delta":{"type":"input_json_delta","partial_json":"-fixture-"}},"session_id":"44454608-b34c-4d38-b9be-a843482dfdb9","parent_tool_use_id":null,"uuid":"26c4a648-38d5-4b67-8feb-778e2a13ddc6"} +{"type":"stream_event","event":{"type":"content_block_delta","index":1,"delta":{"type":"input_json_delta","partial_json":"42\"}"}},"session_id":"44454608-b34c-4d38-b9be-a843482dfdb9","parent_tool_use_id":null,"uuid":"90829d74-a140-4816-a88d-07907ab6acb5"} +{"type":"assistant","message":{"model":"claude-fable-5","id":"msg_011CcsJsg5Wp94K6Ds2MNJkU","type":"message","role":"assistant","content":[{"type":"tool_use","id":"toolu_018dZLztpizDrfsEeF7yttJA","name":"Bash","input":{"command":"echo hola-fixture-42","description":"Print hola-fixture-42"},"caller":{"type":"direct"}}],"stop_reason":null,"stop_sequence":null,"stop_details":null,"usage":{"input_tokens":2808,"cache_creation_input_tokens":3196,"cache_read_input_tokens":13652,"cache_creation":{"ephemeral_5m_input_tokens":0,"ephemeral_1h_input_tokens":3196},"output_tokens":3,"service_tier":"standard","inference_geo":"not_available"},"context_management":null},"parent_tool_use_id":null,"session_id":"44454608-b34c-4d38-b9be-a843482dfdb9","uuid":"4f093532-a786-459f-8748-d16f45a81c36","request_id":"req_011CcsJsewYnbUGJFAJ61J7v"} +{"type":"stream_event","event":{"type":"content_block_stop","index":1},"session_id":"44454608-b34c-4d38-b9be-a843482dfdb9","parent_tool_use_id":null,"uuid":"0cb64b28-da49-446d-895d-ad440198d53f"} +{"type":"stream_event","event":{"type":"message_delta","delta":{"stop_reason":"tool_use","stop_sequence":null,"stop_details":null},"usage":{"input_tokens":2808,"cache_creation_input_tokens":3196,"cache_read_input_tokens":13652,"output_tokens":99,"output_tokens_details":{"thinking_tokens":12},"iterations":[{"input_tokens":2808,"output_tokens":99,"cache_read_input_tokens":13652,"cache_creation_input_tokens":3196,"cache_creation":{"ephemeral_5m_input_tokens":0,"ephemeral_1h_input_tokens":3196},"type":"message"}]},"context_management":{"applied_edits":[]}},"session_id":"44454608-b34c-4d38-b9be-a843482dfdb9","parent_tool_use_id":null,"uuid":"93f03c47-21b0-4e28-ab0b-1a7ee7984160"} +{"type":"stream_event","event":{"type":"message_stop"},"session_id":"44454608-b34c-4d38-b9be-a843482dfdb9","parent_tool_use_id":null,"uuid":"31740094-0d37-4cb9-85c2-393ff05dbb90"} +{"type":"user","message":{"role":"user","content":[{"tool_use_id":"toolu_018dZLztpizDrfsEeF7yttJA","type":"tool_result","content":"hola-fixture-42","is_error":false}]},"parent_tool_use_id":null,"session_id":"44454608-b34c-4d38-b9be-a843482dfdb9","uuid":"824a324e-59b5-43a9-93ac-acdd9b64a005","timestamp":"2026-07-09T22:56:13.835Z","tool_use_result":{"stdout":"hola-fixture-42","stderr":"","interrupted":false,"isImage":false,"noOutputExpected":false}} +{"type":"system","subtype":"status","status":"requesting","uuid":"41ee7377-4388-4979-b758-f644c8d21885","session_id":"44454608-b34c-4d38-b9be-a843482dfdb9"} +{"type":"stream_event","event":{"type":"message_start","message":{"model":"claude-fable-5","id":"msg_011CcsJtD3bVJi1W4JB9xmnc","type":"message","role":"assistant","content":[],"stop_reason":null,"stop_sequence":null,"stop_details":null,"usage":{"input_tokens":2,"cache_creation_input_tokens":2918,"cache_read_input_tokens":16848,"cache_creation":{"ephemeral_5m_input_tokens":0,"ephemeral_1h_input_tokens":2918},"output_tokens":1,"service_tier":"standard","inference_geo":"not_available"}}},"session_id":"44454608-b34c-4d38-b9be-a843482dfdb9","parent_tool_use_id":null,"uuid":"ab718123-d303-4453-84fc-78ba82d26210","ttft_ms":6478} +{"type":"stream_event","event":{"type":"content_block_start","index":0,"content_block":{"type":"text","text":""}},"session_id":"44454608-b34c-4d38-b9be-a843482dfdb9","parent_tool_use_id":null,"uuid":"6b361bce-e74b-4ef8-8f10-735f923ec5d7"} +{"type":"stream_event","event":{"type":"content_block_delta","index":0,"delta":{"type":"text_delta","text":"El"}},"session_id":"44454608-b34c-4d38-b9be-a843482dfdb9","parent_tool_use_id":null,"uuid":"ee724504-b226-4cc6-b959-be2caf967b32"} +{"type":"stream_event","event":{"type":"content_block_delta","index":0,"delta":{"type":"text_delta","text":" comando imprimió `hola-fixture-42`."}},"session_id":"44454608-b34c-4d38-b9be-a843482dfdb9","parent_tool_use_id":null,"uuid":"2221bb9d-38b4-4750-9243-bb19c5af2187"} +{"type":"assistant","message":{"model":"claude-fable-5","id":"msg_011CcsJtD3bVJi1W4JB9xmnc","type":"message","role":"assistant","content":[{"type":"text","text":"El comando imprimió `hola-fixture-42`."}],"stop_reason":null,"stop_sequence":null,"stop_details":null,"usage":{"input_tokens":2,"cache_creation_input_tokens":2918,"cache_read_input_tokens":16848,"cache_creation":{"ephemeral_5m_input_tokens":0,"ephemeral_1h_input_tokens":2918},"output_tokens":1,"service_tier":"standard","inference_geo":"not_available"},"context_management":null},"parent_tool_use_id":null,"session_id":"44454608-b34c-4d38-b9be-a843482dfdb9","uuid":"ce742a3d-967d-494e-a44a-6f452a1b8a5c","request_id":"req_011CcsJtBoBpaRuRLyvfvkph"} +{"type":"stream_event","event":{"type":"content_block_stop","index":0},"session_id":"44454608-b34c-4d38-b9be-a843482dfdb9","parent_tool_use_id":null,"uuid":"26fecbe0-1001-49e1-b29d-579173d5d826"} +{"type":"stream_event","event":{"type":"message_delta","delta":{"stop_reason":"end_turn","stop_sequence":null,"stop_details":null},"usage":{"input_tokens":2,"cache_creation_input_tokens":2918,"cache_read_input_tokens":16848,"output_tokens":20,"output_tokens_details":{"thinking_tokens":0},"iterations":[{"input_tokens":2,"output_tokens":20,"cache_read_input_tokens":16848,"cache_creation_input_tokens":2918,"cache_creation":{"ephemeral_5m_input_tokens":0,"ephemeral_1h_input_tokens":2918},"type":"message"}]},"context_management":{"applied_edits":[]}},"session_id":"44454608-b34c-4d38-b9be-a843482dfdb9","parent_tool_use_id":null,"uuid":"1b8626b2-ff22-4c63-b136-77e29e91dab6"} +{"type":"stream_event","event":{"type":"message_stop"},"session_id":"44454608-b34c-4d38-b9be-a843482dfdb9","parent_tool_use_id":null,"uuid":"713f022e-c5bb-4382-934d-ca9430c836c9"} +{"type":"result","subtype":"success","is_error":false,"api_error_status":null,"duration_ms":20495,"duration_api_ms":28410,"ttft_ms":12900,"ttft_stream_ms":11715,"time_to_request_ms":28,"num_turns":2,"result":"El comando imprimió `hola-fixture-42`.","stop_reason":"end_turn","session_id":"44454608-b34c-4d38-b9be-a843482dfdb9","total_cost_usd":0.18746600000000002,"usage":{"input_tokens":2810,"cache_creation_input_tokens":6114,"cache_read_input_tokens":30500,"output_tokens":119,"server_tool_use":{"web_search_requests":0,"web_fetch_requests":0},"service_tier":"standard","cache_creation":{"ephemeral_1h_input_tokens":6114,"ephemeral_5m_input_tokens":0},"inference_geo":"not_available","iterations":[{"input_tokens":2,"output_tokens":20,"cache_read_input_tokens":16848,"cache_creation_input_tokens":2918,"cache_creation":{"ephemeral_5m_input_tokens":0,"ephemeral_1h_input_tokens":2918},"type":"message"}],"speed":"standard"},"modelUsage":{"claude-haiku-4-5-20251001":{"inputTokens":546,"outputTokens":18,"cacheReadInputTokens":0,"cacheCreationInputTokens":0,"webSearchRequests":0,"costUSD":0.0006360000000000001,"contextWindow":200000,"maxOutputTokens":32000},"claude-fable-5":{"inputTokens":2810,"outputTokens":119,"cacheReadInputTokens":30500,"cacheCreationInputTokens":6114,"webSearchRequests":0,"costUSD":0.18683,"contextWindow":1000000,"maxOutputTokens":64000}},"permission_denials":[],"terminal_reason":"completed","fast_mode_state":"off","uuid":"e0a53121-4eae-427d-9166-8ea6b881c296"} diff --git a/02_ruway/shuma/sandbox/shuma-consola-core/tests/reduce_real.rs b/02_ruway/shuma/sandbox/shuma-consola-core/tests/reduce_real.rs new file mode 100644 index 0000000..e4175ff --- /dev/null +++ b/02_ruway/shuma/sandbox/shuma-consola-core/tests/reduce_real.rs @@ -0,0 +1,68 @@ +//! Certificación contra un **fixture real** del CLI `claude`. +//! +//! `tests/fixtures/turno_bash.jsonl` se capturó de verdad con +//! `claude -p "…echo hola-fixture-42…" --output-format stream-json --verbose +//! --include-partial-messages` (un turno agéntico con una llamada a `Bash`). +//! No es inventado: reducir su schema real es lo único que prueba que el +//! parser habla el idioma que Claude Code de verdad emite. + +use shuma_consola_core::{Atencion, EstadoHerramienta, EstadoSesion, Etapa, Sesion}; + +const FIXTURE: &str = include_str!("fixtures/turno_bash.jsonl"); + +#[test] +fn reduce_un_turno_bash_real() { + let mut s = Sesion::nueva("s-test", "", 0); + s.enviar("Ejecuta el comando de shell 'echo hola-fixture-42' …", 1); + + // Reproduce el stream tal cual salió del CLI. + let mut ts = 2; + for linea in FIXTURE.lines() { + s.aplicar_linea(linea, ts); + ts += 1; + } + + // Aprendió el session_id de Claude Code (el handle de --resume) y el modelo. + assert_eq!( + s.claude_session_id.as_deref(), + Some("44454608-b34c-4d38-b9be-a843482dfdb9"), + "debía aprender el session_id del system/init" + ); + assert_eq!(s.modelo.as_deref(), Some("claude-fable-5")); + + // El turno cerró: Idle y esperando el próximo mensaje. + assert_eq!(s.estado, EstadoSesion::Idle); + assert_eq!(s.atencion(), Atencion::SinLeer(s.sin_leer)); + + // Debe existir la llamada Bash, resuelta OK con su salida real. + let herramienta = s + .turnos + .iter() + .flat_map(|t| &t.etapas) + .find_map(|e| match e { + Etapa::Herramienta(h) if h.nombre == "Bash" => Some(h), + _ => None, + }) + .expect("el turno tenía una llamada a Bash"); + assert_eq!(herramienta.resumen, "echo hola-fixture-42"); + assert_eq!(herramienta.estado, EstadoHerramienta::Ok); + assert_eq!(herramienta.resultado.as_deref(), Some("hola-fixture-42")); + + // Y el texto final visible con la respuesta. + let hay_texto_final = s + .turnos + .iter() + .flat_map(|t| &t.etapas) + .any(|e| matches!(e, Etapa::Texto(t) if t.contains("hola-fixture-42"))); + assert!(hay_texto_final, "faltó el texto final del asistente"); + + // El uso de tokens del turno quedó registrado (result: i=2810, o=119). + let uso = s + .turnos + .iter() + .rev() + .find_map(|t| t.uso) + .expect("el turno del asistente debía tener uso"); + assert_eq!(uso.salida, 119); + assert_eq!(uso.entrada, 2810); +} diff --git a/02_ruway/shuma/sandbox/shuma-consola-host/Cargo.toml b/02_ruway/shuma/sandbox/shuma-consola-host/Cargo.toml new file mode 100644 index 0000000..35c76a9 --- /dev/null +++ b/02_ruway/shuma/sandbox/shuma-consola-host/Cargo.toml @@ -0,0 +1,12 @@ +[package] +name = "shuma-consola-host" +version.workspace = true +edition.workspace = true +license.workspace = true +authors.workspace = true +publish.workspace = true +description = "shuma — registro de sesiones de consola vivas: mantiene N sesiones agénticas de Claude Code desacopladas de toda conexión (se adjuntan/desadjuntan sin matarlas), spawnea `claude -p --output-format stream-json [--resume]`, drena su salida al reducer de shuma-consola-core y transmite los Cambios por broadcast. Espeja el registro PTY del daemon, pero con frames estructurados. Runner de turno inyectable (real=claude / test=replay)." + +[dependencies] +shuma-consola-core = { path = "../shuma-consola-core" } +tokio = { workspace = true } diff --git a/02_ruway/shuma/sandbox/shuma-consola-host/LEEME.md b/02_ruway/shuma/sandbox/shuma-consola-host/LEEME.md new file mode 100644 index 0000000..0a3508d --- /dev/null +++ b/02_ruway/shuma/sandbox/shuma-consola-host/LEEME.md @@ -0,0 +1,26 @@ +# shuma-consola-host + +*Read this in English: [README.md](README.md).* + +El **registro de sesiones de consola vivas**. + +Mantiene N sesiones agénticas de **Claude Code** cuyo ciclo de vida está +**desacoplado de toda conexión** (exactamente como el registro PTY del +daemon, `shuma-daemon/src/pty_sessions.rs`): un cliente móvil se adjunta y +se desadjunta libremente sin matar nada. La sesión durable la posee Claude +Code; aquí vive su **estado reducido** (`shuma_consola_core::Sesion`) y la +maquinaria que corre cada turno. + +Un **turno** spawnea `claude -p --output-format stream-json +--verbose [--resume ]`; un hilo de drenado lee su stdout línea +a línea, la reduce con `Sesion::aplicar_linea` bajo lock, y transmite +cada `Cambio` por un `broadcast` a los clientes adjuntos. Entre turnos no +hay proceso: la sesión queda `Idle` y se reanuda con `--resume`. + +El **runner de turno es inyectable** (`TurnoRunner`): el real spawnea +`claude`; los tests reproducen NDJSON grabado — así el registro se +certifica entero (incluido el cableado de `--resume`) sin gastar cuota. + +--- + +Parte de **shuma** — ver [shuma](../../LEEME.md). diff --git a/02_ruway/shuma/sandbox/shuma-consola-host/README.md b/02_ruway/shuma/sandbox/shuma-consola-host/README.md new file mode 100644 index 0000000..3cbcd02 --- /dev/null +++ b/02_ruway/shuma/sandbox/shuma-consola-host/README.md @@ -0,0 +1,12 @@ +# shuma-consola-host + +The registry of live console sessions. + +It keeps N agentic Claude Code sessions whose lifecycle is **decoupled from any +connection** (exactly like the daemon's PTY registry, +`shuma-daemon/src/pty_sessions.rs`): a mobile client attaches and detaches freely +without killing anything. + +--- + +Part of **shuma** — see [shuma](../../README.md). diff --git a/02_ruway/shuma/sandbox/shuma-consola-host/examples/e2e_real.rs b/02_ruway/shuma/sandbox/shuma-consola-host/examples/e2e_real.rs new file mode 100644 index 0000000..b55beb1 --- /dev/null +++ b/02_ruway/shuma/sandbox/shuma-consola-host/examples/e2e_real.rs @@ -0,0 +1,73 @@ +//! Prueba e2e **real** contra el binario `claude` (gasta un poco de cuota — +//! no es un test hermético). Certifica el `ClaudeRunner` y, sobre todo, que +//! `--resume` continúa la MISMA sesión: el 2º turno recuerda lo del 1º. +//! +//! Correr: `cargo run -p shuma-consola-host --example e2e_real` + +use std::time::{Duration, Instant}; + +use shuma_consola_core::{EstadoSesion, Etapa}; +use shuma_consola_host::ConsolaRegistro; + +fn esperar_idle(reg: &ConsolaRegistro, id: &str) { + let hasta = Instant::now() + Duration::from_secs(90); + while Instant::now() < hasta { + // El proceso debe haber SALIDO (no sólo el `result`): recién ahí la + // sesión quedó persistida y es reanudable. + if !reg.turno_activo(id) { + if let Some(s) = reg.snapshot(id) { + if matches!(s.estado, EstadoSesion::Idle | EstadoSesion::Fallida(_)) { + return; + } + } + } + std::thread::sleep(Duration::from_millis(100)); + } + panic!("timeout esperando idle"); +} + +fn texto_final(reg: &ConsolaRegistro, id: &str) -> String { + let s = reg.snapshot(id).unwrap(); + s.turnos + .iter() + .rev() + .flat_map(|t| t.etapas.iter().rev()) + .find_map(|e| match e { + Etapa::Texto(t) => Some(t.clone()), + _ => None, + }) + .unwrap_or_default() +} + +fn main() { + let cwd = std::env::temp_dir().display().to_string(); + let reg = ConsolaRegistro::con_claude(); + + // Turno 1: pedile una palabra concreta. + let id = reg + .crear(&cwd, "Responde sólo con la palabra: banana. Nada más.", None) + .expect("crear (¿está `claude` en el PATH y logueado?)"); + esperar_idle(®, &id); + let sid = reg.snapshot(&id).unwrap().claude_session_id.clone(); + let r1 = texto_final(®, &id); + println!("── turno 1 ──"); + println!("session_id de claude: {sid:?}"); + println!("respuesta: {r1}"); + assert!(sid.is_some(), "debió aprender el session_id"); + + // Turno 2: reanuda (--resume) y prueba continuidad de contexto. + reg.enviar(&id, "¿Qué palabra te pedí recién? Responde sólo esa palabra.") + .expect("enviar"); + esperar_idle(®, &id); + let r2 = texto_final(®, &id); + println!("── turno 2 (--resume) ──"); + println!("respuesta: {r2}"); + + let ok = r2.to_lowercase().contains("banana"); + println!("\n{}", if ok { "✓ RESUME OK — recordó el contexto" } else { "✗ no recordó" }); + if !ok { + eprintln!("── DEBUG turnos ──\n{:#?}", reg.snapshot(&id).unwrap().turnos); + } + assert!(ok, "el 2º turno no recordó la palabra → --resume no continuó la sesión"); + println!("✓ e2e real verde: {} turnos, sesión reanudada", reg.snapshot(&id).unwrap().turnos.len()); +} diff --git a/02_ruway/shuma/sandbox/shuma-consola-host/src/lib.rs b/02_ruway/shuma/sandbox/shuma-consola-host/src/lib.rs new file mode 100644 index 0000000..189cd51 --- /dev/null +++ b/02_ruway/shuma/sandbox/shuma-consola-host/src/lib.rs @@ -0,0 +1,452 @@ +//! `shuma-consola-host` — el **registro de sesiones de consola vivas**. +//! +//! Mantiene N sesiones agénticas de **Claude Code** cuyo ciclo de vida está +//! **desacoplado de toda conexión** (exactamente como el registro PTY del +//! daemon, `shuma-daemon/src/pty_sessions.rs`): un cliente móvil se adjunta y +//! se desadjunta libremente sin matar nada. La sesión durable la posee Claude +//! Code; aquí vive su **estado reducido** ([`shuma_consola_core::Sesion`]) y la +//! maquinaria que corre cada turno. +//! +//! Un **turno** spawnea `claude -p --output-format stream-json +//! --verbose [--resume ]`; un hilo de drenado lee su stdout línea +//! a línea, la reduce con [`Sesion::aplicar_linea`] bajo lock, y transmite +//! cada [`Cambio`] por un `broadcast` a los clientes adjuntos. Entre turnos no +//! hay proceso: la sesión queda `Idle` y se reanuda con `--resume`. +//! +//! El **runner de turno es inyectable** ([`TurnoRunner`]): el real spawnea +//! `claude`; los tests reproducen NDJSON grabado — así el registro se +//! certifica entero (incluido el cableado de `--resume`) sin gastar cuota. + +#![forbid(unsafe_code)] + +use std::collections::HashMap; +use std::io::{self, BufRead, BufReader}; +use std::process::{Child, Command, Stdio}; +use std::sync::atomic::{AtomicU64, Ordering}; +use std::sync::{Arc, Mutex}; +use std::time::{SystemTime, UNIX_EPOCH}; + +use shuma_consola_core::{Atencion, Cambio, EstadoSesion, Sesion}; +use tokio::sync::broadcast; + +/// Capacidad del broadcast de `Cambio`s por sesión. +const BROADCAST_CAP: usize = 1024; + +// ─────────────────────────── Runner de turno ─────────────────────── + +/// Parámetros con los que se corre un turno. +#[derive(Clone, Debug)] +pub struct TurnoParams { + pub cwd: String, + pub prompt: String, + pub modelo: Option, + /// `session_id` de Claude Code a reanudar (`--resume`). `None` = turno + /// inicial de la sesión. + pub resume: Option, + /// Valor de `SHUMA_SESSION` en el entorno del proceso — enlaza los hooks + /// de Claude Code (`Notification`/`Stop`) con ESTA sesión (igual que el + /// registro PTY). + pub session_env: String, +} + +/// El resultado de arrancar un turno: un iterador **bloqueante** de líneas +/// NDJSON del stream-json + una forma de abortarlo. +pub struct LineasTurno { + pub lineas: Box + Send>, + pub matar: Box, +} + +/// Cómo se corre un turno. Real = spawnea `claude`; test = reproduce NDJSON. +pub trait TurnoRunner: Send + Sync + 'static { + fn correr(&self, params: TurnoParams) -> io::Result; +} + +/// Runner real: el binario `claude` (Claude Code) en modo stream-json. +pub struct ClaudeRunner { + /// Ruta/nombre del binario (`$CLAUDE_CLI_BIN` o `claude`). + pub bin: String, +} + +impl Default for ClaudeRunner { + fn default() -> Self { + Self { + bin: std::env::var("CLAUDE_CLI_BIN").unwrap_or_else(|_| "claude".to_string()), + } + } +} + +impl TurnoRunner for ClaudeRunner { + fn correr(&self, p: TurnoParams) -> io::Result { + let mut cmd = Command::new(&self.bin); + cmd.arg("-p") + .arg(&p.prompt) + .arg("--output-format") + .arg("stream-json") + .arg("--verbose"); + if let Some(m) = &p.modelo { + cmd.arg("--model").arg(m); + } + if let Some(r) = &p.resume { + cmd.arg("--resume").arg(r); + } + cmd.current_dir(&p.cwd) + .env("SHUMA_SESSION", &p.session_env) + .stdin(Stdio::null()) + .stdout(Stdio::piped()) + .stderr(Stdio::null()); + + let mut child = cmd.spawn()?; + let stdout = child + .stdout + .take() + .ok_or_else(|| io::Error::other("sin stdout de claude"))?; + let child = Arc::new(Mutex::new(child)); + + let matar = { + let c = Arc::clone(&child); + Box::new(move || { + if let Ok(mut ch) = c.lock() { + let _ = ch.kill(); + } + }) as Box + }; + + Ok(LineasTurno { + lineas: Box::new(LineasProc { + inner: BufReader::new(stdout).lines(), + child, + terminado: false, + }), + matar, + }) + } +} + +/// Iterador de líneas de un proceso que, al llegar a EOF, **reapea** el hijo +/// (evita zombies) antes de devolver `None`. +struct LineasProc { + inner: io::Lines>, + child: Arc>, + terminado: bool, +} + +impl Iterator for LineasProc { + type Item = String; + fn next(&mut self) -> Option { + match self.inner.next() { + Some(Ok(l)) => Some(l), + _ => { + if !self.terminado { + self.terminado = true; + if let Ok(mut ch) = self.child.lock() { + let _ = ch.wait(); + } + } + None + } + } + } +} + +// ─────────────────────────── Sesión viva ─────────────────────────── + +/// Estado mutable de una sesión bajo un único lock. +struct Interior { + sesion: Sesion, +} + +/// Una sesión de consola viva: su estado reducido + el broadcast de cambios + +/// el killer del turno en curso (si lo hay). +pub struct SesionViva { + shared: Arc>, + tx: broadcast::Sender, + killer: Mutex>>, +} + +impl SesionViva { + /// Copia del estado actual (el "scrollback" de la consola es la `Sesion` + /// entera — estructurada, no un ring de bytes). + pub fn snapshot(&self) -> Sesion { + self.shared.lock().expect("consola lock").sesion.clone() + } + + /// Se adjunta: devuelve el snapshot + un receiver de cambios en vivo. + /// **Atómico** respecto al drenado: se suscribe y saca el snapshot bajo el + /// mismo lock que el drenado toma para *aplicar+transmitir*, así ningún + /// `Cambio` se pierde ni se duplica en la frontera snapshot↔vivo. + pub fn attach(&self) -> (Sesion, broadcast::Receiver) { + let g = self.shared.lock().expect("consola lock"); + let rx = self.tx.subscribe(); + (g.sesion.clone(), rx) + } + + /// `true` si hay un turno **corriendo ahora** — no según el estado lógico + /// (que pasa a `Idle` en el evento `result`), sino según si el **proceso** + /// sigue vivo (el killer se limpia recién tras `child.wait()`). Es el gate + /// correcto para reanudar: Claude Code termina de escribir la sesión al + /// salir, y reanudar antes de eso da un turno vacío. + pub fn turno_activo(&self) -> bool { + self.killer.lock().expect("killer lock").is_some() + } + + fn marcar_leido(&self) { + self.shared.lock().expect("consola lock").sesion.marcar_leido(); + } + + fn avisar_pide_algo(&self) { + let mut g = self.shared.lock().expect("consola lock"); + g.sesion.pide_algo = true; + } +} + +// ────────────────────────── Registro global ──────────────────────── + +type Reloj = Arc u64 + Send + Sync>; + +/// Resumen de una sesión para la lista de tabs. +#[derive(Clone, Debug)] +pub struct ResumenSesion { + pub id: String, + pub titulo: String, + pub estado: EstadoSesion, + pub atencion: Atencion, + pub actualizada: u64, +} + +/// Registro de todas las sesiones de consola del daemon. +pub struct ConsolaRegistro { + sesiones: Mutex>>, + runner: Arc, + reloj: Reloj, + contador: AtomicU64, +} + +impl ConsolaRegistro { + /// Registro sobre un runner dado y el reloj del sistema. + pub fn new(runner: Arc) -> Self { + Self::con_reloj(runner, Arc::new(now_ms)) + } + + /// Registro sobre el binario `claude` real. + pub fn con_claude() -> Self { + Self::new(Arc::new(ClaudeRunner::default())) + } + + /// Registro con reloj inyectado (para tests deterministas). + pub fn con_reloj(runner: Arc, reloj: Reloj) -> Self { + Self { + sesiones: Mutex::new(HashMap::new()), + runner, + reloj, + contador: AtomicU64::new(1), + } + } + + fn nuevo_id(&self) -> String { + format!("consola-{}", self.contador.fetch_add(1, Ordering::Relaxed)) + } + + /// Crea una sesión y arranca su primer turno. Devuelve el id. + pub fn crear( + &self, + cwd: impl Into, + prompt: impl Into, + modelo: Option, + ) -> io::Result { + let id = self.nuevo_id(); + let cwd = cwd.into(); + let prompt = prompt.into(); + let ahora = (self.reloj)(); + + let mut sesion = Sesion::nueva(&id, &cwd, ahora); + sesion.modelo = modelo.clone(); + sesion.enviar(&prompt, ahora); + + let (tx, _) = broadcast::channel(BROADCAST_CAP); + let viva = Arc::new(SesionViva { + shared: Arc::new(Mutex::new(Interior { sesion })), + tx, + killer: Mutex::new(None), + }); + + self.correr_turno(&viva, cwd, prompt, modelo, None)?; + self.sesiones + .lock() + .expect("registro lock") + .insert(id.clone(), viva); + Ok(id) + } + + /// Manda un mensaje de seguimiento: reanuda la sesión con `--resume` y + /// arranca otro turno. `Ok(false)` si no existe; error si hay un turno en + /// curso. + pub fn enviar(&self, id: &str, prompt: impl Into) -> io::Result { + let viva = match self.sesiones.lock().expect("registro lock").get(id) { + Some(v) => Arc::clone(v), + None => return Ok(false), + }; + let prompt = prompt.into(); + + // Gate en el proceso, no en el estado lógico: el proceso del turno + // anterior tiene que haber SALIDO (killer limpio tras child.wait()) + // para que Claude Code haya terminado de persistir la sesión; reanudar + // antes da un turno vacío. + if viva.turno_activo() { + return Err(io::Error::new( + io::ErrorKind::WouldBlock, + "hay un turno en curso — espera a que termine", + )); + } + + let (cwd, modelo, resume); + { + let mut g = viva.shared.lock().expect("consola lock"); + let ahora = (self.reloj)(); + cwd = g.sesion.cwd.clone(); + modelo = g.sesion.modelo.clone(); + resume = g.sesion.claude_session_id.clone(); + g.sesion.enviar(&prompt, ahora); + let _ = viva.tx.send(Cambio::Estado(EstadoSesion::Corriendo)); + } + + self.correr_turno(&viva, cwd, prompt, modelo, resume)?; + Ok(true) + } + + /// Arranca el turno: pide líneas al runner y lanza el hilo de drenado. + fn correr_turno( + &self, + viva: &Arc, + cwd: String, + prompt: String, + modelo: Option, + resume: Option, + ) -> io::Result<()> { + let session_env = viva.snapshot().id; + let lt = self.runner.correr(TurnoParams { + cwd, + prompt, + modelo, + resume, + session_env, + })?; + *viva.killer.lock().expect("killer lock") = Some(lt.matar); + + let this = Arc::clone(viva); + let reloj = Arc::clone(&self.reloj); + std::thread::spawn(move || { + let dbg = std::env::var("CONSOLA_DEBUG").is_ok(); + let mut n = 0usize; + for linea in lt.lineas { + if dbg { n += 1; eprintln!("[drain {n}] {}", &linea[..linea.len().min(70)]); } + let ts = reloj(); + // Aplicar + transmitir bajo el mismo lock: ordena + // correctamente contra quien se adjunta (attach). + let mut g = this.shared.lock().expect("consola lock"); + for c in g.sesion.aplicar_linea(&linea, ts) { + let _ = this.tx.send(c); + } + } + // Fin del stream. Si no llegó `result` (proceso muerto/abortado), + // cerramos el turno igual para no dejarlo `Corriendo` para siempre. + { + let mut g = this.shared.lock().expect("consola lock"); + if matches!( + g.sesion.estado, + EstadoSesion::Corriendo | EstadoSesion::Arrancando + ) { + g.sesion.estado = EstadoSesion::Idle; + let _ = this.tx.send(Cambio::Estado(EstadoSesion::Idle)); + } + } + *this.killer.lock().expect("killer lock") = None; + }); + Ok(()) + } + + /// Se adjunta a una sesión (snapshot + receiver de cambios). + pub fn attach(&self, id: &str) -> Option<(Sesion, broadcast::Receiver)> { + self.sesiones + .lock() + .expect("registro lock") + .get(id) + .map(|v| v.attach()) + } + + /// Snapshot suelto de una sesión. + pub fn snapshot(&self, id: &str) -> Option { + self.sesiones + .lock() + .expect("registro lock") + .get(id) + .map(|v| v.snapshot()) + } + + /// `true` si la sesión tiene un turno con su proceso todavía vivo (gate + /// para poder reanudar / mandar el próximo mensaje). + pub fn turno_activo(&self, id: &str) -> bool { + self.sesiones + .lock() + .expect("registro lock") + .get(id) + .map(|v| v.turno_activo()) + .unwrap_or(false) + } + + /// Marca una sesión como vista (apaga sus badges). + pub fn marcar_leido(&self, id: &str) { + if let Some(v) = self.sesiones.lock().expect("registro lock").get(id) { + v.marcar_leido(); + } + } + + /// Prende el badge "pide algo" — lo llama el puente de hooks de Claude Code + /// (`Notification`) al recibir un aviso de esta sesión. + pub fn avisar_pide_algo(&self, id: &str) { + if let Some(v) = self.sesiones.lock().expect("registro lock").get(id) { + v.avisar_pide_algo(); + } + } + + /// Lista de tabs, ordenada por última actividad. + pub fn list(&self) -> Vec { + let map = self.sesiones.lock().expect("registro lock"); + let mut out: Vec = map + .values() + .map(|v| { + let s = v.snapshot(); + let atencion = s.atencion(); + ResumenSesion { + id: s.id, + titulo: s.titulo, + atencion, + estado: s.estado, + actualizada: s.actualizada, + } + }) + .collect(); + out.sort_by_key(|r| r.actualizada); + out + } + + /// Mata el turno en curso (si hay) y quita la sesión del registro. + pub fn kill(&self, id: &str) -> bool { + let removed = self.sesiones.lock().expect("registro lock").remove(id); + match removed { + Some(v) => { + if let Some(m) = v.killer.lock().expect("killer lock").as_ref() { + m(); + } + true + } + None => false, + } + } +} + +fn now_ms() -> u64 { + SystemTime::now() + .duration_since(UNIX_EPOCH) + .map(|d| d.as_millis() as u64) + .unwrap_or(0) +} diff --git a/02_ruway/shuma/sandbox/shuma-consola-host/tests/registro.rs b/02_ruway/shuma/sandbox/shuma-consola-host/tests/registro.rs new file mode 100644 index 0000000..740677e --- /dev/null +++ b/02_ruway/shuma/sandbox/shuma-consola-host/tests/registro.rs @@ -0,0 +1,196 @@ +//! Certificación del registro con un runner de **replay** (sin `claude`, sin +//! cuota) alimentado por el **fixture real** de `shuma-consola-core`. Prueba el +//! ciclo entero: crear → drenar → reducir → transmitir → adjuntar → reanudar. + +use std::collections::VecDeque; +use std::sync::atomic::{AtomicU64, Ordering}; +use std::sync::{Arc, Condvar, Mutex}; +use std::time::{Duration, Instant}; + +use shuma_consola_core::{Cambio, EstadoHerramienta, EstadoSesion, Etapa}; +use shuma_consola_host::{ConsolaRegistro, LineasTurno, TurnoParams, TurnoRunner}; + +const FIXTURE: &str = include_str!("../../shuma-consola-core/tests/fixtures/turno_bash.jsonl"); + +type Gate = Arc<(Mutex, Condvar)>; + +/// Runner que reproduce líneas grabadas y **anota** los params de cada turno. +/// Una compuerta opcional retiene el primer turno hasta que el test la abra +/// (para que el `attach` se suscriba antes de cualquier `Cambio`). +struct ReplayRunner { + turnos: Mutex>>, + llamadas: Arc>>, + gate: Mutex>, +} + +impl TurnoRunner for ReplayRunner { + fn correr(&self, params: TurnoParams) -> std::io::Result { + self.llamadas.lock().unwrap().push(params); + let lineas = self + .turnos + .lock() + .unwrap() + .pop_front() + .unwrap_or_default(); + let gate = self.gate.lock().unwrap().take(); + Ok(LineasTurno { + lineas: Box::new(GatedIter { + inner: lineas.into_iter(), + gate, + }), + matar: Box::new(|| {}), + }) + } +} + +/// Iterador que, en su primer `next`, espera a que se abra la compuerta. +struct GatedIter { + inner: std::vec::IntoIter, + gate: Option, +} + +impl Iterator for GatedIter { + type Item = String; + fn next(&mut self) -> Option { + if let Some(g) = self.gate.take() { + let (lock, cv) = &*g; + let mut abierta = lock.lock().unwrap(); + while !*abierta { + abierta = cv.wait(abierta).unwrap(); + } + } + self.inner.next() + } +} + +fn abrir(g: &Gate) { + let (lock, cv) = &**g; + *lock.lock().unwrap() = true; + cv.notify_all(); +} + +/// Espera (con timeout) a que se cumpla una condición sobre el registro. +fn esperar(cond: impl Fn() -> bool) { + let hasta = Instant::now() + Duration::from_secs(3); + while Instant::now() < hasta { + if cond() { + return; + } + std::thread::sleep(Duration::from_millis(5)); + } + panic!("timeout esperando la condición"); +} + +fn fixture_lineas() -> Vec { + FIXTURE.lines().map(str::to_string).collect() +} + +#[test] +fn ciclo_completo_crear_reducir_reanudar() { + let reloj_n = Arc::new(AtomicU64::new(1)); + let reloj = { + let n = Arc::clone(&reloj_n); + Arc::new(move || n.fetch_add(1, Ordering::Relaxed)) as Arc u64 + Send + Sync> + }; + let llamadas = Arc::new(Mutex::new(Vec::new())); + let gate: Gate = Arc::new((Mutex::new(false), Condvar::new())); + + let runner = Arc::new(ReplayRunner { + turnos: Mutex::new(VecDeque::from(vec![fixture_lineas(), fixture_lineas()])), + llamadas: Arc::clone(&llamadas), + gate: Mutex::new(Some(Arc::clone(&gate))), + }); + let reg = ConsolaRegistro::con_reloj(runner, reloj); + + // Crear la sesión: el primer turno queda retenido por la compuerta. + let id = reg + .crear("/tmp", "corre 'echo hola-fixture-42'", Some("claude-fable-5".into())) + .expect("crear"); + + // Adjuntarse ANTES de que fluya nada → el receiver ve todos los Cambios. + let (snap0, mut rx) = reg.attach(&id).expect("attach"); + assert_eq!(snap0.estado, EstadoSesion::Corriendo); + assert_eq!(snap0.turnos.len(), 1, "sólo el turno del usuario aún"); + + // Abrir la compuerta y esperar a que el turno cierre (proceso terminado). + abrir(&gate); + esperar(|| { + !reg.turno_activo(&id) + && reg.snapshot(&id).map(|s| s.estado == EstadoSesion::Idle).unwrap_or(false) + }); + + // El estado reducido llegó bien a través del registro. + let s = reg.snapshot(&id).unwrap(); + assert_eq!( + s.claude_session_id.as_deref(), + Some("44454608-b34c-4d38-b9be-a843482dfdb9") + ); + let herramienta = s + .turnos + .iter() + .flat_map(|t| &t.etapas) + .find_map(|e| match e { + Etapa::Herramienta(h) if h.nombre == "Bash" => Some(h), + _ => None, + }) + .expect("llamada Bash"); + assert_eq!(herramienta.estado, EstadoHerramienta::Ok); + assert_eq!(herramienta.resultado.as_deref(), Some("hola-fixture-42")); + + // El broadcast entregó los cambios en vivo: al menos una etapa nueva y el Fin. + let mut cambios = Vec::new(); + while let Ok(c) = rx.try_recv() { + cambios.push(c); + } + assert!( + cambios.iter().any(|c| matches!(c, Cambio::Etapa { .. })), + "el receiver debía ver etapas en vivo" + ); + assert!( + cambios.iter().any(|c| matches!(c, Cambio::Fin { .. })), + "el receiver debía ver el Fin del turno" + ); + + // Segundo turno: reanuda con --resume del session_id aprendido. + assert!(reg.enviar(&id, "y ahora lista el directorio").expect("enviar")); + esperar(|| { + // Vuelve a Idle tras el segundo turno (2 turnos de usuario + asistentes). + !reg.turno_activo(&id) + && reg + .snapshot(&id) + .map(|s| s.estado == EstadoSesion::Idle && s.turnos.len() >= 3) + .unwrap_or(false) + }); + + // Se certifican los params: turno 1 sin resume, turno 2 con el session_id. + let ll = llamadas.lock().unwrap(); + assert_eq!(ll.len(), 2, "dos turnos corridos"); + assert_eq!(ll[0].resume, None, "el primer turno no reanuda"); + assert_eq!( + ll[1].resume.as_deref(), + Some("44454608-b34c-4d38-b9be-a843482dfdb9"), + "el segundo turno reanuda con el session_id de Claude Code" + ); + // Y el SHUMA_SESSION lleva nuestro id (enlace de hooks). + assert_eq!(ll[1].session_env, id); +} + +#[test] +fn list_y_kill() { + let runner = Arc::new(ReplayRunner { + turnos: Mutex::new(VecDeque::from(vec![fixture_lineas()])), + llamadas: Arc::new(Mutex::new(Vec::new())), + gate: Mutex::new(None), + }); + let reg = ConsolaRegistro::new(runner); + let id = reg.crear("/tmp", "hola", None).expect("crear"); + esperar(|| reg.snapshot(&id).map(|s| s.estado == EstadoSesion::Idle).unwrap_or(false)); + + let lista = reg.list(); + assert_eq!(lista.len(), 1); + assert_eq!(lista[0].id, id); + + assert!(reg.kill(&id)); + assert!(reg.list().is_empty()); + assert!(!reg.kill(&id), "matar dos veces = false"); +} diff --git a/02_ruway/shuma/sandbox/shuma-core/Cargo.toml b/02_ruway/shuma/sandbox/shuma-core/Cargo.toml index 5f22db8..0c2f748 100644 --- a/02_ruway/shuma/sandbox/shuma-core/Cargo.toml +++ b/02_ruway/shuma/sandbox/shuma-core/Cargo.toml @@ -10,7 +10,7 @@ description = "Runtime de shuma: WorkspaceManager sobre arje-incarnate. Estado i [dependencies] shuma-card = { path = "../shuma-card" } -shuma-discern = { workspace = true } +shuma-discern = { path = "../shuma-discern" } card-core = { workspace = true } arje-incarnate = { workspace = true } nix = { workspace = true } diff --git a/02_ruway/shuma/sandbox/shuma-core/src/persist.rs b/02_ruway/shuma/sandbox/shuma-core/src/persist.rs index 2d34109..abb1706 100644 --- a/02_ruway/shuma/sandbox/shuma-core/src/persist.rs +++ b/02_ruway/shuma/sandbox/shuma-core/src/persist.rs @@ -199,7 +199,7 @@ impl WorkspaceManager { /// Carga snapshot desde disco y restaura los Workspaces + saved /// pipelines. Devuelve los `live_pipelines` para que el caller - /// (daemon) los relance — no podemos relanzarlos desde acá porque + /// (daemon) los relance — no podemos relanzarlos desde aquí porque /// `run_pipeline` necesita `Incarnator` + `DiscernPipeline`. /// Errores no-fatales (workspaces inválidos) se loguean y se saltan. pub async fn restore_snapshot( diff --git a/02_ruway/shuma/sandbox/shuma-discern/Cargo.toml b/02_ruway/shuma/sandbox/shuma-discern/Cargo.toml new file mode 100644 index 0000000..a648b1e --- /dev/null +++ b/02_ruway/shuma/sandbox/shuma-discern/Cargo.toml @@ -0,0 +1,14 @@ +[package] +name = "shuma-discern" +version.workspace = true +edition.workspace = true +rust-version.workspace = true +license.workspace = true +authors.workspace = true +publish.workspace = true +description = "Discernidor de contenido sobre buffers: MIME, codificación, parser hints. Compartible con file_explorer y nouser." + +[dependencies] +card-core = { workspace = true } +serde_json = { workspace = true } +toml = { workspace = true } diff --git a/02_ruway/shuma/sandbox/shuma-discern/LEEME.md b/02_ruway/shuma/sandbox/shuma-discern/LEEME.md new file mode 100644 index 0000000..a4935aa --- /dev/null +++ b/02_ruway/shuma/sandbox/shuma-discern/LEEME.md @@ -0,0 +1,9 @@ +# shuma-discern + +> Discriminador comando-vs-texto de [shuma](../../README.md). + +Decide si lo tipeado es comando shell o lenguaje natural. Heurística + small classifier. Si es ambiguo, pregunta. + +## Deps + +- [`shuma-core`](../shuma-core/README.md), [`shuma-intent`](../shuma-intent/README.md) diff --git a/02_ruway/shuma/sandbox/shuma-discern/README.md b/02_ruway/shuma/sandbox/shuma-discern/README.md new file mode 100644 index 0000000..2ae5f61 --- /dev/null +++ b/02_ruway/shuma/sandbox/shuma-discern/README.md @@ -0,0 +1,9 @@ +# shuma-discern + +> Command-vs-text discriminator of [shuma](../../README.md). + +Decides whether what the user typed is a shell command or natural language. Heuristic + small classifier. If ambiguous, asks. + +## Deps + +- [`shuma-core`](../shuma-core/README.md), [`shuma-intent`](../shuma-intent/README.md) diff --git a/02_ruway/shuma/sandbox/shuma-discern/src/lib.rs b/02_ruway/shuma/sandbox/shuma-discern/src/lib.rs new file mode 100644 index 0000000..b5d7e86 --- /dev/null +++ b/02_ruway/shuma/sandbox/shuma-discern/src/lib.rs @@ -0,0 +1,1226 @@ +//! `shuma-discern` — detección de tipo de contenido sobre buffers. +//! +//! Trait + pipeline + discerners default. Devuelve un [`Discernment`] con +//! `TypeRef` consistente con el broker, confidence, MIME y un `lens` hint +//! para UIs (reusa el espíritu del `dominant_lens` de chasqui). + +#![forbid(unsafe_code)] + +use card_core::TypeRef; + +#[derive(Debug, Clone)] +pub struct Hint<'a> { + pub path: Option<&'a str>, + pub size_total: Option, +} + +#[derive(Debug, Clone)] +pub struct Discernment { + pub ty: TypeRef, + pub confidence: f32, + pub mime: Option, + pub lens: Option, +} + +pub trait Discerner: Send + Sync { + fn name(&self) -> &str; + fn discern(&self, sample: &[u8], hint: &Hint<'_>) -> Option; +} + +pub struct DiscernPipeline { + discerners: Vec>, +} + +impl DiscernPipeline { + pub fn new() -> Self { + Self { discerners: Vec::new() } + } + + /// Pipeline con los discerners default. Orden importa: el primer match + /// con confidence ≥ `accept_threshold` corta. + pub fn default_pipeline() -> Self { + let mut p = Self::new(); + // DocxProbe antes que MagicBytes: un .docx ES un zip (`PK\x03\x04`), + // pero queremos el lens `docx` (→ visor pluma) en vez del genérico + // `application/zip` (→ visor de archivos). Desambigua el sabor OOXML + // por extensión, igual que el probe de audio hace con .ogg/.opus. + p.push(Box::new(DocxProbe)); + // PptxProbe con el mismo criterio: un .pptx ES un zip, pero queremos el + // lens `pptx` (→ visor de deck) en vez del genérico `application/zip`. + p.push(Box::new(PptxProbe)); + // XlsxProbe, mismo patrón: un .xlsx ES un zip, pero queremos el lens + // `sheet` (→ visor de hoja de nakui) en vez del genérico `application/zip`. + p.push(Box::new(XlsxProbe)); + // OdtProbe, mismo patrón OOXML→ODF: un .odt (LibreOffice Writer) ES un + // zip, pero queremos el lens `odt` (→ visor pluma read-only) en vez del + // genérico `application/zip`. + p.push(Box::new(OdtProbe)); + // EpubProbe antes de MagicBytes: un .epub ES un zip, pero su primera + // entrada `mimetype` lo delata → lens `book` en vez de `application/zip`. + p.push(Box::new(EpubProbe)); + // CbzProbe, mismo patrón que los OOXML: un .cbz (comic book) ES un zip, + // pero queremos el lens `comic` (→ visor de cómic con navegación por + // página) en vez del genérico `application/zip`. "Comic book zip" es una + // convención de nombre sobre zip, no un formato binario propio, así que + // la extensión ES la señal honesta. + p.push(Box::new(CbzProbe)); + p.push(Box::new(MagicBytes)); + // DbfProbe: DBF/xBase no tiene magic string, se reconoce por la + // estructura de su cabecera (versión + fecha + tamaño de cabecera + // múltiplo de 32). Va tras MagicBytes (que no lo agarra) y antes de los + // probes de texto. + p.push(Box::new(DbfProbe)); + // CardProbe y GeoJsonProbe antes que JsonProbe: ambos son JSON, pero + // queremos el TypeRef/lens más específico cuando el contenido lo delata. + p.push(Box::new(CardProbe)); + p.push(Box::new(GeoJsonProbe)); + p.push(Box::new(GpxProbe)); + p.push(Box::new(KmlProbe)); + // SvgProbe antes de los probes de texto genérico: un `.svg` ES XML, + // pero queremos el lens `svg` (→ visor vectorial) en vez de caer al + // text viewer. `) { + self.discerners.push(d); + } + + /// Recorre los discerners y devuelve el primer Discernment con + /// confidence ≥ 0.5, o el más confidente si ninguno alcanza el umbral. + pub fn discern(&self, sample: &[u8], hint: &Hint<'_>) -> Option { + let mut best: Option = None; + for d in &self.discerners { + if let Some(r) = d.discern(sample, hint) { + if r.confidence >= 0.9 { + return Some(r); + } + best = match best { + Some(prev) if prev.confidence >= r.confidence => Some(prev), + _ => Some(r), + }; + } + } + best + } +} + +impl Default for DiscernPipeline { + fn default() -> Self { + Self::default_pipeline() + } +} + +// ===================================================================== +// Discerners +// ===================================================================== + +/// Documentos Word (`.docx`/`.docm`): OOXML, es decir un zip. Se apoya en +/// el `hint.path` para el sabor OOXML (docx vs xlsx/pptx, indistinguibles +/// desde el header) y **confirma con el contenido** exigiendo el magic de +/// zip. Emite lens `docx` para que el shell monte el visor pluma en vez de +/// listarlo como archivo. Va antes de [`MagicBytes`] en el pipeline. +pub struct DocxProbe; + +impl Discerner for DocxProbe { + fn name(&self) -> &str { "docx" } + + fn discern(&self, s: &[u8], h: &Hint<'_>) -> Option { + let path = h.path?; + let es_docx = path.ends_with(".docx") || path.ends_with(".docm"); + let es_zip = s.starts_with(b"PK\x03\x04") || s.starts_with(b"PK\x05\x06"); + if es_docx && es_zip { + Some(Discernment { + ty: TypeRef::Primitive { name: "docx".into() }, + confidence: 0.99, + mime: Some( + "application/vnd.openxmlformats-officedocument.wordprocessingml.document" + .into(), + ), + lens: Some("docx".into()), + }) + } else { + None + } + } +} + +/// Detecta `.pptx`/`.pptm` (PowerPoint OOXML): un zip (`PK\x03\x04`) cuyo +/// nombre delata el sabor presentación. Espejo de [`DocxProbe`]; su `lens` +/// `pptx` rutea al visor de deck (`nahual-deck-viewer-llimphi`), no al +/// visor de archivos genérico. +pub struct PptxProbe; + +impl Discerner for PptxProbe { + fn name(&self) -> &str { "pptx" } + + fn discern(&self, s: &[u8], h: &Hint<'_>) -> Option { + let path = h.path?; + let es_pptx = path.ends_with(".pptx") || path.ends_with(".pptm"); + let es_zip = s.starts_with(b"PK\x03\x04") || s.starts_with(b"PK\x05\x06"); + if es_pptx && es_zip { + Some(Discernment { + ty: TypeRef::Primitive { name: "pptx".into() }, + confidence: 0.99, + mime: Some( + "application/vnd.openxmlformats-officedocument.presentationml.presentation" + .into(), + ), + lens: Some("pptx".into()), + }) + } else { + None + } + } +} + +/// Detecta `.xlsx`/`.xlsm` (Excel OOXML): un zip (`PK\x03\x04`) cuyo nombre +/// delata el sabor hoja de cálculo. Espejo de [`DocxProbe`]; su `lens` +/// `sheet` rutea al visor de hoja (`nahual-sheet-viewer-llimphi`, sobre +/// `foreign-xlsx` → `nakui_sheet::Sheet`), no al visor de archivos genérico. +pub struct XlsxProbe; + +impl Discerner for XlsxProbe { + fn name(&self) -> &str { "xlsx" } + + fn discern(&self, s: &[u8], h: &Hint<'_>) -> Option { + let path = h.path?; + let es_xlsx = path.ends_with(".xlsx") || path.ends_with(".xlsm"); + let es_zip = s.starts_with(b"PK\x03\x04") || s.starts_with(b"PK\x05\x06"); + if es_xlsx && es_zip { + Some(Discernment { + ty: TypeRef::Primitive { name: "xlsx".into() }, + confidence: 0.99, + mime: Some( + "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet".into(), + ), + lens: Some("sheet".into()), + }) + } else { + None + } + } +} + +/// EPUB (ebook): un ZIP cuya primera entrada, `mimetype`, va sin comprimir y +/// contiene `application/epub+zip` justo tras el header local. Ese literal en +/// el preámbulo del zip es una firma fiable (aun sin la extensión `.epub`). +/// Emite lens `book` → visor de libro de nahual, no el visor de archivos. +pub struct EpubProbe; + +impl Discerner for EpubProbe { + fn name(&self) -> &str { "epub" } + + fn discern(&self, s: &[u8], _h: &Hint<'_>) -> Option { + if !s.starts_with(b"PK\x03\x04") { + return None; + } + const FIRMA: &[u8] = b"application/epub+zip"; + let head = &s[..s.len().min(80)]; + if !head.windows(FIRMA.len()).any(|w| w == FIRMA) { + return None; + } + Some(Discernment { + ty: TypeRef::Primitive { name: "epub".into() }, + confidence: 0.99, + mime: Some("application/epub+zip".into()), + lens: Some("book".into()), + }) + } +} + +/// Detecta `.odt` (OpenDocument Text): un zip (`PK\x03\x04`) cuyo nombre delata +/// el sabor ODF de texto. Espejo de [`DocxProbe`]; su `lens` `odt` rutea al +/// visor pluma (`nahual-pluma-viewer-llimphi`, read-only), no al visor de +/// archivos genérico. Case-insensitive en la extensión. +pub struct OdtProbe; + +impl Discerner for OdtProbe { + fn name(&self) -> &str { "odt" } + + fn discern(&self, s: &[u8], h: &Hint<'_>) -> Option { + let path = h.path?.to_ascii_lowercase(); + let es_odt = path.ends_with(".odt"); + let es_zip = s.starts_with(b"PK\x03\x04") || s.starts_with(b"PK\x05\x06"); + if es_odt && es_zip { + Some(Discernment { + ty: TypeRef::Primitive { name: "odt".into() }, + confidence: 0.99, + mime: Some("application/vnd.oasis.opendocument.text".into()), + lens: Some("odt".into()), + }) + } else { + None + } + } +} + +/// Detecta `.cbz` (comic book zip): un zip (`PK\x03\x04`) cuyo nombre delata +/// que es un cómic. Espejo de [`DocxProbe`]; su `lens` `comic` rutea al visor +/// de cómic (`nahual-cbz-viewer-llimphi`, navegación por página), no al visor +/// de archivos genérico. Case-insensitive en la extensión. +pub struct CbzProbe; + +impl Discerner for CbzProbe { + fn name(&self) -> &str { "cbz" } + + fn discern(&self, s: &[u8], h: &Hint<'_>) -> Option { + let path = h.path?.to_ascii_lowercase(); + let es_cbz = path.ends_with(".cbz"); + let es_zip = s.starts_with(b"PK\x03\x04") || s.starts_with(b"PK\x05\x06"); + if es_cbz && es_zip { + Some(Discernment { + ty: TypeRef::Primitive { name: "cbz".into() }, + confidence: 0.99, + mime: Some("application/vnd.comicbook+zip".into()), + lens: Some("comic".into()), + }) + } else { + None + } + } +} + +/// Magic-bytes para formatos comunes. Confidence alta cuando hay match. +pub struct MagicBytes; + +impl Discerner for MagicBytes { + fn name(&self) -> &str { "magic-bytes" } + + fn discern(&self, s: &[u8], _h: &Hint<'_>) -> Option { + let d = |ty: &str, mime: &str, lens: Option<&str>| Discernment { + ty: TypeRef::Primitive { name: ty.into() }, + confidence: 0.99, + mime: Some(mime.into()), + lens: lens.map(String::from), + }; + match s { + x if x.starts_with(&[0x89, b'P', b'N', b'G']) => Some(d("png", "image/png", Some("gallery"))), + x if x.starts_with(&[0xFF, 0xD8, 0xFF]) => Some(d("jpeg", "image/jpeg", Some("gallery"))), + x if x.starts_with(b"%PDF-") => Some(d("pdf", "application/pdf", Some("reader"))), + x if x.starts_with(&[0x7F, b'E', b'L', b'F']) => Some(d("elf", "application/x-executable", None)), + x if x.starts_with(&[0x00, 0x61, 0x73, 0x6D]) => Some(d("wasm", "application/wasm", None)), + x if x.starts_with(&[0x1F, 0x8B]) => Some(d("gzip", "application/gzip", None)), + // xz — magic "\xFD7zXZ\x00". Envuelve un tar (.tar.xz) o un archivo + // suelto; el visor Archive descomprime y decide por contenido. + x if x.starts_with(&[0xFD, 0x37, 0x7A, 0x58, 0x5A, 0x00]) => { + Some(d("xz", "application/x-xz", None)) + } + // zstd — magic de frame 0x28B52FFD (little-endian en disco). + x if x.starts_with(&[0x28, 0xB5, 0x2F, 0xFD]) => { + Some(d("zstd", "application/zstd", None)) + } + // 7-Zip — magic "7z\xBC\xAF\x27\x1C". + x if x.starts_with(&[0x37, 0x7A, 0xBC, 0xAF, 0x27, 0x1C]) => { + Some(d("7z", "application/x-7z-compressed", None)) + } + // RAR — "Rar!\x1A\x07" (comparten prefijo v4 y v5). + x if x.starts_with(b"Rar!\x1A\x07") => Some(d("rar", "application/vnd.rar", None)), + x if x.starts_with(b"PK\x03\x04") || x.starts_with(b"PK\x05\x06") => { + Some(d("zip", "application/zip", None)) + } + // tar — el magic "ustar" no está al inicio sino en el offset + // 257 del primer header (POSIX y GNU lo escriben ahí). Como la + // muestra son 8 KB, el offset cae dentro. Sin esto, un .tar + // (texto en sus primeros 257 bytes) caería al text viewer. + x if x.len() >= 262 && &x[257..262] == b"ustar" => { + Some(d("tar", "application/x-tar", None)) + } + x if x.starts_with(b"GIF87a") || x.starts_with(b"GIF89a") => { + Some(d("gif", "image/gif", Some("gallery"))) + } + // PSD/PSB (Adobe Photoshop): magic "8BPS". Lens `psd` (no `gallery`) + // porque el decoder ráster genérico no abre PSD — lo aplana + // `foreign-psd`. El visor lo rutea por el lens. + x if x.starts_with(b"8BPS") => { + Some(d("psd", "image/vnd.adobe.photoshop", Some("psd"))) + } + // JPEG XL: dos firmas — el contenedor ISOBMFF + // (`00 00 00 0C 4A 58 4C 20`) y el codestream desnudo (`FF 0A`). + // Lens `jxl` (no `gallery`): el decoder ráster genérico no abre JXL, + // lo aplana `foreign-jxl`; el visor lo rutea por el lens. + x if x.starts_with(&[0x00, 0x00, 0x00, 0x0C, 0x4A, 0x58, 0x4C, 0x20]) + || x.starts_with(&[0xFF, 0x0A]) => + { + Some(d("jxl", "image/jxl", Some("jxl"))) + } + // RIFF: el FourCC en off 8 distingue WebP (imagen) de WAVE (audio). + x if x.len() >= 12 && x.starts_with(b"RIFF") && &x[8..12] == b"WEBP" => { + Some(d("webp", "image/webp", Some("gallery"))) + } + x if x.len() >= 12 && x.starts_with(b"RIFF") && &x[8..12] == b"WAVE" => { + Some(d("wav", "audio/wav", Some("audio"))) + } + // FLAC — audio sin pérdida ("fLaC"). + x if x.starts_with(b"fLaC") => Some(d("flac", "audio/flac", Some("audio"))), + // Ogg — contenedor de Vorbis u Opus ("OggS"). El visor elige + // decoder por extensión (.ogg/.oga vs .opus). + x if x.starts_with(b"OggS") => Some(d("ogg", "audio/ogg", Some("audio"))), + // MP3 con tag ID3v2 al inicio. El frame-sync crudo (0xFFEx) es + // ambiguo con otros streams, así que sólo capturamos ID3. + x if x.starts_with(b"ID3") => Some(d("mp3", "audio/mpeg", Some("audio"))), + // EBML — contenedor Matroska/WebM. Lo tratamos como video; el + // visor (media-source-webm) toma el track AV1. .mka (audio-only) + // caería igual aquí, pero el visor lo reporta como "sin video". + x if x.starts_with(&[0x1A, 0x45, 0xDF, 0xA3]) => { + Some(d("webm", "video/webm", Some("video"))) + } + // IVF — contenedor crudo de un stream AV1/VP9 ("DKIF"). + x if x.starts_with(b"DKIF") => Some(d("ivf", "video/x-ivf", Some("video"))), + // ISO-BMFF — "ftyp" en offset 4 (MP4/M4A/MOV). El brand (off + // 8..12) separa el audio puro (M4A/M4B) del video; lo demás se + // trata como video/mp4. Sin esto un .mp4 caía al text viewer + // como "binario" en vez de llegar al reproductor. + x if x.len() >= 12 && &x[4..8] == b"ftyp" => { + match &x[8..12] { + b"M4A " | b"M4B " => Some(d("m4a", "audio/mp4", Some("audio"))), + b"qt " => Some(d("mov", "video/quicktime", Some("video"))), + _ => Some(d("mp4", "video/mp4", Some("video"))), + } + } + // Fuentes parseables por ttf-parser: TrueType (0x00010000 o + // "true"), OpenType/CFF ("OTTO") y colecciones ("ttcf"). WOFF + // queda fuera (es un wrapper comprimido que ttf-parser no abre). + x if x.starts_with(&[0x00, 0x01, 0x00, 0x00]) + || x.starts_with(b"OTTO") + || x.starts_with(b"true") + || x.starts_with(b"ttcf") => + { + Some(d("font", "font/sfnt", Some("font"))) + } + _ => None, + } + } +} + +/// DBF/xBase (dBase III/IV, FoxPro). No hay magic string; se reconoce por la +/// **estructura** de la cabecera de 32 bytes: `[0]` es una versión conocida, +/// `[1..4]` una fecha plausible (año/mes/día), y `[8..10]` (tamaño de +/// cabecera) es `32 + N·32 + 1` con N ≥ 1 campos → `(tam − 1) % 32 == 0`. +/// Esa combinación es muy específica: casi no da falsos positivos. Emite lens +/// `dbf` (→ visor de tablas DBF de nahual). +pub struct DbfProbe; + +impl Discerner for DbfProbe { + fn name(&self) -> &str { "dbf" } + + fn discern(&self, s: &[u8], _h: &Hint<'_>) -> Option { + if s.len() < 32 { + return None; + } + // Versiones DBF conocidas (dBase III/IV, FoxPro, con/sin memo). + const VERSIONES: &[u8] = &[ + 0x02, 0x03, 0x04, 0x05, 0x30, 0x31, 0x32, 0x43, 0x63, 0x7B, 0x83, 0x8B, 0x8E, 0xCB, + 0xF5, 0xFB, + ]; + if !VERSIONES.contains(&s[0]) { + return None; + } + // Fecha de última modificación: [1]=año-1900, [2]=mes, [3]=día. + // Tolerante (doctrina de rescate): rechaza sólo fechas IMPOSIBLES + // (mes > 12, día > 31); permite 0/0 porque bases legadas/mutiladas a + // veces dejan la fecha sin setear. + let (mes, dia) = (s[2], s[3]); + if mes > 12 || dia > 31 { + return None; + } + let tam_cabecera = u16::from_le_bytes([s[8], s[9]]) as usize; + let tam_registro = u16::from_le_bytes([s[10], s[11]]) as usize; + // Al menos un campo (cabecera ≥ 32 + 32 + 1 = 65) y el terminador 0x0D + // hace que (tam − 1) sea múltiplo de 32. + if tam_cabecera < 65 || tam_registro == 0 || (tam_cabecera - 1) % 32 != 0 { + return None; + } + Some(Discernment { + ty: TypeRef::Primitive { name: "dbf".into() }, + confidence: 0.94, + mime: Some("application/dbf".into()), + lens: Some("dbf".into()), + }) + } +} + +/// JSON: parsea el inicio. No requiere parsearlo entero; con que arranque +/// con `{`/`[` y haga progreso cuenta. +pub struct JsonProbe; + +impl Discerner for JsonProbe { + fn name(&self) -> &str { "json" } + + fn discern(&self, s: &[u8], _h: &Hint<'_>) -> Option { + let trimmed = trim_left(s); + let first = *trimmed.first()?; + if first != b'{' && first != b'[' { + return None; + } + // Intento parsear tal cual; si falla por truncated, igualmente confidence media. + let txt = std::str::from_utf8(trimmed).ok()?; + match serde_json::from_str::(txt) { + Ok(_) => Some(Discernment { + ty: TypeRef::Primitive { name: "json".into() }, + confidence: 0.95, + mime: Some("application/json".into()), + lens: Some("tree".into()), + }), + Err(_) => Some(Discernment { + ty: TypeRef::Primitive { name: "json".into() }, + confidence: 0.6, // sample truncado + mime: Some("application/json".into()), + lens: Some("tree".into()), + }), + } + } +} + +pub struct TomlProbe; + +impl Discerner for TomlProbe { + fn name(&self) -> &str { "toml" } + + fn discern(&self, s: &[u8], h: &Hint<'_>) -> Option { + let txt = std::str::from_utf8(s).ok()?; + // Heurística: presencia de `[seccion]` y/o `clave = valor` y extensión. + let looks_like = txt.lines().any(|l| { + let l = l.trim(); + l.starts_with('[') && l.ends_with(']') + }) || txt.lines().any(|l| { + let l = l.trim(); + !l.starts_with('#') && l.contains(" = ") + }); + if !looks_like { + return None; + } + let confidence = if h.path.map_or(false, |p| p.ends_with(".toml")) { + 0.95 + } else { + 0.55 + }; + // Si parsea, sube confidence. + let parsed = toml::from_str::(txt).is_ok(); + Some(Discernment { + ty: TypeRef::Primitive { name: "toml".into() }, + confidence: if parsed { 0.93 } else { confidence }, + mime: Some("application/toml".into()), + lens: Some("tree".into()), + }) + } +} + +/// Si el JSON parsea como Card, lo emite como Wit { brahman:card }. +pub struct CardProbe; + +impl Discerner for CardProbe { + fn name(&self) -> &str { "card" } + + fn discern(&self, s: &[u8], _h: &Hint<'_>) -> Option { + let trimmed = trim_left(s); + if trimmed.first()? != &b'{' { + return None; + } + let txt = std::str::from_utf8(trimmed).ok()?; + let v: serde_json::Value = serde_json::from_str(txt).ok()?; + let obj = v.as_object()?; + if obj.contains_key("schema_version") && obj.contains_key("id") && obj.contains_key("payload") { + Some(Discernment { + ty: TypeRef::Wit { + package: "brahman:card".into(), + interface: None, + name: "card".into(), + }, + confidence: 0.97, + mime: Some("application/json".into()), + lens: Some("card".into()), + }) + } else { + None + } + } +} + +/// GeoJSON: un JSON cuyo `type` raíz es una de las clases GeoJSON +/// (`FeatureCollection`/`Feature`/geometrías). Emite lens `map` y mime +/// `application/geo+json` para que el shell lo rutee al visor de mapas en +/// vez del árbol genérico. +pub struct GeoJsonProbe; + +impl Discerner for GeoJsonProbe { + fn name(&self) -> &str { "geojson" } + + fn discern(&self, s: &[u8], _h: &Hint<'_>) -> Option { + let trimmed = trim_left(s); + if trimmed.first()? != &b'{' { + return None; + } + let txt = std::str::from_utf8(trimmed).ok()?; + let v: serde_json::Value = serde_json::from_str(txt).ok()?; + let ty = v.get("type")?.as_str()?; + let is_geo = matches!( + ty, + "FeatureCollection" + | "Feature" + | "GeometryCollection" + | "Point" + | "MultiPoint" + | "LineString" + | "MultiLineString" + | "Polygon" + | "MultiPolygon" + ); + if !is_geo { + return None; + } + // Confirmar mínimamente la forma: una colección/feature debe traer su + // arreglo característico; una geometría, `coordinates`. Esto evita + // confundir un JSON cualquiera con `"type":"Point"` sin coordenadas. + let confirmed = match ty { + "FeatureCollection" => v.get("features").map(|f| f.is_array()).unwrap_or(false), + "Feature" => v.get("geometry").is_some(), + "GeometryCollection" => v.get("geometries").map(|g| g.is_array()).unwrap_or(false), + _ => v.get("coordinates").map(|c| c.is_array()).unwrap_or(false), + }; + if !confirmed { + return None; + } + Some(Discernment { + ty: TypeRef::Primitive { name: "geojson".into() }, + confidence: 0.96, + mime: Some("application/geo+json".into()), + lens: Some("map".into()), + }) + } +} + +/// GPX: XML de GPS. Arranca con `<` y trae el elemento ` &str { "gpx" } + + fn discern(&self, s: &[u8], _h: &Hint<'_>) -> Option { + let trimmed = trim_left(s); + if trimmed.first()? != &b'<' { + return None; + } + // Buscar ``). + let head = &trimmed[..trimmed.len().min(2048)]; + let found = head.windows(4).any(|w| w == b" &str { "kml" } + + fn discern(&self, s: &[u8], _h: &Hint<'_>) -> Option { + let trimmed = trim_left(s); + if trimmed.first()? != &b'<' { + return None; + } + let head = &trimmed[..trimmed.len().min(2048)]; + if !head.windows(4).any(|w| w == b"` o +/// comentario delante) y trae el elemento raíz ` &str { "svg" } + + fn discern(&self, s: &[u8], _h: &Hint<'_>) -> Option { + let trimmed = trim_left(s); + if trimmed.first()? != &b'<' { + return None; + } + // Buscar ``/comentario). + let head = &trimmed[..trimmed.len().min(2048)]; + if !head.windows(4).any(|w| w == b" &str { "pmtiles" } + + fn discern(&self, s: &[u8], _h: &Hint<'_>) -> Option { + if s.len() < 8 || &s[0..7] != b"PMTiles" { + return None; + } + Some(Discernment { + ty: TypeRef::Primitive { name: "pmtiles".into() }, + confidence: 0.99, + mime: Some("application/vnd.pmtiles".into()), + lens: Some("map".into()), + }) + } +} + +/// Texto UTF-8 plano. Fallback de baja confidence. +pub struct Utf8Probe; + +impl Discerner for Utf8Probe { + fn name(&self) -> &str { "utf8" } + + fn discern(&self, s: &[u8], h: &Hint<'_>) -> Option { + if s.is_empty() { + return None; + } + let valid = std::str::from_utf8(s).is_ok(); + if !valid { + return None; + } + // Detectar binario disfrazado: bytes de control fuera de \t\n\r. + let suspicious = s.iter().filter(|&&b| b < 0x09 || (b > 0x0D && b < 0x20)).count(); + if suspicious * 100 / s.len().max(1) > 5 { + return None; + } + let lens = h.path.and_then(|p| { + if p.ends_with(".md") { Some("markdown") } + else if p.ends_with(".rs") || p.ends_with(".py") || p.ends_with(".go") || p.ends_with(".js") || p.ends_with(".ts") { + Some("code") + } else { None } + }).map(String::from); + Some(Discernment { + ty: TypeRef::Primitive { name: "text".into() }, + confidence: 0.5, + mime: Some("text/plain; charset=utf-8".into()), + lens, + }) + } +} + +/// Datos tabulares (CSV/TSV). El formato no tiene magic-bytes, así que +/// se apoya en el `hint.path` (`.csv`/`.tsv`) y confirma con el contenido: +/// la primera línea debe traer el delimitador. Emite lens `table`. +pub struct TabularProbe; + +impl Discerner for TabularProbe { + fn name(&self) -> &str { "tabular" } + + fn discern(&self, s: &[u8], h: &Hint<'_>) -> Option { + let path = h.path?; + // `.tsv` exige tab; `.csv` acepta cualquier delimitador común (coma, + // punto y coma, tab, barra) — Excel en locales es/pt/de exporta con + // `;`, y el visor de tabla igual lo sniffea. Sin esto, un `.csv` con + // `;` no traía coma en la primera línea y caía al text viewer. + let (cands, mime): (&[u8], &str) = if path.ends_with(".csv") { + (b",;\t|", "text/csv") + } else if path.ends_with(".tsv") { + (b"\t", "text/tab-separated-values") + } else { + return None; + }; + // Confirmar con la primera línea: debe ser UTF-8 y tener algún + // delimitador (una columna sola no es una tabla). + let txt = std::str::from_utf8(s).ok()?; + let first = txt.lines().next()?; + if !first.as_bytes().iter().any(|b| cands.contains(b)) { + return None; + } + Some(Discernment { + ty: TypeRef::Primitive { name: "tabular".into() }, + confidence: 0.93, + mime: Some(mime.into()), + lens: Some("table".into()), + }) + } +} + +fn trim_left(s: &[u8]) -> &[u8] { + let mut i = 0; + while i < s.len() && (s[i] == b' ' || s[i] == b'\t' || s[i] == b'\n' || s[i] == b'\r') { + i += 1; + } + &s[i..] +} + +#[cfg(test)] +mod tests { + use super::*; + + fn discern(sample: &[u8]) -> Option { + DiscernPipeline::default_pipeline().discern(sample, &Hint { path: None, size_total: None }) + } + + #[test] + fn png_detected() { + let r = discern(&[0x89, b'P', b'N', b'G', 0x0D, 0x0A, 0x1A, 0x0A, 0, 0]).unwrap(); + assert_eq!(r.mime.as_deref(), Some("image/png")); + assert!(r.confidence > 0.9); + } + + #[test] + fn psd_detectado_por_magic() { + // "8BPS" + versión 1 + relleno. + let r = discern(b"8BPS\x00\x01\x00\x00\x00\x00\x00\x00").unwrap(); + assert_eq!(r.mime.as_deref(), Some("image/vnd.adobe.photoshop")); + assert_eq!(r.lens.as_deref(), Some("psd")); + } + + #[test] + fn jxl_detectado_por_magic() { + // Codestream desnudo: firma `FF 0A`. + let r = discern(&[0xFF, 0x0A, 0x00, 0x00]).unwrap(); + assert_eq!(r.mime.as_deref(), Some("image/jxl")); + assert_eq!(r.lens.as_deref(), Some("jxl")); + // Contenedor ISOBMFF: `00 00 00 0C 4A 58 4C 20`. + let cont = discern(&[0x00, 0x00, 0x00, 0x0C, 0x4A, 0x58, 0x4C, 0x20, 0x0D, 0x0A]).unwrap(); + assert_eq!(cont.lens.as_deref(), Some("jxl")); + } + + #[test] + fn odt_detectado_por_extension_y_zip() { + let p = DiscernPipeline::default_pipeline(); + let r = p + .discern(b"PK\x03\x04relleno", &Hint { path: Some("/x/carta.odt"), size_total: None }) + .unwrap(); + assert_eq!(r.mime.as_deref(), Some("application/vnd.oasis.opendocument.text")); + assert_eq!(r.lens.as_deref(), Some("odt")); + // Un .odt que no es zip no matchea como odt. + let no = p.discern(b"texto plano", &Hint { path: Some("/x/carta.odt"), size_total: None }); + assert_ne!(no.and_then(|d| d.lens), Some("odt".to_string())); + } + + #[test] + fn cbz_detectado_por_extension_y_zip() { + let p = DiscernPipeline::default_pipeline(); + // Un zip con nombre .cbz → lens comic (no application/zip genérico). + let r = p + .discern(b"PK\x03\x04relleno", &Hint { path: Some("/x/comic.cbz"), size_total: None }) + .unwrap(); + assert_eq!(r.mime.as_deref(), Some("application/vnd.comicbook+zip")); + assert_eq!(r.lens.as_deref(), Some("comic")); + // Case-insensitive. + let up = p + .discern(b"PK\x03\x04relleno", &Hint { path: Some("/x/COMIC.CBZ"), size_total: None }) + .unwrap(); + assert_eq!(up.lens.as_deref(), Some("comic")); + // Un .cbz que NO es zip no matchea (cae a otro probe). + let no = p.discern(b"no soy zip", &Hint { path: Some("/x/comic.cbz"), size_total: None }); + assert_ne!(no.and_then(|d| d.lens), Some("comic".to_string())); + } + + #[test] + fn epub_detectado_por_firma_mimetype() { + // Prefijo de zip realista: header local + nombre "mimetype" + contenido + // "application/epub+zip". + let mut b = Vec::from(&b"PK\x03\x04"[..]); + b.extend_from_slice(&[0u8; 26]); // resto del header local + b.extend_from_slice(b"mimetype"); + b.extend_from_slice(b"application/epub+zip"); + let r = discern(&b).unwrap(); + assert_eq!(r.mime.as_deref(), Some("application/epub+zip")); + assert_eq!(r.lens.as_deref(), Some("book")); + } + + #[test] + fn zip_comun_no_es_epub() { + // Un zip sin la firma mimetype cae al zip genérico, no a book. + let r = discern(b"PK\x03\x04\x14\x00\x00\x00\x08\x00sin_firma_aqui").unwrap(); + assert_ne!(r.lens.as_deref(), Some("book")); + } + + #[test] + fn xz_detectado_por_magic() { + // "\xFD7zXZ\x00" + relleno; rutea al visor de archivos. + let r = discern(&[0xFD, 0x37, 0x7A, 0x58, 0x5A, 0x00, 0x00, 0x00]).unwrap(); + assert_eq!(r.mime.as_deref(), Some("application/x-xz")); + assert!(r.confidence > 0.9); + } + + #[test] + fn zstd_detectado_por_magic() { + // Frame zstd 0x28B52FFD (little-endian en disco) + relleno. + let r = discern(&[0x28, 0xB5, 0x2F, 0xFD, 0x00, 0x00, 0x00, 0x00]).unwrap(); + assert_eq!(r.mime.as_deref(), Some("application/zstd")); + assert!(r.confidence > 0.9); + } + + #[test] + fn siete_z_detectado_por_magic() { + let r = discern(&[0x37, 0x7A, 0xBC, 0xAF, 0x27, 0x1C, 0x00, 0x04]).unwrap(); + assert_eq!(r.mime.as_deref(), Some("application/x-7z-compressed")); + assert!(r.confidence > 0.9); + } + + #[test] + fn rar_detectado_por_magic() { + // RAR4 y RAR5 comparten el prefijo "Rar!\x1A\x07". + let rar4 = discern(&[0x52, 0x61, 0x72, 0x21, 0x1A, 0x07, 0x00, 0x00]).unwrap(); + assert_eq!(rar4.mime.as_deref(), Some("application/vnd.rar")); + let rar5 = discern(&[0x52, 0x61, 0x72, 0x21, 0x1A, 0x07, 0x01, 0x00]).unwrap(); + assert_eq!(rar5.mime.as_deref(), Some("application/vnd.rar")); + } + + #[test] + fn dbf_detectado_por_estructura() { + // Cabecera DBF dBase III: versión 0x03, fecha 2026-06-15, header=65 + // (un campo), registro=11. + let mut h = vec![0u8; 32]; + h[0] = 0x03; + h[1] = 126; // 2026 - 1900 + h[2] = 6; + h[3] = 15; + h[8] = 65; // tam_cabecera = 65 (little-endian: 0x41,0x00) + h[9] = 0; + h[10] = 11; // tam_registro + h[11] = 0; + let r = discern(&h).unwrap(); + assert_eq!(r.mime.as_deref(), Some("application/dbf")); + assert_eq!(r.lens.as_deref(), Some("dbf")); + } + + #[test] + fn dbf_con_fecha_imposible_no_es_dbf() { + // Versión válida pero mes 13 → no es DBF (evita falsos positivos). + let mut h = vec![0u8; 32]; + h[0] = 0x03; + h[2] = 13; // mes inválido + h[3] = 15; + h[8] = 65; + h[10] = 11; + assert_ne!(discern(&h).and_then(|d| d.lens).as_deref(), Some("dbf")); + } + + #[test] + fn webm_ebml_detected_como_video() { + let mut bytes = vec![0x1A, 0x45, 0xDF, 0xA3]; + bytes.extend_from_slice(b"\x01\x00\x00\x00\x00\x00\x00\x1f"); + let r = discern(&bytes).unwrap(); + assert_eq!(r.mime.as_deref(), Some("video/webm")); + assert_eq!(r.lens.as_deref(), Some("video")); + } + + #[test] + fn ivf_detected_como_video() { + let r = discern(b"DKIF\x00\x00\x20\x00AV01").unwrap(); + assert_eq!(r.mime.as_deref(), Some("video/x-ivf")); + assert_eq!(r.lens.as_deref(), Some("video")); + } + + #[test] + fn mp4_ftyp_detectado_como_video() { + // Caja ftyp ISO-BMFF típica: tamaño + "ftyp" + brand "isom". + let r = discern(b"\x00\x00\x00\x20ftypisom\x00\x00\x02\x00").unwrap(); + assert_eq!(r.mime.as_deref(), Some("video/mp4")); + assert_eq!(r.lens.as_deref(), Some("video")); + } + + #[test] + fn m4a_ftyp_detectado_como_audio() { + let r = discern(b"\x00\x00\x00\x20ftypM4A \x00\x00\x00\x00").unwrap(); + assert_eq!(r.mime.as_deref(), Some("audio/mp4")); + assert_eq!(r.lens.as_deref(), Some("audio")); + } + + #[test] + fn tar_detectado_por_ustar_en_offset_257() { + // Un header tar: nombre + relleno hasta el offset 257 donde va el + // magic "ustar". Los primeros bytes son texto (el nombre), así que + // sin el chequeo de offset caería al text viewer. + let mut bytes = vec![0u8; 512]; + bytes[..8].copy_from_slice(b"file.txt"); + bytes[257..262].copy_from_slice(b"ustar"); + let r = discern(&bytes).unwrap(); + assert_eq!(r.mime.as_deref(), Some("application/x-tar")); + } + + #[test] + fn fuentes_detectadas_por_magic() { + // TTF (0x00010000) y OTF ("OTTO") → lens font. + let r = discern(&[0x00, 0x01, 0x00, 0x00, 0x00, 0x0F]).unwrap(); + assert_eq!(r.lens.as_deref(), Some("font")); + assert_eq!(discern(b"OTTO\x00\x0a").unwrap().lens.as_deref(), Some("font")); + assert_eq!(discern(b"ttcf\x00\x01").unwrap().mime.as_deref(), Some("font/sfnt")); + } + + #[test] + fn wav_riff_detected_como_audio() { + let mut bytes = b"RIFF".to_vec(); + bytes.extend_from_slice(&[0x24, 0x08, 0x00, 0x00]); // chunk size + bytes.extend_from_slice(b"WAVE"); + let r = discern(&bytes).unwrap(); + assert_eq!(r.mime.as_deref(), Some("audio/wav")); + assert_eq!(r.lens.as_deref(), Some("audio")); + } + + #[test] + fn flac_y_ogg_detectados_como_audio() { + assert_eq!( + discern(b"fLaC\x00\x00\x00\x22").unwrap().lens.as_deref(), + Some("audio") + ); + assert_eq!( + discern(b"OggS\x00\x02\x00\x00").unwrap().mime.as_deref(), + Some("audio/ogg") + ); + } + + #[test] + fn geojson_detectado_como_mapa() { + let fc = br#"{"type":"FeatureCollection","features":[ + {"type":"Feature","geometry":{"type":"Point","coordinates":[1,2]},"properties":{}} + ]}"#; + let r = discern(fc).unwrap(); + assert_eq!(r.lens.as_deref(), Some("map")); + assert_eq!(r.mime.as_deref(), Some("application/geo+json")); + // Una geometría suelta también. + let pt = discern(br#"{"type":"Polygon","coordinates":[[[0,0],[1,0],[1,1],[0,0]]]}"#).unwrap(); + assert_eq!(pt.lens.as_deref(), Some("map")); + } + + #[test] + fn gpx_detectado_como_mapa() { + let gpx = br#" + + Cusco + "#; + let r = discern(gpx).unwrap(); + assert_eq!(r.lens.as_deref(), Some("map")); + assert_eq!(r.mime.as_deref(), Some("application/gpx+xml")); + } + + #[test] + fn kml_detectado_como_mapa() { + let kml = br#" + + -77,-12,0 + "#; + let r = discern(kml).unwrap(); + assert_eq!(r.lens.as_deref(), Some("map")); + assert_eq!(r.mime.as_deref(), Some("application/vnd.google-earth.kml+xml")); + } + + #[test] + fn svg_detectado_como_vectorial() { + let svg = br##" + + + "##; + let r = discern(svg).unwrap(); + assert_eq!(r.lens.as_deref(), Some("svg")); + assert_eq!(r.mime.as_deref(), Some("image/svg+xml")); + } + + #[test] + fn svg_sin_prologo_xml() { + // Muchos SVG omiten el `` y arrancan directo con `"#; + let r = discern(svg).unwrap(); + assert_eq!(r.mime.as_deref(), Some("image/svg+xml")); + } + + #[test] + fn pmtiles_detectado_como_mapa() { + let mut b = b"PMTiles".to_vec(); + b.push(3); // versión + b.extend_from_slice(&[0u8; 32]); + let r = discern(&b).unwrap(); + assert_eq!(r.lens.as_deref(), Some("map")); + assert_eq!(r.mime.as_deref(), Some("application/vnd.pmtiles")); + } + + #[test] + fn xml_no_gpx_no_es_mapa() { + // Un XML cualquiera (sin `"); + assert_ne!(r.and_then(|d| d.lens).as_deref(), Some("map")); + } + + #[test] + fn json_con_type_no_geo_cae_a_tree() { + // Un JSON con `"type"` arbitrario (no GeoJSON) no debe robarlo el + // GeoJsonProbe: cae al árbol. + let r = discern(br#"{"type":"banana","valor":3}"#).unwrap(); + assert_eq!(r.lens.as_deref(), Some("tree")); + } + + #[test] + fn point_sin_coordinates_no_es_geo() { + // `"type":"Point"` sin `coordinates` no se confunde con GeoJSON. + let r = discern(br#"{"type":"Point","name":"un punto cualquiera"}"#).unwrap(); + assert_eq!(r.lens.as_deref(), Some("tree")); + } + + #[test] + fn csv_por_path_es_tabla() { + let p = DiscernPipeline::default_pipeline(); + let hint = Hint { path: Some("/datos/ventas.csv"), size_total: None }; + let r = p.discern(b"fecha,monto,region\n2026-01,10,sur\n", &hint).unwrap(); + assert_eq!(r.mime.as_deref(), Some("text/csv")); + assert_eq!(r.lens.as_deref(), Some("table")); + } + + #[test] + fn csv_sin_delimitador_no_es_tabla() { + let p = DiscernPipeline::default_pipeline(); + let hint = Hint { path: Some("/x.csv"), size_total: None }; + // Sin coma en la primera línea: cae al text fallback, no a tabla. + let r = p.discern(b"una sola columna\nsin comas\n", &hint).unwrap(); + assert_ne!(r.lens.as_deref(), Some("table")); + } + + #[test] + fn json_detected() { + let r = discern(b"{\"hello\": 1}").unwrap(); + assert_eq!(r.mime.as_deref(), Some("application/json")); + } + + #[test] + fn card_wins_over_plain_json() { + let payload = br#"{"schema_version":1,"id":"01ARZ3NDEKTSV4RRFFQ69G5FAV","label":"x","payload":{"Virtual":null},"supervision":"OneShot"}"#; + let r = discern(payload).unwrap(); + match r.ty { + TypeRef::Wit { ref package, .. } => assert_eq!(package, "brahman:card"), + _ => panic!("expected card"), + } + } + + #[test] + fn utf8_text_fallback() { + let r = discern(b"hello world\nthis is text").unwrap(); + // Puede ser detected as toml (= heurística) o text. Ambos son aceptables, sólo aseguro algo razonable. + assert!(r.mime.is_some()); + } + + #[test] + fn binary_rejected_by_utf8() { + let mut bytes = vec![0u8; 100]; + bytes[0] = 0x00; + bytes[1] = 0x01; + bytes[2] = 0x02; + let r = DiscernPipeline::default_pipeline().discern(&bytes, &Hint { path: None, size_total: None }); + // Tras Utf8Probe rechazar, no hay match → None. + // Si por casualidad otro discerner mata antes, también es OK. + if let Some(r) = r { + assert_ne!(r.mime.as_deref(), Some("text/plain; charset=utf-8")); + } + } + + fn discern_con_path(sample: &[u8], path: &str) -> Option { + DiscernPipeline::default_pipeline() + .discern(sample, &Hint { path: Some(path), size_total: None }) + } + + #[test] + fn docx_por_zip_mas_extension_da_lens_docx() { + // Zip magic + extensión .docx → visor pluma, no Archive. + let r = discern_con_path(b"PK\x03\x04\x14\x00\x06\x00", "informe.docx").unwrap(); + assert_eq!(r.lens.as_deref(), Some("docx")); + assert_eq!( + r.mime.as_deref(), + Some("application/vnd.openxmlformats-officedocument.wordprocessingml.document") + ); + } + + #[test] + fn zip_sin_extension_docx_sigue_siendo_archivo() { + // Un .zip común no debe robarse el lens docx: cae al zip genérico. + let r = discern_con_path(b"PK\x03\x04\x14\x00\x06\x00", "backup.zip").unwrap(); + assert_eq!(r.mime.as_deref(), Some("application/zip")); + assert_ne!(r.lens.as_deref(), Some("docx")); + } + + #[test] + fn extension_docx_sin_zip_no_confunde() { + // Extensión mentirosa (no es zip) → DocxProbe no dispara. + let r = discern_con_path(b"esto no es un zip", "falso.docx"); + assert_ne!(r.and_then(|d| d.lens).as_deref(), Some("docx")); + } + + #[test] + fn pptx_por_zip_mas_extension_da_lens_pptx() { + let r = discern_con_path(b"PK\x03\x04\x14\x00\x06\x00", "charla.pptx").unwrap(); + assert_eq!(r.lens.as_deref(), Some("pptx")); + assert_eq!( + r.mime.as_deref(), + Some("application/vnd.openxmlformats-officedocument.presentationml.presentation") + ); + } + + #[test] + fn extension_pptx_sin_zip_no_confunde() { + let r = discern_con_path(b"esto no es un zip", "falso.pptx"); + assert_ne!(r.and_then(|d| d.lens).as_deref(), Some("pptx")); + } + + #[test] + fn xlsx_por_zip_mas_extension_da_lens_sheet() { + let r = discern_con_path(b"PK\x03\x04\x14\x00\x06\x00", "presupuesto.xlsx").unwrap(); + assert_eq!(r.lens.as_deref(), Some("sheet")); + assert_eq!( + r.mime.as_deref(), + Some("application/vnd.openxmlformats-officedocument.spreadsheetml.sheet") + ); + } + + #[test] + fn extension_xlsx_sin_zip_no_confunde() { + let r = discern_con_path(b"esto no es un zip", "falso.xlsx"); + assert_ne!(r.and_then(|d| d.lens).as_deref(), Some("sheet")); + } + + #[test] + fn csv_coma_va_a_tabla() { + let r = discern_con_path(b"fecha,monto\n2026-01,10\n", "datos.csv").unwrap(); + assert_eq!(r.lens.as_deref(), Some("table")); + assert_eq!(r.mime.as_deref(), Some("text/csv")); + } + + #[test] + fn csv_punto_y_coma_tambien_va_a_tabla() { + // Excel es/pt/de exporta con `;`. Antes esto caía al text viewer. + let r = discern_con_path(b"fecha;monto\n2026-01;10\n", "datos.csv").unwrap(); + assert_eq!(r.lens.as_deref(), Some("table")); + } + + #[test] + fn csv_de_una_sola_columna_no_es_tabla() { + // Sin delimitador en la primera línea → no es tabla. + let r = discern_con_path(b"solo_una_columna\nvalor\n", "lista.csv"); + assert_ne!(r.and_then(|d| d.lens).as_deref(), Some("table")); + } +} diff --git a/02_ruway/shuma/sandbox/shuma-exec/src/lib.rs b/02_ruway/shuma/sandbox/shuma-exec/src/lib.rs index 4e667c3..2f060e2 100644 --- a/02_ruway/shuma/sandbox/shuma-exec/src/lib.rs +++ b/02_ruway/shuma/sandbox/shuma-exec/src/lib.rs @@ -76,6 +76,13 @@ pub struct CommandSpec { pub spill_path: Option, /// Texto a alimentar por stdin — para reprocesar una salida previa. pub stdin_data: Option, + /// Variables de entorno **específicas de este comando**, aplicadas + /// encima del entorno heredado del proceso (lo sobreescriben). Es el + /// overlay *scoped*: p. ej. `http_proxy` sólo para `claude`, sin + /// filtrarse a ningún otro comando ni al resto del sistema. Vacío = sólo + /// se hereda el entorno del proceso, como siempre. Aplica en los tres + /// modos (Shell/Direct/PTY). + pub env: Vec<(String, String)>, /// Si `true`, en un pipe `Direct` se intercepta el stdout de **cada /// etapa intermedia** (tee): además de alimentar a la siguiente, cada /// línea se emite como [`RunEvent::StageStdout`]. Permite ver el stream @@ -93,6 +100,7 @@ impl CommandSpec { capture_limit: 0, spill_path: None, stdin_data: None, + env: Vec::new(), capture_stages: false, } } @@ -105,10 +113,17 @@ impl CommandSpec { capture_limit: 0, spill_path: None, stdin_data: None, + env: Vec::new(), capture_stages: false, } } + /// Fija el overlay de entorno *scoped* de este comando (encadenable). + pub fn with_env(mut self, env: Vec<(String, String)>) -> Self { + self.env = env; + self + } + /// Activa la captura por etapa (tee) en pipes directos (encadenable). pub fn with_stage_capture(mut self) -> Self { self.capture_stages = true; @@ -347,8 +362,15 @@ impl RunHandle { /// Drena todos los eventos disponibles ahora mismo, sin bloquear. pub fn try_events(&mut self) -> Vec { + self.try_events_limit(usize::MAX) + } + + /// Drena hasta `max` eventos del receiver, dejando el resto en cola para + /// el próximo llamado. Pensado para ráfagas grandes (`ls -alR`): permite + /// liberar el lock del run entre tandas para que el render no se pasme. + pub fn try_events_limit(&mut self, max: usize) -> Vec { let mut out = Vec::new(); - loop { + for _ in 0..max { match self.rx.try_recv() { Ok(ev) => { if ev.is_terminal() { @@ -474,7 +496,7 @@ fn spawn_reader( let mut buf = String::new(); loop { buf.clear(); - let n = match reader.read_line(&mut buf) { + let n = match read_line_loose(&mut reader, &mut buf) { Ok(0) => break, // EOF Ok(n) => n, Err(_) => break, @@ -506,6 +528,42 @@ fn spawn_reader( }) } +/// Como `BufRead::read_line`, pero corta también en `\r` (no solo `\n`). +/// Pensado para que **progress bars** estilo `wget`/`pip install -v`/`curl` +/// se muestren en vivo: esos escriben `\r` para sobreescribir la misma +/// línea y nunca emiten `\n` hasta el final — con `read_line` clásico el +/// usuario no ve nada hasta que el comando termina. +/// +/// El `\n` posterior a un `\r` (`\r\n` clásico de Windows o de `git log` +/// pasado por less) se ve como una línea vacía adicional — aceptable a +/// cambio de tener feedback en vivo en TUIs no-PTY. +fn read_line_loose(reader: &mut R, buf: &mut String) -> std::io::Result { + let mut bytes: Vec = Vec::with_capacity(128); + let mut total = 0; + loop { + let chunk = reader.fill_buf()?; + if chunk.is_empty() { + break; // EOF + } + if let Some(pos) = chunk.iter().position(|&b| b == b'\n' || b == b'\r') { + bytes.extend_from_slice(&chunk[..=pos]); + let consumed = pos + 1; + reader.consume(consumed); + total += consumed; + break; + } else { + bytes.extend_from_slice(chunk); + let n = chunk.len(); + reader.consume(n); + total += n; + } + } + if !bytes.is_empty() { + buf.push_str(&String::from_utf8_lossy(&bytes)); + } + Ok(total) +} + /// Resultado de lanzar los procesos: lo que el coordinador necesita. struct Spawned { children: Vec, @@ -526,20 +584,35 @@ struct StageTee { sink: std::fs::File, } -/// Lanza un único proceso shell (`program -c ""`). -fn spawn_shell(line: &str, program: &str, cwd: &str, want_stdin: bool) -> std::io::Result { - let mut child = Command::new(program) - .arg("-c") +/// Lanza un único proceso shell (`program -c ""`). `_want_stdin` se +/// mantiene por compatibilidad de firma: ahora stdin SIEMPRE se abre como +/// `piped` para que el usuario pueda alimentar Y/n a prompts interactivos +/// (apt, pacman, sudo, etc.). Los comandos que no leen stdin no se +/// afectan; los que sí (cat sin args, head -) se cuelgan esperando — lo +/// cual es el comportamiento esperado de un shell real. +fn spawn_shell( + line: &str, + program: &str, + cwd: &str, + env: &[(String, String)], + _want_stdin: bool, +) -> std::io::Result { + let mut cmd = Command::new(program); + cmd.arg("-c") .arg(line) .current_dir(cwd) - .stdin(if want_stdin { Stdio::piped() } else { Stdio::null() }) + .stdin(Stdio::piped()) .stdout(Stdio::piped()) .stderr(Stdio::piped()) // Nuevo grupo de procesos: con `bash -c "sleep 30"` el bash se // forka a un sleep hijo; matar al bash sólo no alcanza al sleep. // Con el grupo, `killpg(pid, SIG)` derriba a todo el subárbol. - .process_group(0) - .spawn()?; + .process_group(0); + // Overlay de entorno scoped: sobreescribe lo heredado, sólo para este run. + for (k, v) in env { + cmd.env(k, v); + } + let mut child = cmd.spawn()?; let stdin = child.stdin.take(); let stdout = child.stdout.take(); let stderrs = child.stderr.take().into_iter().collect(); @@ -550,6 +623,7 @@ fn spawn_shell(line: &str, program: &str, cwd: &str, want_stdin: bool) -> std::i fn spawn_direct( stages: &[StageSpec], cwd: &str, + env: &[(String, String)], want_stdin: bool, capture_stages: bool, ) -> std::io::Result { @@ -572,6 +646,10 @@ fn spawn_direct( .current_dir(cwd) .stdout(Stdio::piped()) .stderr(Stdio::piped()); + // Overlay de entorno scoped: cada etapa del pipe lo recibe. + for (k, v) in env { + cmd.env(k, v); + } if i == 0 { cmd.stdin(if want_stdin { Stdio::piped() } else { Stdio::null() }); // Primera etapa abre su propio grupo de procesos; las demás @@ -698,11 +776,13 @@ pub fn run(spec: &CommandSpec) -> RunHandle { let cols = *cols; let rows = *rows; let cwd = spec.cwd.clone(); + let env = spec.env.clone(); std::thread::spawn(move || { spawn_pty_thread( &program, &args, &cwd, + &env, cols, rows, tx, @@ -725,10 +805,10 @@ pub fn run(spec: &CommandSpec) -> RunHandle { let want_stdin = spec.stdin_data.is_some(); let spawned = match &spec.exec { Exec::Shell { line, program } => { - spawn_shell(line, program, &spec.cwd, want_stdin) + spawn_shell(line, program, &spec.cwd, &spec.env, want_stdin) } Exec::Direct { stages } => { - spawn_direct(stages, &spec.cwd, want_stdin, spec.capture_stages) + spawn_direct(stages, &spec.cwd, &spec.env, want_stdin, spec.capture_stages) } Exec::Pty { .. } => unreachable!("Pty se maneja antes"), }; @@ -740,10 +820,30 @@ pub fn run(spec: &CommandSpec) -> RunHandle { } }; - // Alimenta stdin (reproceso) en su propio hilo. - if let (Some(data), Some(mut sink)) = (spec.stdin_data.clone(), stdin) { + // Alimenta stdin. Hay DOS modos según si el caller dio `stdin_data`: + // + // - **Reprocess** (`stdin_data = Some(...)`): escribimos los bytes + // y CERRAMOS el sink inmediatamente. El child recibe EOF y procesa + // normal (sort, head, jq…). El input vivo NO aplica aquí. + // + // - **Interactivo** (`stdin_data = None`): el thread queda leyendo + // `stdin_rx` para que el usuario pueda responder prompts (apt Y/n, + // sudo password, etc.) tipeando en el input box. Sale cuando el + // channel cierra (`RunHandle` droppeado) o el child cierra stdin. + if let Some(mut sink) = stdin { + let initial = spec.stdin_data.clone(); std::thread::spawn(move || { - let _ = sink.write_all(data.as_bytes()); + if let Some(data) = initial { + let _ = sink.write_all(data.as_bytes()); + // Drop del sink al salir = EOF para el child. + return; + } + while let Ok(bytes) = stdin_rx.recv() { + if sink.write_all(&bytes).is_err() { + break; + } + let _ = sink.flush(); + } }); } @@ -837,6 +937,7 @@ fn spawn_pty_thread( program: &str, args: &[String], cwd: &str, + env: &[(String, String)], cols: u16, rows: u16, tx: Sender, @@ -862,9 +963,26 @@ fn spawn_pty_thread( cmd.arg(a); } cmd.cwd(cwd); + // `CommandBuilder` de portable_pty arranca con env vacío — hay que + // heredar manualmente PATH/HOME/USER/SUDO_ASKPASS/SSH_ASKPASS/etc. + // Sin esto, `sudo -A` no encuentra el askpass y `which` falla. + for (k, v) in std::env::vars_os() { + cmd.env(k, v); + } // Heurística estándar: TUIs leen `TERM` para decidir capacidad de - // colores y movimiento. xterm-256color es el lcm más amplio. + // colores y movimiento. xterm-256color es el lcm más amplio. Se + // sobreescribe el TERM heredado por si el caller corre desde una + // shell sin TERM (cron, systemd-run, etc.). cmd.env("TERM", "xterm-256color"); + // Muchos programas (fastfetch, eza, bat…) sólo emiten color de 24 bits si + // ven `COLORTERM=truecolor`; sin esto fastfetch avisa «limitaciones de + // color» y cae a 256. El grid vt100 ya pinta RGB, así que lo declaramos. + cmd.env("COLORTERM", "truecolor"); + // Overlay scoped al final: sobreescribe lo heredado y hasta TERM/COLORTERM + // si una regla `on_command` lo pidió explícito para este comando. + for (k, v) in env { + cmd.env(k, v); + } let mut child = match pair.slave.spawn_command(cmd) { Ok(c) => c, Err(e) => { @@ -1146,6 +1264,7 @@ mod tests { capture_limit: 0, spill_path: None, stdin_data: None, + env: Vec::new(), capture_stages: false, }; let mut h = run(&spec); @@ -1186,6 +1305,7 @@ mod tests { capture_limit: 0, spill_path: None, stdin_data: None, + env: Vec::new(), capture_stages: false, }; let mut h = run(&spec); @@ -1225,6 +1345,7 @@ mod tests { capture_limit: 0, spill_path: None, stdin_data: None, + env: Vec::new(), capture_stages: false, }; let mut h = run(&spec); diff --git a/02_ruway/shuma/sandbox/shuma-history/src/foreign.rs b/02_ruway/shuma/sandbox/shuma-history/src/foreign.rs new file mode 100644 index 0000000..9421c1d --- /dev/null +++ b/02_ruway/shuma/sandbox/shuma-history/src/foreign.rs @@ -0,0 +1,460 @@ +//! Absorción de historiales de **shells ajenos** (bash, zsh) al historial +//! propio de shuma. +//! +//! El usuario que estrena shuma no llega con las manos vacías: ya tiene años +//! de comandos en `~/.bash_history` y `~/.zsh_history`. Importarlos hace que +//! el ghost, el ranking por frecuencia y la búsqueda fuzzy funcionen **desde +//! el primer arranque**, sin reaprender. +//! +//! Diseño: +//! +//! - **Parsers tolerantes.** bash es una línea por comando (con líneas +//! `#` opcionales si `HISTTIMEFORMAT` está puesto). zsh tiene dos +//! formatos: plano (igual que bash) y *extended* (`: :;cmd`), +//! este último con continuación por `\` al final de línea para comandos +//! multilínea. Ambos parsers nunca entran en pánico; una línea ilegible se +//! saltea. +//! - **Importación incremental.** Un fichero de estado +//! (`shell_import.json`) recuerda cuántas entradas de cada fuente ya se +//! absorbieron y el tamaño del fichero. Al relanzar shuma sólo se importa +//! la **cola nueva** — no se reimporta todo cada vez. Si el fichero +//! encogió (historial limpiado/rotado), se reimporta desde cero. +//! - **Orden cronológico.** Las entradas nuevas de todas las fuentes se +//! mezclan por timestamp antes de appendear, así el historial propio queda +//! en orden temporal coherente. + +use std::collections::BTreeMap; +use std::path::PathBuf; + +use serde::{Deserialize, Serialize}; + +use crate::{Entry, History}; + +/// Qué shell produjo un fichero de historial — determina el parser. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum ShellKind { + /// `~/.bash_history` — una línea por comando, `#` opcional. + Bash, + /// `~/.zsh_history` — plano o *extended* (`: ts:elapsed;cmd`). + Zsh, +} + +/// Una fuente de historial ajeno a absorber. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct ForeignSource { + pub kind: ShellKind, + pub path: PathBuf, +} + +impl ForeignSource { + pub fn bash(path: impl Into) -> Self { + Self { kind: ShellKind::Bash, path: path.into() } + } + pub fn zsh(path: impl Into) -> Self { + Self { kind: ShellKind::Zsh, path: path.into() } + } +} + +/// Resultado de una absorción — para reportar en la UI. +#[derive(Debug, Clone, Default, PartialEq, Eq)] +pub struct ImportReport { + /// Entradas efectivamente añadidas al historial. + pub imported: usize, + /// Fuentes que aportaron al menos una entrada nueva. + pub sources: Vec, +} + +impl ImportReport { + pub fn is_empty(&self) -> bool { + self.imported == 0 + } +} + +/// Fuentes por defecto a partir de `$HOME` / `$ZDOTDIR` / `$HISTFILE`. +/// Sólo las que **existen** en disco. Respeta `HISTFILE` de bash si apunta a +/// otro fichero. zsh busca en `$ZDOTDIR` antes que en `$HOME`. +pub fn default_sources() -> Vec { + let mut out = Vec::new(); + let home = std::env::var_os("HOME").map(PathBuf::from); + + // bash: HISTFILE si está, si no ~/.bash_history. + let bash_path = std::env::var_os("HISTFILE") + .map(PathBuf::from) + .filter(|p| p.file_name().is_some_and(|n| n.to_string_lossy().contains("bash"))) + .or_else(|| home.as_ref().map(|h| h.join(".bash_history"))); + if let Some(p) = bash_path { + if p.exists() { + out.push(ForeignSource::bash(p)); + } + } + + // zsh: $ZDOTDIR/.zsh_history o ~/.zsh_history. + let zsh_path = std::env::var_os("ZDOTDIR") + .map(|z| PathBuf::from(z).join(".zsh_history")) + .or_else(|| home.as_ref().map(|h| h.join(".zsh_history"))); + if let Some(p) = zsh_path { + if p.exists() { + out.push(ForeignSource::zsh(p)); + } + } + out +} + +/// Parsea el contenido de un `~/.bash_history`. Las líneas `#` son +/// timestamps (de `HISTTIMEFORMAT`) y se adjuntan al comando siguiente. +pub fn parse_bash(text: &str) -> Vec { + let mut out = Vec::new(); + let mut pending_ts: Option = None; + for raw in text.lines() { + let line = raw.trim_end_matches('\r'); + if line.is_empty() { + continue; + } + // `#1700000000` → timestamp del próximo comando. + if let Some(rest) = line.strip_prefix('#') { + if let Ok(ts) = rest.trim().parse::() { + pending_ts = Some(ts); + continue; + } + // `#` que no es timestamp = comentario en historial: se saltea. + continue; + } + out.push(Entry::new(line, "", pending_ts.take().unwrap_or(0))); + } + out +} + +/// Parsea el contenido de un `~/.zsh_history`. Soporta el formato *extended* +/// (`: :;cmd`) y el plano (una línea por comando). Los comandos +/// multilínea se reúnen siguiendo la continuación por `\` al final de línea +/// (cómo zsh codifica un newline dentro de un comando). +pub fn parse_zsh(text: &str) -> Vec { + let mut out = Vec::new(); + let mut buf = String::new(); + let mut cur_ts: u64 = 0; + let mut in_entry = false; + + let flush = |out: &mut Vec, buf: &mut String, ts: u64| { + let cmd = buf.trim(); + if !cmd.is_empty() { + out.push(Entry::new(cmd, "", ts)); + } + buf.clear(); + }; + + for raw in text.lines() { + let line = raw.trim_end_matches('\r'); + // Continuación: la entrada en curso terminaba en `\` → este renglón + // es parte del mismo comando. + if in_entry { + buf.push('\n'); + buf.push_str(line); + in_entry = line.ends_with('\\'); + if !in_entry { + flush(&mut out, &mut buf, cur_ts); + } + continue; + } + if line.is_empty() { + continue; + } + // Cabecera extended: `: :;`. + let (ts, cmd) = parse_zsh_header(line).unwrap_or((0, line)); + cur_ts = ts; + buf.push_str(cmd); + if cmd.ends_with('\\') { + in_entry = true; + } else { + flush(&mut out, &mut buf, cur_ts); + } + } + // Última entrada sin newline final. + if !buf.trim().is_empty() { + flush(&mut out, &mut buf, cur_ts); + } + out +} + +/// Descompone una cabecera extended de zsh `: :;` en +/// `(timestamp, comando)`. `None` si la línea no tiene esa forma. +fn parse_zsh_header(line: &str) -> Option<(u64, &str)> { + let rest = line.strip_prefix(": ")?; + let (ts_part, after) = rest.split_once(':')?; + let ts = ts_part.trim().parse::().ok()?; + let (_elapsed, cmd) = after.split_once(';')?; + Some((ts, cmd)) +} + +// ─── Estado de importación incremental ───────────────────────────────── + +/// Deshace la **metaficación** de zsh: para no chocar con sus separadores, zsh +/// guarda cada byte `>= 0x80` como `0x83` seguido del byte con el bit 0x20 +/// invertido. Un fichero así no es UTF-8 válido y `read_to_string` lo rechaza +/// entero — un acento en un comando bastaba para perder todo el historial. +fn desmetaficar(bytes: &[u8]) -> Vec { + const META: u8 = 0x83; + let mut out = Vec::with_capacity(bytes.len()); + let mut i = 0; + while i < bytes.len() { + if bytes[i] == META && i + 1 < bytes.len() { + out.push(bytes[i + 1] ^ 32); + i += 2; + } else { + out.push(bytes[i]); + i += 1; + } + } + out +} + +/// Cuánto de una fuente ya se absorbió: tamaño del fichero al importar y +/// número de entradas parseadas hasta entonces. +#[derive(Debug, Clone, Default, Serialize, Deserialize)] +struct SourceState { + /// Bytes del fichero la última vez que se importó (detecta truncado). + size: u64, + /// Cuántas entradas parseadas ya se absorbieron. + imported: usize, + /// Timestamp de la entrada más nueva ya absorbida de esta fuente. + /// + /// Es la marca de agua **que sobrevive a una reescritura**. El contador + /// `imported` supone que el fichero sólo crece por el final, y eso es falso + /// en zsh: al pasar de `SAVEHIST` recorta reescribiendo el archivo entero + /// (con `histexpiredupsfirst`, además, expira duplicados primero). Después + /// de un recorte el contador apunta a cualquier lado, se reimporta todo, y + /// eso llenó un historial real con 25.596 copias de 68 líneas. + /// + /// Con timestamps (zsh `extendedhistory`) se importa sólo lo posterior, y + /// da igual cuántas veces se relea el fichero. `0` = sin marca todavía. + #[serde(default)] + ultimo_ts: u64, +} + +#[derive(Debug, Clone, Default, Serialize, Deserialize)] +struct ImportState { + /// path del fichero (como string) → progreso. + sources: BTreeMap, +} + +/// `$XDG_DATA_HOME/shuma/shell_import.json` — el estado de importación. +fn import_state_path() -> Option { + directories::ProjectDirs::from("", "", "shuma") + .map(|d| d.data_dir().join("shell_import.json")) +} + +fn load_import_state() -> ImportState { + import_state_path() + .and_then(|p| std::fs::read_to_string(p).ok()) + .and_then(|s| serde_json::from_str(&s).ok()) + .unwrap_or_default() +} + +fn save_import_state(state: &ImportState) -> std::io::Result<()> { + let Some(path) = import_state_path() else { + return Ok(()); + }; + if let Some(dir) = path.parent() { + std::fs::create_dir_all(dir)?; + } + let json = serde_json::to_string_pretty(state) + .map_err(std::io::Error::other)?; + let tmp = path.with_extension("json.tmp"); + std::fs::write(&tmp, json)?; + std::fs::rename(&tmp, path) +} + +/// Absorbe las entradas **nuevas** de `sources` al `history`. Incremental: +/// usa el estado en disco para importar sólo lo que creció desde la última +/// vez. Las entradas nuevas de todas las fuentes se mezclan por timestamp y +/// se appendean en bloque. Devuelve qué se importó. +/// +/// Errores de IO son blandos: una fuente ilegible se saltea sin abortar. +pub fn absorb_foreign(history: &mut History, sources: &[ForeignSource]) -> ImportReport { + let mut state = load_import_state(); + let mut fresh: Vec = Vec::new(); + let mut report = ImportReport::default(); + let mut state_changed = false; + + for src in sources { + let key = src.path.to_string_lossy().to_string(); + // Bytes, no `read_to_string`: el historial de zsh **no es UTF-8**. zsh + // "metafica" los bytes altos, así que un solo acento en un comando + // hacía fallar la lectura entera y la fuente se salteaba en silencio — + // razón por la cual un historial de zsh de 9.913 líneas nunca se + // importó, y ni siquiera figuraba en el estado. + let Ok(bytes) = std::fs::read(&src.path) else { + continue; + }; + let size = bytes.len() as u64; + let texto = match src.kind { + ShellKind::Zsh => String::from_utf8_lossy(&desmetaficar(&bytes)).into_owned(), + ShellKind::Bash => String::from_utf8_lossy(&bytes).into_owned(), + }; + let entries = match src.kind { + ShellKind::Bash => parse_bash(&texto), + ShellKind::Zsh => parse_zsh(&texto), + }; + let st = state.sources.entry(key).or_default(); + // Fichero encogió → rotado/limpiado → el contador ya no ubica nada. + if size < st.size { + st.imported = 0; + } + // Con timestamps mandan ELLOS: importar sólo lo posterior a la marca es + // idempotente aunque el fichero se haya reescrito entero. Sin + // timestamps (bash) queda el contador, que es lo único que hay. + let hay_ts = entries.iter().any(|e| e.started > 0); + let nuevos: Vec = if hay_ts && st.ultimo_ts > 0 { + entries.iter().filter(|e| e.started > st.ultimo_ts).cloned().collect() + } else { + let already = st.imported.min(entries.len()); + entries[already..].to_vec() + }; + if !nuevos.is_empty() { + report.sources.push(src.path.clone()); + } + st.ultimo_ts = entries.iter().map(|e| e.started).max().unwrap_or(0).max(st.ultimo_ts); + fresh.extend(nuevos); + st.imported = entries.len(); + st.size = size; + state_changed = true; + } + + if !fresh.is_empty() { + // Orden cronológico estable: las que no tienen ts (0) conservan su + // orden relativo de aparición (sort_by es estable). + fresh.sort_by(|a, b| a.started.cmp(&b.started)); + report.imported = history.append_bulk(fresh).unwrap_or(0); + } + if state_changed { + let _ = save_import_state(&state); + } + report +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn parse_bash_plain_lines() { + let entries = parse_bash("ls -la\ngit status\n\ncargo build\n"); + assert_eq!(entries.len(), 3); + assert_eq!(entries[0].line, "ls -la"); + assert_eq!(entries[2].line, "cargo build"); + } + + #[test] + fn parse_bash_attaches_timestamps() { + let entries = parse_bash("#1700000000\nls\n#1700000050\ngit pull\n"); + assert_eq!(entries.len(), 2); + assert_eq!(entries[0].line, "ls"); + assert_eq!(entries[0].started, 1700000000); + assert_eq!(entries[1].started, 1700000050); + } + + #[test] + fn parse_zsh_extended_format() { + let text = ": 1700000000:0;ls -la\n: 1700000005:2;cargo build --release\n"; + let entries = parse_zsh(text); + assert_eq!(entries.len(), 2); + assert_eq!(entries[0].line, "ls -la"); + assert_eq!(entries[0].started, 1700000000); + assert_eq!(entries[1].line, "cargo build --release"); + assert_eq!(entries[1].started, 1700000005); + } + + #[test] + fn parse_zsh_plain_format() { + let entries = parse_zsh("ls\npwd\n"); + assert_eq!(entries.len(), 2); + assert_eq!(entries[0].line, "ls"); + } + + #[test] + fn parse_zsh_multiline_continuation() { + // Comando multilínea: zsh lo escribe con `\` al final del renglón. + let text = ": 1700000000:0;for f in *; do\\\n echo $f\\\ndone\n: 1700000010:0;ls\n"; + let entries = parse_zsh(text); + assert_eq!(entries.len(), 2); + assert!(entries[0].line.starts_with("for f in *; do")); + assert!(entries[0].line.contains("echo $f")); + assert!(entries[0].line.contains("done")); + assert_eq!(entries[1].line, "ls"); + } + + #[test] + fn parse_zsh_header_extracts_ts_and_cmd() { + assert_eq!(parse_zsh_header(": 123:0;echo hi"), Some((123, "echo hi"))); + // Comando con `;` propio: sólo se parte en el primero. + assert_eq!( + parse_zsh_header(": 123:0;echo a; echo b"), + Some((123, "echo a; echo b")) + ); + assert_eq!(parse_zsh_header("plain line"), None); + } + + #[test] + fn absorb_is_incremental_across_calls() { + // El estado en disco vive en el data dir real; para no tocarlo en el + // test, ejercitamos sólo los parsers + append_bulk directamente + // (la incrementalidad de disco se cubre en el e2e del shell). + let d = tempfile::tempdir().unwrap(); + let mut h = History::open(d.path().join("h.jsonl")).unwrap(); + let entries = parse_bash("ls\npwd\nls\n"); + // append_bulk colapsa duplicados consecutivos, no los no-consecutivos. + let added = h.append_bulk(entries).unwrap(); + assert_eq!(added, 3); + assert_eq!(h.len(), 3); + } +} + +#[cfg(test)] +mod tests_zsh_metafica { + use super::*; + + /// zsh guarda los bytes altos metaficados; un historial con un solo acento + /// no es UTF-8 y `read_to_string` lo rechaza ENTERO. Eso dejó un historial + /// real de 9.913 líneas sin importar nunca — y con él, 446 usos del comando + /// más tecleado del usuario, invisibles para el autocompletado. + #[test] + fn desmetafica_los_bytes_altos_de_zsh() { + // "café" con la é (U+00E9 = 0xC3 0xA9) metaficada por zsh. + let mut crudo: Vec = b": 1700000000:0;echo caf".to_vec(); + for b in [0xC3u8, 0xA9] { + crudo.push(0x83); + crudo.push(b ^ 32); + } + crudo.push(b'\n'); + assert!( + String::from_utf8(crudo.clone()).is_err(), + "el crudo NO debe ser UTF-8: si lo fuera, el test no probaría nada" + ); + let limpio = desmetaficar(&crudo); + let texto = String::from_utf8(limpio).expect("desmetaficado debe ser UTF-8"); + let entradas = parse_zsh(&texto); + assert_eq!(entradas.len(), 1); + assert_eq!(entradas[0].line, "echo café"); + assert_eq!(entradas[0].started, 1_700_000_000); + } + + /// Un `0x83` al final, sin byte que le siga, no debe colgar ni entrar en + /// pánico: se copia tal cual. + #[test] + fn un_marcador_huerfano_al_final_no_rompe() { + assert_eq!(desmetaficar(&[b'a', 0x83]), vec![b'a', 0x83]); + } + + /// La marca de agua por timestamp es lo que hace la importación idempotente + /// aunque zsh **reescriba** el fichero al recortarlo por `SAVEHIST` — que es + /// lo que duplicó 68 líneas hasta 25.596 copias. + #[test] + fn solo_entra_lo_posterior_a_la_marca() { + let texto = ": 100:0;uno\n: 200:0;dos\n: 300:0;tres\n"; + let e = parse_zsh(texto); + assert_eq!(e.len(), 3); + let marca = 200u64; + let nuevas: Vec<_> = e.iter().filter(|x| x.started > marca).collect(); + assert_eq!(nuevas.len(), 1); + assert_eq!(nuevas[0].line, "tres"); + } +} diff --git a/02_ruway/shuma/sandbox/shuma-history/src/lib.rs b/02_ruway/shuma/sandbox/shuma-history/src/lib.rs index 90e5b7f..694ad2c 100644 --- a/02_ruway/shuma/sandbox/shuma-history/src/lib.rs +++ b/02_ruway/shuma/sandbox/shuma-history/src/lib.rs @@ -26,6 +26,8 @@ use std::path::{Path, PathBuf}; use serde::{Deserialize, Serialize}; +pub mod foreign; + /// Una entrada del historial durable — la línea y su contexto mínimo. #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] pub struct Entry { @@ -191,6 +193,35 @@ impl History { Ok(true) } + /// Appendea muchas entradas en una sola apertura del fichero — pensado + /// para **importación en bloque** (historiales ajenos). Colapsa + /// duplicados *consecutivos* (idéntica `line`) y saltea líneas vacías, + /// independiente de la [`DedupPolicy`] activa (la importación no debe + /// reescribir el fichero entero por cada entrada). Devuelve cuántas se + /// añadieron de verdad. + pub fn append_bulk( + &mut self, + entries: impl IntoIterator, + ) -> io::Result { + let mut f = OpenOptions::new().create(true).append(true).open(&self.path)?; + let mut added = 0usize; + for entry in entries { + if entry.line.trim().is_empty() { + continue; + } + if self.entries.last().is_some_and(|e| e.line == entry.line) { + continue; + } + let mut s = serde_json::to_string(&entry).map_err(io::Error::other)?; + s.push('\n'); + f.write_all(s.as_bytes())?; + self.entries.push(entry); + added += 1; + } + f.flush()?; + Ok(added) + } + /// Actualiza la última entrada con el código de salida y la duración /// cuando el comando termina. Persiste reescribiendo el fichero. pub fn finalize_last(&mut self, exit: i32, duration_ms: u64) -> io::Result<()> { diff --git a/02_ruway/shuma/sandbox/shuma-intent/src/lib.rs b/02_ruway/shuma/sandbox/shuma-intent/src/lib.rs index d520c89..9041ec3 100644 --- a/02_ruway/shuma/sandbox/shuma-intent/src/lib.rs +++ b/02_ruway/shuma/sandbox/shuma-intent/src/lib.rs @@ -6,7 +6,7 @@ //! historial como un grafo de contexto navegable: cada comando es un //! nodo, cada salida un buffer intermedio referenciable. //! -//! Todo acá es lógica pura y serializable — el front-end GPUI (las tres +//! Todo aquí es lógica pura y serializable — el front-end GPUI (las tres //! zonas: RUN, SENS y el lienzo central) lo rehidrata; la ejecución real //! la hace `sandokan`. diff --git a/02_ruway/shuma/sandbox/shuma-line/src/decorate.rs b/02_ruway/shuma/sandbox/shuma-line/src/decorate.rs index 9485d47..0d4a59c 100644 --- a/02_ruway/shuma/sandbox/shuma-line/src/decorate.rs +++ b/02_ruway/shuma/sandbox/shuma-line/src/decorate.rs @@ -71,6 +71,30 @@ pub enum DecorationKind { /// con la fuente monospace + color accent para que los bordes /// calcen entre filas y se vean como una caja real. BoxDraw, + /// Número suelto (conteos, tamaños, ids), con sufijo de unidad + /// opcional (`248`, `1024K`, `1.3 GiB` captura sólo `1.3`+unidad + /// pegada). Sin acción de click — sólo color. + Number, + /// Fecha u hora reconocible: ISO (`2026-06-12`), hora (`10:12`, + /// `10:12:33`) o `mes día` estilo `ls -l` (`jun 9`). Sólo color. + DateTime, + /// Palabra de estado con carga semántica (error/warning/ok) — el + /// frontend la tiñe rojo/amarillo/verde para escanear de un vistazo. + Severity(Severity), + /// Versión tipo semver, con `v` opcional (`v1.2.3`, `0.7.0`). + Version, + /// Porcentaje (`85%`, `99.7%`). Sólo color. + Percent, + /// Máscara de permisos estilo `ls -l` (`drwxr-xr-x`, `-rw-r--r--+`). + PermMask, +} + +/// Nivel semántico de una palabra de estado en el output. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +pub enum Severity { + Error, + Warn, + Ok, } /// Punto de entrada: detecta decoraciones para una línea. `cwd` se usa @@ -95,6 +119,15 @@ pub fn decorate_line(line: &str, cwd: &Path) -> Vec { find_git_shas(line, &mut out); find_issue_refs(line, &mut out); find_paths(line, cwd, &mut out); + // Coloreo semántico de relleno — va al FINAL para que cualquier + // decoración accionable (path/url/sha) le gane el rango. De lo más + // específico a lo más genérico, cada finder respeta `overlaps_any`. + find_perm_masks(line, &mut out); + find_versions(line, &mut out); + find_datetimes(line, &mut out); + find_percents(line, &mut out); + find_severities(line, &mut out); + find_numbers(line, &mut out); out.sort_by_key(|d| d.start); let mut merged: Vec = Vec::with_capacity(out.len()); for d in out { @@ -247,6 +280,315 @@ fn is_end_boundary(line: &str, pos: usize) -> bool { !(next.is_ascii_alphanumeric() || next == b'_') } +// --- Coloreo semántico de relleno (números, fechas, severidades…) --- + +/// Máscara de permisos `ls -l`: tipo + 9 de rwx, con sufijo ACL/SELinux +/// opcional (`+`/`.`). Muy específica, va primero entre los de relleno. +fn find_perm_masks(line: &str, out: &mut Vec) { + let bytes = line.as_bytes(); + let n = bytes.len(); + let mut i = 0; + while i + 10 <= n { + if !is_boundary(line, i) || !matches!(bytes[i], b'-' | b'd' | b'l' | b'b' | b'c' | b's' | b'p') { + i += 1; + continue; + } + let perms_ok = (1..10).all(|k| { + let c = bytes[i + k]; + let esperado: &[u8] = match k % 3 { + 1 => b"r-", + 2 => b"w-", + _ => b"xsStT-", + }; + esperado.contains(&c) + }); + let mut end = i + 10; + if perms_ok { + if end < n && matches!(bytes[end], b'+' | b'.') { + end += 1; + } + if is_end_boundary(line, end) && !overlaps_any(i, end, out) { + out.push(Decoration { start: i, end, kind: DecorationKind::PermMask }); + } + i = end; + } else { + i += 1; + } + } +} + +/// Versiones semver con `v` opcional: `v1.2.3`, `0.7.0`, `1.45.0-rc1`. +/// Exige al menos DOS puntos (un `1.5` suelto es un número decimal). +fn find_versions(line: &str, out: &mut Vec) { + let bytes = line.as_bytes(); + let n = bytes.len(); + let mut i = 0; + while i < n { + if !is_boundary(line, i) { + i += 1; + continue; + } + let start = i; + let mut j = i; + if j < n && bytes[j] == b'v' { + j += 1; + } + let mut grupos = 0; + loop { + let d0 = j; + while j < n && bytes[j].is_ascii_digit() { + j += 1; + } + if j == d0 { + break; + } + grupos += 1; + if j < n && bytes[j] == b'.' && j + 1 < n && bytes[j + 1].is_ascii_digit() { + j += 1; + } else { + break; + } + } + // Sufijo pre-release pegado (`-rc1`, `-beta.2`). + if grupos >= 3 && j < n && bytes[j] == b'-' { + let mut k = j + 1; + while k < n && (bytes[k].is_ascii_alphanumeric() || bytes[k] == b'.') { + k += 1; + } + if k > j + 1 { + j = k; + } + } + if grupos >= 3 && is_end_boundary(line, j) && !overlaps_any(start, j, out) { + out.push(Decoration { start, end: j, kind: DecorationKind::Version }); + i = j; + } else { + i = (start + 1).max(j.min(start + 1)); + } + } +} + +const MESES: &[&str] = &[ + "ene", "feb", "mar", "abr", "may", "jun", "jul", "ago", "sep", "oct", "nov", "dic", + "jan", "apr", "aug", "dec", +]; + +/// Fechas y horas: ISO `2026-06-12`, horas `10:12[:33]`, y `mes día` +/// estilo `ls -l` (`jun 9`). Sólo coloreo, sin acción. +fn find_datetimes(line: &str, out: &mut Vec) { + let bytes = line.as_bytes(); + let n = bytes.len(); + // ISO: dddd-dd-dd + let mut i = 0; + while i + 10 <= n { + if is_boundary(line, i) + && bytes[i..i + 4].iter().all(u8::is_ascii_digit) + && bytes[i + 4] == b'-' + && bytes[i + 5..i + 7].iter().all(u8::is_ascii_digit) + && bytes[i + 7] == b'-' + && bytes[i + 8..i + 10].iter().all(u8::is_ascii_digit) + && is_end_boundary(line, i + 10) + && !overlaps_any(i, i + 10, out) + { + out.push(Decoration { start: i, end: i + 10, kind: DecorationKind::DateTime }); + i += 10; + } else { + i += 1; + } + } + // Hora: d?d:dd(:dd)? + let mut i = 0; + while i < n { + if !is_boundary(line, i) || !bytes[i].is_ascii_digit() { + i += 1; + continue; + } + let start = i; + let mut j = i; + while j < n && bytes[j].is_ascii_digit() { + j += 1; + } + if j - start <= 2 && j + 2 < n && bytes[j] == b':' && bytes[j + 1].is_ascii_digit() && bytes[j + 2].is_ascii_digit() { + let mut end = j + 3; + if end + 2 < n + && bytes[end] == b':' + && bytes[end + 1].is_ascii_digit() + && bytes[end + 2].is_ascii_digit() + { + end += 3; + } + if is_end_boundary(line, end) && !overlaps_any(start, end, out) { + out.push(Decoration { start, end, kind: DecorationKind::DateTime }); + } + i = end; + } else { + i = j.max(start + 1); + } + } + // `mes día` (ls -l): palabra de 3 letras del set + espacios + 1-2 dígitos. + let lower = line.to_ascii_lowercase(); + let lb = lower.as_bytes(); + let mut i = 0; + while i + 3 <= n { + // Sólo runs ASCII alfabéticos de 3 bytes (los meses lo son); un byte + // no-ASCII aquí sería el medio de un char multibyte — no sliceable. + if !is_boundary(line, i) + || !lb[i..i + 3].iter().all(u8::is_ascii_alphabetic) + { + i += 1; + continue; + } + let word = &lower[i..i + 3]; + if MESES.contains(&word) && is_end_boundary(line, i + 3) { + // espacios + día + let mut j = i + 3; + while j < n && lb[j] == b' ' { + j += 1; + } + let d0 = j; + while j < n && lb[j].is_ascii_digit() { + j += 1; + } + let digits = j - d0; + if (1..=2).contains(&digits) + && j - i <= 7 + && is_end_boundary(line, j) + && !overlaps_any(i, j, out) + { + out.push(Decoration { start: i, end: j, kind: DecorationKind::DateTime }); + i = j; + continue; + } + } + i += 1; + } +} + +/// Porcentajes: `85%`, `99.7%`. +fn find_percents(line: &str, out: &mut Vec) { + let bytes = line.as_bytes(); + let n = bytes.len(); + let mut i = 0; + while i < n { + if !is_boundary(line, i) || !bytes[i].is_ascii_digit() { + i += 1; + continue; + } + let start = i; + let mut j = i; + while j < n && (bytes[j].is_ascii_digit() || bytes[j] == b'.') { + j += 1; + } + if j < n && bytes[j] == b'%' && !overlaps_any(start, j + 1, out) { + out.push(Decoration { start, end: j + 1, kind: DecorationKind::Percent }); + i = j + 1; + } else { + i = j.max(start + 1); + } + } +} + +/// Palabras de estado (case-insensitive) + glifos ✔/✓/✖/✗/⚠. +fn find_severities(line: &str, out: &mut Vec) { + const ERR: &[&str] = &[ + "error", "err", "failed", "failure", "fail", "fatal", "panic", "denied", "abort", + "aborted", "rechazado", "fallo", "falló", + ]; + const WARN: &[&str] = &["warning", "warn", "aviso", "deprecated", "stale"]; + const OK: &[&str] = &[ + "ok", "done", "success", "succeeded", "passed", "ready", "finished", "listo", "hecho", + ]; + let lower = line.to_ascii_lowercase(); + // Palabras: escaneo por tokens alfabéticos. + let lb = lower.as_bytes(); + let n = lb.len(); + let mut i = 0; + while i < n { + if !lb[i].is_ascii_alphabetic() { + i += 1; + continue; + } + let start = i; + let mut j = i; + while j < n && lb[j].is_ascii_alphabetic() { + j += 1; + } + let word = &lower[start..j]; + let sev = if ERR.contains(&word) { + Some(Severity::Error) + } else if WARN.contains(&word) { + Some(Severity::Warn) + } else if OK.contains(&word) { + Some(Severity::Ok) + } else { + None + }; + if let Some(sev) = sev { + if is_boundary(line, start) && is_end_boundary(line, j) && !overlaps_any(start, j, out) + { + out.push(Decoration { start, end: j, kind: DecorationKind::Severity(sev) }); + } + } + i = j; + } + // Glifos sueltos. + for (idx, c) in line.char_indices() { + let sev = match c { + '✔' | '✓' => Severity::Ok, + '✖' | '✗' => Severity::Error, + '⚠' => Severity::Warn, + _ => continue, + }; + let end = idx + c.len_utf8(); + if !overlaps_any(idx, end, out) { + out.push(Decoration { start: idx, end, kind: DecorationKind::Severity(sev) }); + } + } +} + +/// Números sueltos (enteros o decimales), con sufijo de unidad corto +/// pegado (`248`, `4096`, `1.3M`, `512KB`, `350ms`). El finder más +/// genérico: va último y respeta todo lo ya reclamado. +fn find_numbers(line: &str, out: &mut Vec) { + let bytes = line.as_bytes(); + let n = bytes.len(); + let mut i = 0; + while i < n { + if !is_boundary(line, i) || !bytes[i].is_ascii_digit() { + i += 1; + continue; + } + let start = i; + let mut j = i; + let mut punto = false; + while j < n { + let c = bytes[j]; + if c.is_ascii_digit() { + j += 1; + } else if c == b'.' && !punto && j + 1 < n && bytes[j + 1].is_ascii_digit() { + punto = true; + j += 1; + } else { + break; + } + } + // Sufijo de unidad pegado, hasta 3 letras (K, MB, GiB, ms, s). + let mut end = j; + let mut letras = 0; + while end < n && letras < 3 && bytes[end].is_ascii_alphabetic() { + end += 1; + letras += 1; + } + if end < n && (bytes[end].is_ascii_alphanumeric() || bytes[end] == b'_') { + end = j; // sufijo demasiado largo → no era unidad; sólo el número + } + if is_end_boundary(line, end) && !overlaps_any(start, end, out) { + out.push(Decoration { start, end, kind: DecorationKind::Number }); + } + i = end.max(start + 1); + } +} + // --- URL detection --- const URL_PREFIXES: &[&str] = &["http://", "https://", "file://", "ftp://", "ssh://"]; diff --git a/02_ruway/shuma/sandbox/shuma-line/src/editor.rs b/02_ruway/shuma/sandbox/shuma-line/src/editor.rs index 56d6c9b..4eb7493 100644 --- a/02_ruway/shuma/sandbox/shuma-line/src/editor.rs +++ b/02_ruway/shuma/sandbox/shuma-line/src/editor.rs @@ -20,6 +20,11 @@ pub struct LineState { /// Offset de byte del cursor; invariante: siempre en límite de carácter. cursor: usize, dialect: Dialect, + /// Ancla de selección (offset de byte). `Some` cuando hay una selección + /// viva entre `anchor` y `cursor`. Las ediciones y los movimientos sin + /// `shift` la limpian. `#[serde(default)]` para leer estados viejos. + #[serde(default)] + anchor: Option, } impl LineState { @@ -56,28 +61,147 @@ impl LineState { pub fn set_text(&mut self, text: impl Into) { self.text = text.into(); self.cursor = self.text.len(); + self.anchor = None; } /// Vacía la línea. pub fn clear(&mut self) { self.text.clear(); self.cursor = 0; + self.anchor = None; } - /// Inserta texto en el cursor y lo avanza. + /// Inserta texto en el cursor y lo avanza. Si hay selección viva, la + /// reemplaza primero (comportamiento estándar de editor). pub fn insert(&mut self, s: &str) { + self.delete_selection(); self.text.insert_str(self.cursor, s); self.cursor += s.len(); } + // ── Selección ── + + /// Offset de ancla de la selección, si hay. + pub fn anchor(&self) -> Option { + self.anchor + } + + /// Empieza (o continúa) una selección: si no había ancla, la fija en el + /// cursor actual. Llamar antes de un movimiento con `shift`. + pub fn begin_or_extend_selection(&mut self) { + if self.anchor.is_none() { + self.anchor = Some(self.cursor); + } + } + + /// Limpia la selección (sin tocar el texto ni el cursor). + pub fn clear_selection(&mut self) { + self.anchor = None; + } + + /// Rango `[start, end)` de la selección en bytes (ordenado), o `None`. + pub fn selection(&self) -> Option<(usize, usize)> { + let a = self.anchor?; + if a == self.cursor { + return None; + } + Some((a.min(self.cursor), a.max(self.cursor))) + } + + /// Texto seleccionado, si hay. + pub fn selected_text(&self) -> Option { + let (s, e) = self.selection()?; + Some(self.text[s..e].to_string()) + } + + /// Selecciona toda la línea (ancla al inicio, cursor al final). + pub fn select_all(&mut self) { + self.anchor = Some(0); + self.cursor = self.text.len(); + } + + /// Posa el cursor en `byte` (clampeado al límite de carácter anterior). + /// NO toca la selección — el caller decide si ancla o limpia (el click + /// simple limpia; el arrastre ancla antes de mover). + pub fn set_cursor(&mut self, byte: usize) { + self.cursor = self.clamp_boundary(byte); + } + + /// Selecciona el rango `[a, b)` (cada extremo clampeado a límite de + /// carácter): ancla en `a`, cursor en `b`. Con `a == b` queda sin + /// selección (sólo cursor). + pub fn select_range(&mut self, a: usize, b: usize) { + let a = self.clamp_boundary(a); + let b = self.clamp_boundary(b); + self.anchor = Some(a); + self.cursor = b; + } + + /// Selecciona la **palabra** que cubre `byte` (separada por whitespace, + /// como los movimientos de palabra). Sobre un espacio o fuera del texto, + /// posa el cursor ahí sin seleccionar. Para el doble-click. + pub fn select_word_at(&mut self, byte: usize) { + let b = self.clamp_boundary(byte); + let en_palabra = self.text[b..].chars().next().map(|c| !c.is_whitespace()); + if en_palabra != Some(true) { + self.anchor = None; + self.cursor = b; + return; + } + let mut ini = b; + while let Some(ch) = self.text[..ini].chars().next_back() { + if ch.is_whitespace() { + break; + } + ini -= ch.len_utf8(); + } + let mut fin = b; + while let Some(ch) = self.text[fin..].chars().next() { + if ch.is_whitespace() { + break; + } + fin += ch.len_utf8(); + } + self.anchor = Some(ini); + self.cursor = fin; + } + + /// Clampea un offset de byte al límite de carácter ≤ más cercano (y al + /// largo del texto). Mantiene la invariante del cursor ante offsets + /// calculados desde píxeles. + fn clamp_boundary(&self, byte: usize) -> usize { + let mut b = byte.min(self.text.len()); + while b > 0 && !self.text.is_char_boundary(b) { + b -= 1; + } + b + } + + /// Si hay selección, la borra y deja el cursor en su inicio. Devuelve + /// `true` si borró algo. + pub fn delete_selection(&mut self) -> bool { + if let Some((s, e)) = self.selection() { + self.text.replace_range(s..e, ""); + self.cursor = s; + self.anchor = None; + true + } else { + self.anchor = None; + false + } + } + /// Inserta un carácter en el cursor. pub fn insert_char(&mut self, c: char) { let mut buf = [0u8; 4]; self.insert(c.encode_utf8(&mut buf)); } - /// Borra el carácter a la izquierda del cursor. + /// Borra el carácter a la izquierda del cursor (o la selección, si hay). pub fn backspace(&mut self) { + if self.delete_selection() { + return; + } if let Some(prev) = self.text[..self.cursor].chars().next_back() { let bl = prev.len_utf8(); self.text.replace_range(self.cursor - bl..self.cursor, ""); @@ -85,8 +209,11 @@ impl LineState { } } - /// Borra el carácter a la derecha del cursor. + /// Borra el carácter a la derecha del cursor (o la selección, si hay). pub fn delete(&mut self) { + if self.delete_selection() { + return; + } if let Some(next) = self.text[self.cursor..].chars().next() { let nl = next.len_utf8(); self.text.replace_range(self.cursor..self.cursor + nl, ""); @@ -207,6 +334,72 @@ mod tests { assert_eq!(l.cursor(), 2); } + #[test] + fn select_all_and_copy_text() { + let mut l = LineState::new(); + l.insert("ls -la"); + l.select_all(); + assert_eq!(l.selection(), Some((0, 6))); + assert_eq!(l.selected_text().as_deref(), Some("ls -la")); + } + + #[test] + fn insert_replaces_live_selection() { + let mut l = LineState::new(); + l.insert("hola"); + l.select_all(); + l.insert("chau"); + assert_eq!(l.text(), "chau"); + assert!(l.selection().is_none(), "tras reemplazar no queda selección"); + } + + #[test] + fn shift_extend_then_backspace_deletes_selection() { + let mut l = LineState::new(); + l.insert("abcdef"); + // Simula Shift+Home: ancla en cursor (6), luego mueve a inicio. + l.begin_or_extend_selection(); + l.move_home(); + assert_eq!(l.selection(), Some((0, 6))); + l.backspace(); + assert_eq!(l.text(), "", "backspace borra la selección entera"); + } + + #[test] + fn set_cursor_clamps_to_char_boundary() { + let mut l = LineState::new(); + l.set_text("café x"); + // 'é' ocupa los bytes 3-4: caer en el medio clampa al inicio del char. + l.set_cursor(4); + assert_eq!(l.cursor(), 3); + l.set_cursor(999); + assert_eq!(l.cursor(), l.text().len()); + } + + #[test] + fn select_word_at_takes_the_word_under_the_byte() { + let mut l = LineState::new(); + l.set_text("git commit -m hola"); + l.select_word_at(6); // dentro de "commit" + assert_eq!(l.selected_text().as_deref(), Some("commit")); + // Sobre el espacio: cursor ahí, sin selección. + l.select_word_at(3); + assert!(l.selection().is_none()); + assert_eq!(l.cursor(), 3); + } + + #[test] + fn select_range_orders_and_selects() { + let mut l = LineState::new(); + l.set_text("abcdef"); + l.select_range(1, 4); + assert_eq!(l.selected_text().as_deref(), Some("bcd")); + // Arrastre hacia la izquierda (cursor < ancla) también vale. + l.select_range(5, 2); + assert_eq!(l.selection(), Some((2, 5))); + assert_eq!(l.cursor(), 2); + } + #[test] fn editing_is_utf8_safe() { let mut l = LineState::new(); diff --git a/02_ruway/shuma/sandbox/shuma-line/src/icon.rs b/02_ruway/shuma/sandbox/shuma-line/src/icon.rs index a46dc6e..6baf9f9 100644 --- a/02_ruway/shuma/sandbox/shuma-line/src/icon.rs +++ b/02_ruway/shuma/sandbox/shuma-line/src/icon.rs @@ -9,7 +9,7 @@ //! //! Espíritu del repo: no inventamos un set de iconos propio cuando el //! `lens` de `shuma-discern` ya clasifica por familia (gallery/audio/ -//! video/...). Acá cubrimos el caso del shell, donde sólo tenemos el +//! video/...). Aquí cubrimos el caso del shell, donde sólo tenemos el //! path en disco (sin samplear bytes), así que vamos por extensión. //! //! Dos salidas paralelas, ambas UI-agnósticas: diff --git a/02_ruway/shuma/sandbox/shuma-line/src/lib.rs b/02_ruway/shuma/sandbox/shuma-line/src/lib.rs index 2a3cc43..1bfbe91 100644 --- a/02_ruway/shuma/sandbox/shuma-line/src/lib.rs +++ b/02_ruway/shuma/sandbox/shuma-line/src/lib.rs @@ -36,7 +36,7 @@ pub use complete::{ complete, flag_hints, Completion, CompletionKind, CompletionSource, StaticSource, }; pub use continuation::needs_continuation; -pub use decorate::{decorate_line, Decoration, DecorationKind}; +pub use decorate::{decorate_line, Decoration, DecorationKind, Severity}; pub use dialect::Dialect; pub use editor::LineState; pub use ghost::ghost_suggestion; diff --git a/02_ruway/shuma/sandbox/shuma-module-agente/Cargo.toml b/02_ruway/shuma/sandbox/shuma-module-agente/Cargo.toml new file mode 100644 index 0000000..271d953 --- /dev/null +++ b/02_ruway/shuma/sandbox/shuma-module-agente/Cargo.toml @@ -0,0 +1,32 @@ +[package] +name = "shuma-module-agente" +version.workspace = true +edition.workspace = true +license.workspace = true +authors.workspace = true +publish.workspace = true +description = "shuma-module-agente — panel de chat multi-agente al estilo apps web de IA: sidebar de conversaciones + selector de agente + hilo con bloques ricos (texto/código/acción) y tarjetas aprobar/rechazar. Frontend del núcleo shuma-agente; el chasis corre pluma-llm (shuma-agente-host)." + +[dependencies] +shuma-module = { path = "../shuma-module" } +# Indicador de escucha compartido (EstadoEscucha + botón de mic animado): el +# mismo widget que la command-bar y el shell input — un solo «llamado shuma». +shuma-voz-ui = { path = "../shuma-voz-ui" } +shuma-agente = { path = "../shuma-agente" } +wawa-config = { workspace = true } +# Taxonomía TTS + política de lectura discriminada (sólo la prosa se vocaliza). +# El núcleo puro de la voz (sin cpal/tokio): aquí se mapea BloqueSalida → TipoBloque. +rimay-voz-core = { workspace = true } +llimphi-ui = { workspace = true } +llimphi-theme = { workspace = true } +llimphi-widget-button = { workspace = true } +llimphi-widget-scroll = { workspace = true } +llimphi-widget-text-input = { workspace = true } +llimphi-image = { workspace = true } +base64 = { workspace = true } + +[dev-dependencies] +# Render headless del panel a PNG (`--example mic_estados`) para verificar el +# indicador de voz en cada estado de escucha, sin abrir ventana. +png = { workspace = true } +pollster = { workspace = true } diff --git a/02_ruway/shuma/sandbox/shuma-module-agente/LEEME.md b/02_ruway/shuma/sandbox/shuma-module-agente/LEEME.md new file mode 100644 index 0000000..8d4a6c3 --- /dev/null +++ b/02_ruway/shuma/sandbox/shuma-module-agente/LEEME.md @@ -0,0 +1,28 @@ +# shuma-module-agente + +*Read this in English: [README.md](README.md).* + +El panel de chat multi-agente de shuma. + +Frontend del núcleo `shuma_agente`, al estilo de las apps web de IA: +sidebar con la lista de conversaciones + selector de agente, un hilo central +con los turnos (cada bloque del asistente pintado según su tipo) y un input +abajo. Las acciones de control aparecen como tarjetas con **aprobar / +rechazar** — nunca se ejecutan solas. + +Sigue el contrato estructural de los módulos shuma (como +`shuma-module-commandbar`): `State` + `Msg` + `update` puro + `view` + las +funciones de provisión que el chasis llama fuera del `update` +(`State::set_agentes`, `State::set_conversaciones`, `State::fijar_reloj`). + +## Trabajo async (mismo patrón intent que el shell) + +El módulo **no habla con la red**: cuando el usuario manda un mensaje, deja +una `Peticion` en `pendiente`; el chasis la toma con `State::take_request`, +corre `shuma-agente-host::responder` en un thread, y devuelve el resultado +como `Msg::Respuesta`. Igual con las acciones aprobadas +(`State::take_ejecucion`). + +--- + +Parte de **shuma** — ver [shuma](../../LEEME.md). diff --git a/02_ruway/shuma/sandbox/shuma-module-agente/README.md b/02_ruway/shuma/sandbox/shuma-module-agente/README.md new file mode 100644 index 0000000..1eb69e9 --- /dev/null +++ b/02_ruway/shuma/sandbox/shuma-module-agente/README.md @@ -0,0 +1,12 @@ +# shuma-module-agente + +shuma's multi-agent chat panel. + +A frontend over the `shuma_agente` core, in the style of the AI web apps: a +sidebar with the conversation list plus an agent selector, a central thread with +the turns (each assistant block painted according to its type) and an input at the +bottom. Control actions appear as cards with **approve / reject**. + +--- + +Part of **shuma** — see [shuma](../../README.md). diff --git a/02_ruway/shuma/sandbox/shuma-module-agente/examples/mic_estados.rs b/02_ruway/shuma/sandbox/shuma-module-agente/examples/mic_estados.rs new file mode 100644 index 0000000..a516318 --- /dev/null +++ b/02_ruway/shuma/sandbox/shuma-module-agente/examples/mic_estados.rs @@ -0,0 +1,164 @@ +//! Render headless del panel de chat con el **indicador de voz** en cada estado +//! de escucha, para verificar el botón de micrófono (halo «cava» animado) y el +//! glow del input sin abrir ventana. Es el caso que la Regla 8 permite mirar: +//! un efecto visual nuevo que no se certifica de otra forma. +//! +//! ```sh +//! cargo run -p shuma-module-agente --example mic_estados +//! # → /tmp/mic_.png (uno por estado) +//! ``` + +use shuma_agente::Agente; +use shuma_module_agente::{view, EstadoEscucha, Msg, State}; + +use llimphi_theme::Theme; +use llimphi_ui::llimphi_hal::{wgpu, Hal}; +use llimphi_ui::llimphi_layout::taffy; +use llimphi_ui::llimphi_layout::LayoutTree; +use llimphi_ui::llimphi_raster::peniko::Color; +use llimphi_ui::llimphi_raster::{vello, Renderer}; +use llimphi_ui::llimphi_text::Typesetter; +use llimphi_ui::{measure_text_node, mount, paint}; + +const W: u32 = 960; +const H: u32 = 560; +const FMT: wgpu::TextureFormat = wgpu::TextureFormat::Rgba8Unorm; + +fn main() { + let dir = std::env::args().nth(1).unwrap_or_else(|| "/tmp".to_string()); + // Cada estado en una fase distinta del reloj para que el halo se vea a media + // expansión (no siempre en r=0). + let estados = [ + ("apagado", EstadoEscucha::Apagado, 0u64), + ("esperando", EstadoEscucha::Esperando, 400), + ("oyendo", EstadoEscucha::Oyendo, 300), + ("despierto", EstadoEscucha::Despierto, 250), + ("dictando", EstadoEscucha::Dictando, 200), + ("enrolando", EstadoEscucha::Apagado, 1), // reloj==1 dispara el modo enrolar + ]; + let hal = pollster::block_on(Hal::new(None)).expect("hal"); + let mut renderer = Renderer::new(&hal).expect("renderer"); + for (nombre, escucha, reloj) in estados { + let out = format!("{dir}/mic_{nombre}.png"); + render_estado(&hal, &mut renderer, escucha, reloj, &out); + eprintln!("mic_estados: {out} ({escucha:?})"); + } +} + +fn render_estado(hal: &Hal, renderer: &mut Renderer, escucha: EstadoEscucha, reloj: u64, out: &str) { + let theme = Theme::dark(); + let mut state = State::new(); + state.set_agentes(vec![Agente::nuevo("shuma")]); + // Alto del hilo acotado para que la barra de input (con el micrófono) quede + // dentro del canvas, no empujada abajo. + state.fijar_vista_alto((H as f32) - 80.0); + state.fijar_reloj(reloj); + state.fijar_escucha(escucha); + if escucha == EstadoEscucha::Dictando { + // Mostramos texto dictado en el input. + state = shuma_module_agente::update(state, Msg::Dictado("abrí cosmos".into())); + } + // Estado especial «enrolando»: la palabra Apagado + un enrolamiento a 1/3. + if matches!(escucha, EstadoEscucha::Apagado) && reloj == 1 { + state = shuma_module_agente::update(state, Msg::EnrolarWake); + state = shuma_module_agente::update(state, Msg::EnrolarCapturado); + } + + let root = view(&state, &theme, |m: Msg| m); + + // view → layout → scene (misma secuencia que el eventloop real). + let mut layout = LayoutTree::new(); + let mounted = mount(&mut layout, root); + let mut ts = Typesetter::new(); + let computed = { + let tmap = &mounted.text_measures; + layout + .compute_with_measure(mounted.root, (W as f32, H as f32), |nid, known, avail| { + match tmap.get(&nid) { + Some(tm) => measure_text_node(&mut ts, tm, known, avail), + None => taffy::Size::ZERO, + } + }) + .expect("layout") + }; + let mut scene = vello::Scene::new(); + paint(&mut scene, &mounted, &computed, &mut ts, None, None); + + let target = hal.device.create_texture(&wgpu::TextureDescriptor { + label: Some("mic-estados"), + size: wgpu::Extent3d { width: W, height: H, depth_or_array_layers: 1 }, + mip_level_count: 1, + sample_count: 1, + dimension: wgpu::TextureDimension::D2, + format: FMT, + usage: wgpu::TextureUsages::STORAGE_BINDING + | wgpu::TextureUsages::TEXTURE_BINDING + | wgpu::TextureUsages::RENDER_ATTACHMENT + | wgpu::TextureUsages::COPY_SRC, + view_formats: &[], + }); + let texview = target.create_view(&wgpu::TextureViewDescriptor::default()); + let [r, g, b, _] = theme.bg_app.components; + let bg = Color::from_rgba8((r * 255.0) as u8, (g * 255.0) as u8, (b * 255.0) as u8, 255); + renderer.render_to_view(hal, &scene, &texview, W, H, bg).expect("render_to_view"); + + write_png(hal, &target, out); +} + +fn write_png(hal: &Hal, target: &wgpu::Texture, path: &str) { + let unpadded = (W * 4) as usize; + let align = wgpu::COPY_BYTES_PER_ROW_ALIGNMENT as usize; + let padded = unpadded.div_ceil(align) * align; + let buf = hal.device.create_buffer(&wgpu::BufferDescriptor { + label: Some("readback"), + size: (padded * H as usize) as u64, + usage: wgpu::BufferUsages::MAP_READ | wgpu::BufferUsages::COPY_DST, + mapped_at_creation: false, + }); + let mut enc = hal + .device + .create_command_encoder(&wgpu::CommandEncoderDescriptor { label: None }); + enc.copy_texture_to_buffer( + wgpu::TexelCopyTextureInfo { + texture: target, + mip_level: 0, + origin: wgpu::Origin3d::ZERO, + aspect: wgpu::TextureAspect::All, + }, + wgpu::TexelCopyBufferInfo { + buffer: &buf, + layout: wgpu::TexelCopyBufferLayout { + offset: 0, + bytes_per_row: Some(padded as u32), + rows_per_image: Some(H), + }, + }, + wgpu::Extent3d { width: W, height: H, depth_or_array_layers: 1 }, + ); + hal.queue.submit(std::iter::once(enc.finish())); + let _ = hal.device.poll(wgpu::PollType::wait_indefinitely()); + + let slice = buf.slice(..); + let (tx, rx) = std::sync::mpsc::channel(); + slice.map_async(wgpu::MapMode::Read, move |r| { + let _ = tx.send(r); + }); + let _ = hal.device.poll(wgpu::PollType::wait_indefinitely()); + rx.recv().unwrap().unwrap(); + let data = slice.get_mapped_range(); + + let mut pixels = Vec::with_capacity((W * H * 4) as usize); + for row in 0..H as usize { + let start = row * padded; + pixels.extend_from_slice(&data[start..start + unpadded]); + } + drop(data); + buf.unmap(); + + let file = std::fs::File::create(path).expect("crear png"); + let w = std::io::BufWriter::new(file); + let mut encoder = png::Encoder::new(w, W, H); + encoder.set_color(png::ColorType::Rgba); + encoder.set_depth(png::BitDepth::Eight); + encoder.write_header().unwrap().write_image_data(&pixels).unwrap(); +} diff --git a/02_ruway/shuma/sandbox/shuma-module-agente/src/lib.rs b/02_ruway/shuma/sandbox/shuma-module-agente/src/lib.rs new file mode 100644 index 0000000..0f52352 --- /dev/null +++ b/02_ruway/shuma/sandbox/shuma-module-agente/src/lib.rs @@ -0,0 +1,2016 @@ +//! `shuma-module-agente` — el panel de chat multi-agente de shuma. +//! +//! Frontend del núcleo [`shuma_agente`], al estilo de las apps web de IA: +//! sidebar con la lista de conversaciones + selector de agente, un hilo central +//! con los turnos (cada bloque del asistente pintado según su tipo) y un input +//! abajo. Las acciones de control aparecen como tarjetas con **aprobar / +//! rechazar** — nunca se ejecutan solas. +//! +//! Sigue el contrato estructural de los módulos shuma (como +//! `shuma-module-commandbar`): `State` + `Msg` + `update` puro + `view` + las +//! funciones de provisión que el chasis llama fuera del `update` +//! ([`State::set_agentes`], [`State::set_conversaciones`], [`State::fijar_reloj`]). +//! +//! ## Trabajo async (mismo patrón intent que el shell) +//! +//! El módulo **no habla con la red**: cuando el usuario manda un mensaje, deja +//! una [`Peticion`] en `pendiente`; el chasis la toma con [`State::take_request`], +//! corre `shuma-agente-host::responder` en un thread, y devuelve el resultado +//! como [`Msg::Respuesta`]. Igual con las acciones aprobadas +//! ([`State::take_ejecucion`]). + +#![forbid(unsafe_code)] + +use llimphi_ui::llimphi_layout::taffy::{ + prelude::{ + length, percent, AlignItems, Dimension, FlexDirection, JustifyContent, LengthPercentage, + Size, Style, + }, + Rect, +}; +use llimphi_ui::llimphi_text::Alignment; +use llimphi_ui::{Key, KeyEvent, KeyState, NamedKey, View}; +use llimphi_theme::Theme; +use llimphi_widget_button::{button_view, ButtonPalette}; +use llimphi_widget_scroll::{scroll_y, ScrollPalette}; +use llimphi_widget_text_input::{ + text_input_view_full, MemClipboard, TextInputEvent, TextInputPalette, TextInputState, +}; +use shuma_agente::{Agente, BloqueSalida, Conversacion, EstadoAccion, Peligro}; +use shuma_module::{ModuleContributions, Placement}; + +/// `id` canónico del módulo. +pub const ID: &str = "agente"; + +/// `Placement` por defecto: ocupa el área principal. +pub const DEFAULT_PLACEMENT: Placement = Placement::Main; + +const SIDEBAR_W: f32 = 150.0; +const VISTA_ALTO_DEFAULT: f32 = 600.0; + +/// Lo que el chasis debe cumplir: responder un turno con `pluma-llm`. El módulo +/// la deja servida; el chasis le inyecta el backend de fallback global. +#[derive(Debug, Clone)] +pub struct Peticion { + /// La conversación con el último mensaje del usuario ya agregado. + pub conv: Conversacion, + /// El agente que la responde (con su backend propio). + pub agente: Agente, +} + +/// Backends que el editor de agentes ofrece (se ciclan con un click). El +/// primero, `claude-cli`, usa la suscripción de Claude Code sin API key. +const BACKENDS: &[&str] = &[ + "claude-cli", + "anthropic", + "gemini", + "deepseek", + "cohere", + "ollama", + "mock", + "", // vacío = heredar el backend global del SO +]; + +/// Qué campo de texto del editor tiene el foco del teclado. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum Campo { + Nombre, + Modelo, + Persona, +} + +/// Formulario de alta/edición de un agente. Abierto = el panel muestra el +/// editor en vez del hilo. +#[derive(Debug, Clone)] +struct EditorAgente { + /// Id del agente que se edita; `None` = uno nuevo. + id: Option, + nombre: TextInputState, + modelo: TextInputState, + persona: TextInputState, + /// Índice en [`BACKENDS`]. + backend_idx: usize, + control: bool, + foco: Campo, +} + +impl EditorAgente { + fn nuevo() -> Self { + Self { + id: None, + nombre: TextInputState::new(), + modelo: TextInputState::new(), + persona: TextInputState::new(), + backend_idx: 0, + control: false, + foco: Campo::Nombre, + } + } + + fn desde(a: &Agente) -> Self { + let backend_idx = BACKENDS + .iter() + .position(|b| *b == a.backend.backend) + .unwrap_or(BACKENDS.len() - 1); + let mut nombre = TextInputState::new(); + nombre.set_text(&a.nombre); + let mut modelo = TextInputState::new(); + modelo.set_text(&a.backend.model); + let mut persona = TextInputState::new(); + persona.set_text(&a.system_prompt); + Self { + id: Some(a.id.clone()), + nombre, + modelo, + persona, + backend_idx, + control: a.capacidades.control, + foco: Campo::Nombre, + } + } + + fn campo_mut(&mut self) -> &mut TextInputState { + self.campo_por(self.foco) + } + + /// El `TextInputState` de un campo dado (para el ruteo por mouse, que trae + /// el campo en el evento en vez de leer el foco). + fn campo_por(&mut self, c: Campo) -> &mut TextInputState { + match c { + Campo::Nombre => &mut self.nombre, + Campo::Modelo => &mut self.modelo, + Campo::Persona => &mut self.persona, + } + } + + /// Construye el `Agente` a guardar a partir del formulario. Conserva el `id` + /// si se edita; uno nuevo si no. + fn a_agente(&self) -> Agente { + let mut a = match &self.id { + Some(id) => { + let mut a = Agente::nuevo(self.nombre.text()); + a.id = id.clone(); + a + } + None => Agente::nuevo(self.nombre.text()), + }; + a.system_prompt = self.persona.text(); + a.backend = wawa_config::LlmSettings { + backend: BACKENDS[self.backend_idx].to_string(), + model: self.modelo.text(), + ..Default::default() + }; + a.capacidades.control = self.control; + a + } +} + +// El estado de escucha y el botón de micrófono animado viven en `shuma-voz-ui` +// (compartidos con la command-bar y el shell input). Se re-exporta `EstadoEscucha` +// para no romper a los consumidores que lo importan de este módulo (el chasis). +pub use shuma_voz_ui::EstadoEscucha; + +/// Estado del panel de chat. Las conversaciones y agentes los **provee el +/// chasis** desde el [`shuma_agente::Almacen`]; el módulo los edita en memoria y +/// el chasis persiste tras cada `update`. +#[derive(Debug, Clone)] +pub struct State { + agentes: Vec, + /// Índice del agente activo dentro de `agentes`. + agente_sel: usize, + /// Conversaciones, más recientes primero (orden del sidebar). + conversaciones: Vec, + /// Id de la conversación abierta (estable ante reordenamientos). + conv_activa: Option, + input: TextInputState, + focused: bool, + scroll: f32, + /// Reloj inyectado por el chasis (epoch ms) — el `update` no lee el reloj. + reloj_ms: u64, + /// Alto del viewport del hilo (px) — lo fija el chasis según el panel. + vista_alto: f32, + /// `true` mientras un turno está en vuelo (lo tomó el chasis). + esperando: bool, + /// Intent de responder un turno; `None` salvo entre el envío y su resultado. + pendiente: Option, + /// Intent de ejecutar una acción aprobada; lo corre el chasis (shell). + ejecucion: Option, + /// Texto del turno del asistente que está llegando en streaming; `None` + /// salvo entre el envío y la respuesta final. Se pinta como una burbuja viva. + parcial: Option, + /// Editor de agente abierto; `None` = se muestra el hilo. + editor: Option, + /// Intent: agente a persistir (alta/edición); lo escribe el chasis al Almacen. + persist_agente: Option, + /// Intent: id de agente a borrar; lo borra el chasis del Almacen. + borrar_agente_id: Option, + /// Intent: id de conversación a borrar del Almacen. + borrar_conv_id: Option, + /// Renombre en curso: `(id de conversación, input con el título)`. `None` = + /// no se está renombrando. + renombrando: Option<(String, TextInputState)>, + /// Estado de la escucha por voz (lo fija el chasis con `fijar_escucha`). + escucha: EstadoEscucha, + /// Intent: el usuario pidió encender (`Some(true)`) o apagar (`Some(false)`) + /// el micrófono; el chasis lo toma con [`State::tomar_mic_intent`] y arranca + /// o para la captura de `rimay-voz-host`. `None` = nada pendiente. + mic_intent: Option, + /// Enrolamiento del wake-word en curso: `Some(n)` = ya se grabaron `n` + /// muestras de «shuma» (de [`ENROL_OBJETIVO`]); `None` = no se está enrolando. + enrolando: Option, + /// Intent de enrolar (`Some(true)` arrancar, `Some(false)` cancelar); el + /// chasis lo toma con [`State::tomar_enrol_intent`] y corre `rimay_voz_host::enrolar`. + enrol_intent: Option, + /// `true` si ya hay un wake-word enrolado (lo fija el chasis al cargar / tras + /// enrolar). Sólo rotula la UI; la compuerta real la monta el chasis. + wake_listo: bool, + /// Lectura TTS de las respuestas activada (opt-in, doctrina VOZ.md: la voz no + /// lee sola). Con esto en `true`, al cerrar un turno del asistente se stagea la + /// **prosa** (sólo `BloqueSalida::Texto`) en `leer_intent`. + leer_voz: bool, + /// Intent de lectura: la prosa vocalizable de la última respuesta, lista para + /// que el chasis la sintetice y reproduzca. El chasis la toma con + /// [`State::tomar_leer_intent`]. `None` = nada que leer. + leer_intent: Option, + /// Portapapeles intra-módulo para copiar/cortar/pegar en los campos de + /// texto. Es un [`MemClipboard`] (no el del sistema): el módulo corre en + /// sandbox y no debe alcanzar el portapapeles global; copiar/pegar viven + /// dentro del panel. Ver [`TextInputState::handle`]. + clipboard: MemClipboard, +} + +/// Cuántas grabaciones de «shuma» pide el enrolamiento. +pub const ENROL_OBJETIVO: u8 = 3; + +impl Default for State { + fn default() -> Self { + Self::new() + } +} + +impl State { + pub fn new() -> Self { + Self { + agentes: Vec::new(), + agente_sel: 0, + conversaciones: Vec::new(), + conv_activa: None, + input: TextInputState::new(), + focused: false, + scroll: 0.0, + reloj_ms: 0, + vista_alto: VISTA_ALTO_DEFAULT, + esperando: false, + pendiente: None, + ejecucion: None, + parcial: None, + editor: None, + persist_agente: None, + borrar_agente_id: None, + borrar_conv_id: None, + renombrando: None, + escucha: EstadoEscucha::Apagado, + mic_intent: None, + enrolando: None, + enrol_intent: None, + wake_listo: false, + leer_voz: false, + leer_intent: None, + clipboard: MemClipboard::new(), + } + } + + // ── Provisión por el chasis (fuera del update, como set_catalog) ──────── + + /// Inyecta los agentes disponibles (del Almacen). Mantiene la selección en + /// rango. + pub fn set_agentes(&mut self, agentes: Vec) { + self.agentes = agentes; + if self.agente_sel >= self.agentes.len() { + self.agente_sel = 0; + } + } + + /// Fija el estado de la escucha por voz (lo llama el chasis al recibir un + /// `EventoEscucha` de `rimay-voz-host`). Si la escucha se apagó por su cuenta + /// (timeout, error), el indicador vuelve a apagado. + pub fn fijar_escucha(&mut self, e: EstadoEscucha) { + self.escucha = e; + } + + /// Estado actual de la escucha (para el chasis / tests). + pub fn escucha(&self) -> EstadoEscucha { + self.escucha + } + + /// Toma el intent de encender/apagar el micrófono y lo limpia. El chasis lo + /// consulta tras cada `update` y arranca o para `rimay-voz-host`. + pub fn tomar_mic_intent(&mut self) -> Option { + self.mic_intent.take() + } + + /// Toma el intent de enrolar (arrancar/cancelar) y lo limpia. + pub fn tomar_enrol_intent(&mut self) -> Option { + self.enrol_intent.take() + } + + /// `true` si la lectura TTS de las respuestas está activada. + pub fn leer_voz(&self) -> bool { + self.leer_voz + } + + /// Toma la prosa a leer que dejó la última respuesta (la limpia). El chasis + /// la consulta tras cada `update`, la sintetiza con el `rimay_voz::Locutor` + /// y la reproduce. `None` = nada que leer. + pub fn tomar_leer_intent(&mut self) -> Option { + self.leer_intent.take() + } + + /// Progreso del enrolamiento (`Some(n)` grabadas, `None` si no enrola). + pub fn enrolando(&self) -> Option { + self.enrolando + } + + /// El chasis avisa que grabó una muestra más de «shuma» (avanza el contador). + pub fn enrol_capturado(&mut self) { + if let Some(n) = self.enrolando.as_mut() { + *n = n.saturating_add(1).min(ENROL_OBJETIVO); + } + } + + /// El chasis avisa que el enrolamiento terminó y el wake-word quedó listo. + pub fn enrol_terminado(&mut self) { + self.enrolando = None; + self.wake_listo = true; + } + + /// El chasis fija si ya hay un wake-word enrolado (al cargar la config). + pub fn set_wake_listo(&mut self, listo: bool) { + self.wake_listo = listo; + } + + /// Inyecta las conversaciones (más recientes primero). Si la activa ya no + /// existe, la deselecciona. + pub fn set_conversaciones(&mut self, convs: Vec) { + if let Some(id) = &self.conv_activa { + if !convs.iter().any(|c| &c.id == id) { + self.conv_activa = None; + } + } + self.conversaciones = convs; + } + + /// Fija el reloj (epoch ms) que usa el `update` para estampar turnos. + pub fn fijar_reloj(&mut self, ms: u64) { + self.reloj_ms = ms; + } + + /// Abre la conversación más reciente si no hay ninguna activa (al arrancar, + /// para reanudar donde se dejó — como las apps web de IA). No-op si ya hay + /// una abierta o no hay conversaciones. + pub fn abrir_mas_reciente(&mut self) { + if self.conv_activa.is_none() { + if let Some(c) = self.conversaciones.first() { + self.conv_activa = Some(c.id.clone()); + } + } + } + + /// Fija el alto del viewport del hilo (px). + pub fn fijar_vista_alto(&mut self, h: f32) { + self.vista_alto = h.max(120.0); + } + + /// Marca el input como (des)enfocado. El chasis lo enfoca cuando el diente + /// del chat está activo. + pub fn set_focus(&mut self, f: bool) { + self.focused = f; + } + + /// `true` si hay una petición servida esperando que el chasis la corra. + pub fn tiene_pendiente(&self) -> bool { + self.pendiente.is_some() + } + + /// El chasis toma la petición pendiente para correr `pluma-llm`. Marca el + /// turno en vuelo para no re-dispararlo. + pub fn take_request(&mut self) -> Option { + self.pendiente.take() + } + + /// El chasis toma una acción aprobada para ejecutarla (en el shell). + pub fn take_ejecucion(&mut self) -> Option { + self.ejecucion.take() + } + + /// El nombre del agente activo — para atribuir una acción aprobada en la + /// cadena forense del device (#2). `None` si no hay agentes. + pub fn nombre_agente_activo(&self) -> Option<&str> { + self.agente_activo().map(|a| a.nombre.as_str()) + } + + /// El chasis toma un agente a persistir (alta/edición) para escribirlo al + /// Almacen y re-proveer la lista con [`State::set_agentes`]. + pub fn take_persist_agente(&mut self) -> Option { + self.persist_agente.take() + } + + /// El chasis toma el id de un agente a borrar del Almacen. + pub fn take_borrar_agente(&mut self) -> Option { + self.borrar_agente_id.take() + } + + /// El chasis toma el id de una conversación a borrar del Almacen. + pub fn take_borrar_conversacion(&mut self) -> Option { + self.borrar_conv_id.take() + } + + /// Las conversaciones actuales (para que el chasis persista tras un update). + pub fn conversaciones(&self) -> &[Conversacion] { + &self.conversaciones + } + + /// La conversación abierta, si hay. + pub fn conversacion_activa(&self) -> Option<&Conversacion> { + let id = self.conv_activa.as_ref()?; + self.conversaciones.iter().find(|c| &c.id == id) + } + + fn conversacion_activa_mut(&mut self) -> Option<&mut Conversacion> { + let id = self.conv_activa.clone()?; + self.conversaciones.iter_mut().find(|c| c.id == id) + } + + fn agente_activo(&self) -> Option<&Agente> { + self.agentes.get(self.agente_sel) + } +} + +/// Mensajes del panel. +#[derive(Debug, Clone)] +pub enum Msg { + /// Tecla desde el chasis (cuando el input tiene foco). + Key(KeyEvent), + /// Click en el input → toma foco. + FocusInput, + /// Evento de mouse del input del chat (click/arrastre). El `Press` enfoca; + /// el resto lo procesa `handle` (caret, selección). Ver [`text_input_view_full`]. + CampoInput(TextInputEvent), + /// Evento de mouse del input de renombre de conversación (sidebar). + CampoRenombre(TextInputEvent), + /// Evento de mouse de un campo del editor de agente. El [`Campo`] dice cuál; + /// el `Press` además lo enfoca. + CampoEditor(Campo, TextInputEvent), + /// Enviar el texto del input como turno de usuario. + Enviar, + /// Empezar una conversación nueva con el agente activo. + NuevaConversacion, + /// Abrir la conversación en esa posición de la lista. + AbrirConversacion(usize), + /// Borrar la conversación en esa posición. + BorrarConversacion(usize), + /// Empezar a renombrar la conversación en esa posición. + RenombrarConversacion(usize), + /// Confirmar el renombre en curso. + ConfirmarRenombre, + /// Elegir el agente en esa posición. + SeleccionarAgente(usize), + /// Rueda/arrastre del hilo (delta en px). + Scroll(f32), + /// Aprobar la acción del bloque `bloque` del turno `turno`. + Aprobar { turno: usize, bloque: usize }, + /// Rechazar esa acción. + Rechazar { turno: usize, bloque: usize }, + /// Fragmento de texto en streaming (lo dispatcha el chasis token a token). + Token { conv_id: String, delta: String }, + /// Resultado del turno (lo dispatcha el chasis tras correr pluma-llm). + Respuesta { + conv_id: String, + bloques: Vec, + ok: bool, + /// Tokens reportados por el backend (0 si no los expone). + entrada: u32, + salida: u32, + }, + /// Abrir el editor para crear un agente nuevo. + NuevoAgente, + /// Abrir el editor del agente seleccionado. + EditarAgente, + /// Enfocar un campo de texto del editor. + EditorFoco(Campo), + /// Ciclar el backend del editor (claude-cli → anthropic → …). + EditorCiclarBackend, + /// Alternar la capacidad de control del editor. + EditorToggleControl, + /// Guardar el agente del editor (alta/edición). + GuardarAgente, + /// Borrar el agente que se está editando. + BorrarAgente, + /// Cerrar el editor sin guardar. + CancelarEditor, + /// Click en el micrófono: alterna encender/apagar la escucha por voz. + ToggleMic, + /// Click en el altavoz: alterna la lectura TTS de las respuestas (opt-in). + ToggleLeerVoz, + /// El chasis reporta un cambio de estado de la escucha por voz. + EscuchaCambio(EstadoEscucha), + /// Texto dictado por voz: se inserta en el input (no envía solo). + Dictado(String), + /// Empezar a enrolar la palabra de llamada (grabar «shuma» ×N). + EnrolarWake, + /// El chasis grabó una muestra más de «shuma» (avanza el contador). + EnrolarCapturado, + /// El chasis terminó: el wake-word quedó enrolado. + EnrolarHecho, + /// Cancelar el enrolamiento en curso. + EnrolarCancelar, +} + +/// Transición pura del estado. +pub fn update(state: State, msg: Msg) -> State { + let mut s = state; + match msg { + Msg::Key(ev) => { + if ev.state != KeyState::Pressed { + return s; + } + // Renombre en curso: teclas al input del título (Enter confirma, + // Escape cancela). + if let Some((_, input)) = s.renombrando.as_mut() { + match &ev.key { + Key::Named(NamedKey::Escape) => s.renombrando = None, + Key::Named(NamedKey::Enter) => return update(s, Msg::ConfirmarRenombre), + _ => { + input.handle(TextInputEvent::Key(ev.clone()), &mut s.clipboard); + } + } + return s; + } + // Con el editor abierto, las teclas van al campo enfocado (Tab cicla; + // Escape cancela). No se envía mensaje. + if let Some(ed) = s.editor.as_mut() { + match &ev.key { + Key::Named(NamedKey::Escape) => return update(s, Msg::CancelarEditor), + Key::Named(NamedKey::Tab) => { + ed.foco = match ed.foco { + Campo::Nombre => Campo::Modelo, + Campo::Modelo => Campo::Persona, + Campo::Persona => Campo::Nombre, + }; + } + _ => { + ed.campo_mut().handle(TextInputEvent::Key(ev.clone()), &mut s.clipboard); + } + } + return s; + } + // Enter (sin Shift) envía; el resto lo consume el input. + if let Key::Named(NamedKey::Enter) = ev.key { + if !ev.modifiers.shift { + return update(s, Msg::Enviar); + } + } + s.input.handle(TextInputEvent::Key(ev.clone()), &mut s.clipboard); + } + Msg::FocusInput => s.focused = true, + Msg::CampoInput(ev) => { + // El press/click enfoca el input del chat; el arrastre selecciona. + if matches!(ev, TextInputEvent::Press(_)) { + s.focused = true; + } + s.input.handle(ev, &mut s.clipboard); + } + Msg::CampoRenombre(ev) => { + if let Some((_, input)) = s.renombrando.as_mut() { + input.handle(ev, &mut s.clipboard); + } + } + Msg::CampoEditor(c, ev) => { + if let Some(ed) = s.editor.as_mut() { + if matches!(ev, TextInputEvent::Press(_)) { + ed.foco = c; + } + ed.campo_por(c).handle(ev, &mut s.clipboard); + } + } + Msg::Enviar => { + if s.esperando { + return s; + } + // Líneas `img:` se cargan como imágenes (visión); el resto es texto. + let (texto, imagenes) = cargar_imagenes(&s.input.text()); + if texto.is_empty() && imagenes.is_empty() { + return s; + } + let Some(agente) = s.agente_activo().cloned() else { + return s; // sin agentes provistos no hay a quién preguntar + }; + // Asegurá una conversación abierta (si no, abrí una nueva). + if s.conversacion_activa().is_none() { + let conv = Conversacion::nueva(&agente.id, s.reloj_ms); + s.conv_activa = Some(conv.id.clone()); + s.conversaciones.insert(0, conv); + } + let ms = s.reloj_ms; + if let Some(conv) = s.conversacion_activa_mut() { + if imagenes.is_empty() { + conv.agregar_usuario(texto, ms); + } else { + conv.agregar_usuario_con_imagenes(texto, imagenes, ms); + } + let snap = conv.clone(); + s.pendiente = Some(Peticion { conv: snap, agente }); + s.esperando = true; + s.parcial = Some(String::new()); // burbuja viva en streaming + } + s.input.set_text(""); + s.scroll = f32::MAX; // salta al final + } + Msg::NuevaConversacion => { + // Abrí un lienzo limpio: la Conversacion concreta nace al primer + // envío (así no se acumulan vacías). + s.conv_activa = None; + s.input.set_text(""); + s.scroll = 0.0; + } + Msg::AbrirConversacion(i) => { + if let Some(c) = s.conversaciones.get(i) { + s.conv_activa = Some(c.id.clone()); + s.scroll = f32::MAX; + } + } + Msg::BorrarConversacion(i) => { + if i < s.conversaciones.len() { + let c = s.conversaciones.remove(i); + if s.conv_activa.as_deref() == Some(c.id.as_str()) { + s.conv_activa = None; + } + s.borrar_conv_id = Some(c.id); + } + } + Msg::RenombrarConversacion(i) => { + if let Some(c) = s.conversaciones.get(i) { + let mut input = TextInputState::new(); + input.set_text(&c.titulo); + s.renombrando = Some((c.id.clone(), input)); + } + } + Msg::ConfirmarRenombre => { + if let Some((id, input)) = s.renombrando.take() { + let nuevo = input.text().trim().to_string(); + if !nuevo.is_empty() { + if let Some(c) = s.conversaciones.iter_mut().find(|c| c.id == id) { + c.titulo = nuevo; + } + } + } + } + Msg::SeleccionarAgente(i) => { + if i < s.agentes.len() { + s.agente_sel = i; + } + } + Msg::Scroll(delta) => { + s.scroll = (s.scroll + delta).max(0.0); + } + Msg::Token { conv_id, delta } => { + // Sólo acumula si es para la conversación en vuelo. + if s.esperando && s.conv_activa.as_deref() == Some(conv_id.as_str()) { + s.parcial.get_or_insert_with(String::new).push_str(&delta); + s.scroll = f32::MAX; + } + } + Msg::Respuesta { conv_id, bloques, ok, entrada, salida } => { + s.esperando = false; + s.parcial = None; + let ms = s.reloj_ms; + let leer = s.leer_voz; + // Prosa a vocalizar si la lectura está activa (se stagea abajo). + let mut a_leer: Option = None; + if let Some(conv) = s.conversaciones.iter_mut().find(|c| c.id == conv_id) { + let bloques = if ok { + bloques + } else { + vec![BloqueSalida::Error( + bloques + .into_iter() + .find_map(|b| match b { + BloqueSalida::Error(e) => Some(e), + BloqueSalida::Texto(t) => Some(t), + _ => None, + }) + .unwrap_or_else(|| "el modelo no respondió".to_string()), + )] + }; + // Lectura discriminada (doctrina VOZ.md): sólo la prosa, y sólo si + // el turno fue OK — un error no se lee en voz alta. + if ok && leer { + let prosa = prosa_vocalizable(&bloques); + if !prosa.trim().is_empty() { + a_leer = Some(prosa); + } + } + conv.agregar_asistente(bloques, ms, Some(shuma_agente::Uso { entrada, salida })); + } + if a_leer.is_some() { + s.leer_intent = a_leer; + } + s.scroll = f32::MAX; + } + Msg::Aprobar { turno, bloque } => { + if let Some(accion) = marcar_accion(&mut s, turno, bloque, EstadoAccion::Aprobada) { + s.ejecucion = Some(accion); + } + } + Msg::Rechazar { turno, bloque } => { + marcar_accion(&mut s, turno, bloque, EstadoAccion::Rechazada); + } + Msg::NuevoAgente => { + s.editor = Some(EditorAgente::nuevo()); + } + Msg::EditarAgente => { + if let Some(a) = s.agentes.get(s.agente_sel) { + s.editor = Some(EditorAgente::desde(a)); + } + } + Msg::EditorFoco(c) => { + if let Some(ed) = s.editor.as_mut() { + ed.foco = c; + } + } + Msg::EditorCiclarBackend => { + if let Some(ed) = s.editor.as_mut() { + ed.backend_idx = (ed.backend_idx + 1) % BACKENDS.len(); + } + } + Msg::EditorToggleControl => { + if let Some(ed) = s.editor.as_mut() { + ed.control = !ed.control; + } + } + Msg::GuardarAgente => { + if let Some(ed) = s.editor.take() { + if ed.nombre.text().trim().is_empty() { + // Sin nombre no se guarda; reabrí el editor para corregir. + s.editor = Some(ed); + } else { + let agente = ed.a_agente(); + // Refleja el cambio en memoria (el chasis además lo persiste). + if let Some(pos) = s.agentes.iter().position(|a| a.id == agente.id) { + s.agentes[pos] = agente.clone(); + } else { + s.agentes.push(agente.clone()); + } + s.persist_agente = Some(agente); + } + } + } + Msg::BorrarAgente => { + if let Some(ed) = s.editor.take() { + if let Some(id) = ed.id { + s.agentes.retain(|a| a.id != id); + if s.agente_sel >= s.agentes.len() { + s.agente_sel = s.agentes.len().saturating_sub(1); + } + s.borrar_agente_id = Some(id); + } + } + } + Msg::CancelarEditor => { + s.editor = None; + } + Msg::ToggleMic => { + // Durante el enrolamiento el micrófono lo usa la grabación: ignorar. + if s.enrolando.is_some() { + return s; + } + // Alterna encender/apagar; deja el intent para que el chasis arranque + // o pare la captura real (rimay-voz-host). + if s.escucha.activo() { + s.escucha = EstadoEscucha::Apagado; + s.mic_intent = Some(false); + } else { + s.escucha = EstadoEscucha::Esperando; + s.mic_intent = Some(true); + } + } + Msg::ToggleLeerVoz => { + // Opt-in: alterna la lectura de las respuestas. No re-lee lo anterior; + // aplica desde el próximo turno cerrado. + s.leer_voz = !s.leer_voz; + } + Msg::EscuchaCambio(e) => { + s.escucha = e; + } + Msg::Dictado(t) => { + // El dictado se inserta en el input; NO se envía solo (el usuario + // revisa y manda con Enter / el botón), salvo que diga el llamado de + // envío — eso lo decide quien dispatcha, no aquí. + if !t.is_empty() { + if !s.input.is_empty() && !s.input.text().ends_with(' ') { + s.input.push_str(" "); + } + s.input.push_str(&t); + s.focused = true; + } + } + Msg::EnrolarWake => { + // No enrolar mientras se escucha (mutuamente excluyente). + if s.enrolando.is_none() && !s.escucha.activo() { + s.enrolando = Some(0); + s.enrol_intent = Some(true); + } + } + Msg::EnrolarCapturado => s.enrol_capturado(), + Msg::EnrolarHecho => s.enrol_terminado(), + Msg::EnrolarCancelar => { + if s.enrolando.is_some() { + s.enrolando = None; + s.enrol_intent = Some(false); + } + } + } + s +} + +/// Cambia el estado de la acción en `(turno, bloque)` de la conversación activa. +/// Devuelve la acción (clonada) si la encontró y era una acción. +fn marcar_accion( + s: &mut State, + turno: usize, + bloque: usize, + nuevo: EstadoAccion, +) -> Option { + let conv = s.conversacion_activa_mut()?; + let b = conv.turnos.get_mut(turno)?.bloques.get_mut(bloque)?; + if let BloqueSalida::Accion(a) = b { + a.estado = nuevo; + Some(a.clone()) + } else { + None + } +} + +/// Aportes al chasis: el panel no contribuye monitores ni shortcuts. +pub fn contributions(_state: &State) -> ModuleContributions { + ModuleContributions::empty() +} + +// ─── Vista ────────────────────────────────────────────────────────────────── + +/// Pinta el panel. `lift` sube los `Msg` del módulo al `Msg` del chasis. +pub fn view( + state: &State, + theme: &Theme, + lift: impl Fn(Msg) -> HostMsg + Send + Sync + 'static + Clone, +) -> View { + let sidebar = sidebar_view(state, theme, lift.clone()); + let main = panel_view(state, theme, lift); + View::new(Style { + flex_direction: FlexDirection::Row, + size: Size { width: percent(1.0_f32), height: percent(1.0_f32) }, + ..Default::default() + }) + .fill(theme.bg_app) + .children(vec![sidebar, main]) +} + +fn sidebar_view( + state: &State, + theme: &Theme, + lift: impl Fn(Msg) -> HostMsg + Send + Sync + 'static + Clone, +) -> View { + let mut hijos: Vec> = Vec::new(); + + // Selector de agentes. + hijos.push(rotulo("AGENTES", theme)); + for (i, ag) in state.agentes.iter().enumerate() { + let sel = i == state.agente_sel; + hijos.push( + fila_seleccionable(&ag.nombre, sel, theme) + .on_click(lift(Msg::SeleccionarAgente(i))), + ); + } + // Acciones de agentes: nuevo / editar el seleccionado. + let bp = ButtonPalette::from_theme(theme); + hijos.push( + View::new(Style { + flex_direction: FlexDirection::Row, + size: Size { width: percent(1.0_f32), height: length(30.0_f32) }, + gap: Size { width: length(6.0_f32), height: length(0.0_f32) }, + padding: rect_xy(8.0, 2.0), + ..Default::default() + }) + .children(vec![ + View::new(Style { flex_grow: 1.0, ..Default::default() }) + .children(vec![button_view("+ agente", &bp, lift(Msg::NuevoAgente))]), + View::new(Style { flex_grow: 1.0, ..Default::default() }) + .children(vec![button_view("editar", &bp, lift(Msg::EditarAgente))]), + ]), + ); + + // Botón nueva conversación. + hijos.push( + View::new(Style { + size: Size { width: percent(1.0_f32), height: length(36.0_f32) }, + padding: rect_xy(8.0, 4.0), + ..Default::default() + }) + .children(vec![button_view("+ nueva conversación", &bp, lift(Msg::NuevaConversacion))]), + ); + + // Lista de conversaciones: título clickeable + «×» para borrar. + hijos.push(rotulo("CONVERSACIONES", theme)); + let renombrando_id = state.renombrando.as_ref().map(|(id, _)| id.as_str()); + for (i, c) in state.conversaciones.iter().enumerate() { + let activa = state.conv_activa.as_deref() == Some(c.id.as_str()); + let titulo = if c.titulo.trim().is_empty() { "(sin título)" } else { &c.titulo }; + let bg = if activa { theme.bg_selected } else { theme.bg_panel_alt }; + let fg = if activa { theme.fg_text } else { theme.fg_muted }; + + // Renombrando esta conversación: input en vez del título. + if renombrando_id == Some(c.id.as_str()) { + if let Some((_, input)) = &state.renombrando { + let tp = TextInputPalette::from_theme(theme); + hijos.push( + View::new(Style { + size: Size { width: percent(1.0_f32), height: length(26.0_f32) }, + padding: rect_xy(6.0, 0.0), + ..Default::default() + }) + .children(vec![{ + let lift = lift.clone(); + text_input_view_full( + input, + "nuevo título…", + true, + &tp, + move |ev| lift(Msg::CampoRenombre(ev)), + ) + }]), + ); + continue; + } + } + + let fila = View::new(Style { + flex_direction: FlexDirection::Row, + size: Size { width: percent(1.0_f32), height: length(26.0_f32) }, + align_items: Some(AlignItems::Center), + ..Default::default() + }) + .fill(bg) + .children(vec![ + // Título: toma el ancho y abre la conversación. + View::new(Style { + flex_grow: 1.0, + size: Size { width: Dimension::auto(), height: percent(1.0_f32) }, + padding: rect_xy(10.0, 0.0), + align_items: Some(AlignItems::Center), + ..Default::default() + }) + .text_aligned(titulo, 12.5, fg, Alignment::Start) + .on_click(lift(Msg::AbrirConversacion(i))), + // «✎»: renombrar. + View::new(Style { + size: Size { width: length(20.0_f32), height: percent(1.0_f32) }, + align_items: Some(AlignItems::Center), + ..Default::default() + }) + .text_aligned("✎", 12.0, theme.fg_muted, Alignment::Center) + .on_click(lift(Msg::RenombrarConversacion(i))), + // «×»: borra la conversación. + View::new(Style { + size: Size { width: length(20.0_f32), height: percent(1.0_f32) }, + align_items: Some(AlignItems::Center), + ..Default::default() + }) + .text_aligned("×", 14.0, theme.fg_muted, Alignment::Center) + .on_click(lift(Msg::BorrarConversacion(i))), + ]); + hijos.push(fila); + } + + View::new(Style { + flex_direction: FlexDirection::Column, + size: Size { width: length(SIDEBAR_W), height: percent(1.0_f32) }, + padding: rect_xy(0.0, 8.0), + gap: Size { width: length(0.0_f32), height: length(2.0_f32) }, + ..Default::default() + }) + .fill(theme.bg_panel_alt) + .children(hijos) +} + +/// Mapea un bloque de salida de shuma → la taxonomía TTS de `rimay-voz`. Es «lo +/// único de shuma» para la lectura (VOZ.md): la política y la síntesis son de +/// rimay; aquí sólo se clasifica cada bloque. Imagen y Error no son prosa +/// vocalizable — se tratan como no-texto (no se leen). +fn tipo_bloque(b: &BloqueSalida) -> rimay_voz_core::TipoBloque { + use rimay_voz_core::TipoBloque as T; + match b { + BloqueSalida::Texto(_) => T::Texto, + BloqueSalida::Codigo { .. } => T::Codigo, + BloqueSalida::Accion(_) => T::Accion, + BloqueSalida::Imagen { .. } | BloqueSalida::Error(_) => T::Codigo, + } +} + +/// Concatena la prosa vocalizable de una respuesta: sólo los bloques que la +/// política `debe_leer` (Texto), nunca código ni acciones (doctrina VOZ.md: un +/// bloque de código leído en voz alta es ruido; una acción se aprueba, no se +/// narra). Bloques separados por salto de línea. +fn prosa_vocalizable(bloques: &[BloqueSalida]) -> String { + let mut out = String::new(); + for b in bloques { + if rimay_voz_core::debe_leer(tipo_bloque(b)) { + if let BloqueSalida::Texto(t) = b { + if !out.is_empty() { + out.push('\n'); + } + out.push_str(t); + } + } + } + out +} + +/// Botón de **altavoz** que alterna la lectura TTS de las respuestas (opt-in). +/// Glifo de parlante + ondas: teñido con el accent cuando la lectura está activa, +/// apagado (muted, sin ondas) cuando no. El click dispatcha [`Msg::ToggleLeerVoz`]. +fn boton_leer( + on: bool, + theme: &Theme, + lift: impl Fn(Msg) -> HostMsg + Send + Sync + 'static + Clone, +) -> View { + let color = if on { theme.accent } else { theme.fg_muted }; + View::new(Style { + size: Size { width: length(34.0_f32), height: length(34.0_f32) }, + flex_shrink: 0.0, + align_items: Some(AlignItems::Center), + justify_content: Some(JustifyContent::Center), + ..Default::default() + }) + .hover_fill(theme.bg_row_hover) + .on_click(lift(Msg::ToggleLeerVoz)) + .paint_with(move |scene, _ts, rect| { + use llimphi_ui::llimphi_raster::kurbo::{Affine, BezPath, Point, Stroke}; + use llimphi_ui::llimphi_raster::peniko::Fill; + if rect.w <= 0.0 || rect.h <= 0.0 { + return; + } + let cx = (rect.x + rect.w * 0.5) as f64; + let cy = (rect.y + rect.h * 0.5) as f64; + let lado = rect.w.min(rect.h) as f64; + // Cuerpo del parlante: caja chica + cono trapezoidal hacia la derecha. + let bx = cx - lado * 0.20; + let bw = lado * 0.10; + let bh = lado * 0.11; + let cono = lado * 0.20; + let mut p = BezPath::new(); + p.move_to(Point::new(bx - bw, cy - bh)); + p.line_to(Point::new(bx, cy - bh)); + p.line_to(Point::new(bx + cono, cy - lado * 0.22)); + p.line_to(Point::new(bx + cono, cy + lado * 0.22)); + p.line_to(Point::new(bx, cy + bh)); + p.line_to(Point::new(bx - bw, cy + bh)); + p.close_path(); + scene.fill(Fill::NonZero, Affine::IDENTITY, color, None, &p); + // Ondas de sonido: dos arquitos a la derecha, sólo cuando la lectura está + // activa (apagada, el parlante queda «mudo»). + if on { + let ox = bx + cono + lado * 0.04; + for k in 0..2 { + let r = lado * (0.12 + 0.10 * k as f64); + let dx = lado * 0.06 * k as f64; + let mut w = BezPath::new(); + w.move_to(Point::new(ox + dx, cy - r)); + w.quad_to(Point::new(ox + dx + r * 0.9, cy), Point::new(ox + dx, cy + r)); + scene.stroke(&Stroke::new(1.4), Affine::IDENTITY, color, None, &w); + } + } + }) +} + +fn panel_view( + state: &State, + theme: &Theme, + lift: impl Fn(Msg) -> HostMsg + Send + Sync + 'static + Clone, +) -> View { + // Con el editor abierto ocupa todo el panel. + if let Some(ed) = &state.editor { + return editor_view(ed, theme, lift); + } + // Hilo de turnos. + let mut turnos: Vec> = Vec::new(); + let mut alto_total = 0.0_f32; + if let Some(conv) = state.conversacion_activa() { + for (ti, t) in conv.turnos.iter().enumerate() { + let (v, h) = turno_view(ti, t, theme, lift.clone()); + alto_total += h + 10.0; + turnos.push(v); + } + } else { + turnos.push( + View::new(Style { + padding: rect_xy(16.0, 16.0), + ..Default::default() + }) + .text( + "Elige un agente y escribe abajo para empezar una conversación.", + 13.0, + theme.fg_muted, + ), + ); + alto_total = 60.0; + } + if state.esperando { + // Burbuja viva: el texto que está llegando en streaming, o «…pensando» + // mientras no llegó el primer token. + let parcial = state.parcial.as_deref().unwrap_or(""); + if parcial.is_empty() { + turnos.push( + View::new(Style { padding: rect_xy(16.0, 6.0), ..Default::default() }) + .text("…pensando", 13.0, theme.fg_muted), + ); + alto_total += 30.0; + } else { + turnos.push( + View::new(Style { + flex_direction: FlexDirection::Column, + size: Size { width: percent(1.0_f32), height: Dimension::auto() }, + gap: Size { width: length(0.0_f32), height: length(4.0_f32) }, + padding: rect_xy(12.0, 8.0), + ..Default::default() + }) + .fill(theme.bg_panel_alt) + .children(vec![ + View::new(Style { size: Size { width: percent(1.0_f32), height: length(16.0_f32) }, ..Default::default() }) + .text("IA", 11.0, theme.fg_muted), + View::new(Style { size: Size { width: percent(1.0_f32), height: Dimension::auto() }, ..Default::default() }) + .text(format!("{parcial}▌"), 13.0, theme.fg_text), + ]), + ); + alto_total += estimar_alto_texto(parcial, 13.0) + 30.0; + } + } + + let contenido = View::new(Style { + flex_direction: FlexDirection::Column, + size: Size { width: percent(1.0_f32), height: Dimension::auto() }, + gap: Size { width: length(0.0_f32), height: length(10.0_f32) }, + padding: rect_xy(16.0, 12.0), + ..Default::default() + }) + .children(turnos); + + let sp = ScrollPalette::from_theme(theme); + let lift_scroll = lift.clone(); + let hilo = scroll_y( + state.scroll.min(alto_total), + alto_total, + state.vista_alto, + contenido, + move |d| lift_scroll(Msg::Scroll(-d)), + &sp, + ); + + // Barra de input. + let tp = TextInputPalette::from_theme(theme); + // Glow del input mientras escucha: borde redondeado que respira (varias + // pasadas con alpha decreciente para difuminar). Apagado = sin pintura. + let escucha = state.escucha; + let reloj = state.reloj_ms; + let accent = theme.accent; + let input = View::new(Style { + flex_grow: 1.0, + ..Default::default() + }) + .paint_with(move |scene, _ts, rect| { + use llimphi_ui::llimphi_raster::kurbo::{Affine, RoundedRect, Stroke}; + if !escucha.activo() || rect.w <= 0.0 || rect.h <= 0.0 { + return; + } + let (_, periodo, intensidad) = shuma_voz_ui::params_escucha(escucha); + // Respiración 0..1 (seno) sincronizada con el periodo del estado. + let t = (reloj as f64) / periodo * std::f64::consts::TAU; + let respira = 0.5 + 0.5 * (t.sin() as f32); + let base = intensidad * (0.35 + 0.65 * respira); + let (x0, y0) = (rect.x as f64 + 1.0, rect.y as f64 + 1.0); + let (x1, y1) = ((rect.x + rect.w) as f64 - 1.0, (rect.y + rect.h) as f64 - 1.0); + // Tres pasadas hacia afuera con alpha decreciente → halo difuso. + for (i, ancho) in [1.4_f64, 2.6, 3.8].into_iter().enumerate() { + let d = i as f64 * 1.3; + let a = base * (1.0 - i as f32 * 0.32); + let rr = RoundedRect::new(x0 - d, y0 - d, x1 + d, y1 + d, 8.0 + d); + scene.stroke( + &Stroke::new(ancho), + Affine::IDENTITY, + accent.with_alpha(a.clamp(0.0, 1.0)), + None, + &rr, + ); + } + }); + // Enrolando: el placeholder guía la grabación de «shuma». + let placeholder = match state.enrolando { + Some(n) => format!("🎙 Graba «shuma» — {}/{} (cancelar →)", n, ENROL_OBJETIVO), + None => "Escribe tu mensaje… (Enter envía · img:/ruta para adjuntar · 🎙 dicta)".into(), + }; + let input = input.children(vec![{ + let lift = lift.clone(); + text_input_view_full( + &state.input, + &placeholder, + state.focused, + &tp, + move |ev| lift(Msg::CampoInput(ev)), + ) + }]); + // Botón de micrófono con el indicador animado (escucha o enrolamiento). + // El widget vive en `shuma-voz-ui` (compartido con la command-bar y el shell); + // aquí se cablea su click a nuestro `Msg::ToggleMic`. + let mic = shuma_voz_ui::boton_mic( + state.escucha, + state.enrolando.is_some(), + state.reloj_ms, + 34.0, + theme, + lift(Msg::ToggleMic), + ); + // Altavoz: alterna la lectura TTS de las respuestas (opt-in). + let leer = boton_leer(state.leer_voz, theme, lift.clone()); + let bp = ButtonPalette::from_theme(theme); + // En idle, un acceso a enrolar el wake-word; enrolando, a cancelar; si no, Enviar. + let accion = if state.enrolando.is_some() { + View::new(Style { + size: Size { width: length(96.0_f32), height: Dimension::auto() }, + ..Default::default() + }) + .children(vec![button_view("Cancelar", &bp, lift(Msg::EnrolarCancelar))]) + } else if !state.escucha.activo() { + // Idle: ofrece enrolar (o re-enrolar) la palabra de llamada. + let etq = if state.wake_listo { "re-enrolar" } else { "enrolar voz" }; + View::new(Style { + flex_direction: FlexDirection::Row, + gap: Size { width: length(8.0_f32), height: length(0.0_f32) }, + ..Default::default() + }) + .children(vec![ + View::new(Style { + size: Size { width: length(96.0_f32), height: Dimension::auto() }, + ..Default::default() + }) + .children(vec![button_view(etq, &bp, lift(Msg::EnrolarWake))]), + View::new(Style { + size: Size { width: length(96.0_f32), height: Dimension::auto() }, + ..Default::default() + }) + .children(vec![button_view("Enviar", &bp, lift(Msg::Enviar))]), + ]) + } else { + View::new(Style { + size: Size { width: length(96.0_f32), height: Dimension::auto() }, + ..Default::default() + }) + .children(vec![button_view("Enviar", &bp, lift(Msg::Enviar))]) + }; + let enviar = accion; + + let barra = View::new(Style { + flex_direction: FlexDirection::Row, + size: Size { width: percent(1.0_f32), height: length(40.0_f32) }, + gap: Size { width: length(8.0_f32), height: length(0.0_f32) }, + padding: rect_xy(12.0, 6.0), + align_items: Some(AlignItems::Center), + ..Default::default() + }) + .fill(theme.bg_panel) + .children(vec![input, mic, leer, enviar]); + + let hilo_wrap = View::new(Style { + flex_grow: 1.0, + size: Size { width: percent(1.0_f32), height: length(state.vista_alto) }, + ..Default::default() + }) + .children(vec![hilo]); + + View::new(Style { + flex_direction: FlexDirection::Column, + flex_grow: 1.0, + size: Size { width: Dimension::auto(), height: percent(1.0_f32) }, + ..Default::default() + }) + .fill(theme.bg_app) + .children(vec![hilo_wrap, barra]) +} + +/// Formulario de alta/edición de un agente. +fn editor_view( + ed: &EditorAgente, + theme: &Theme, + lift: impl Fn(Msg) -> HostMsg + Send + Sync + 'static + Clone, +) -> View { + let tp = TextInputPalette::from_theme(theme); + let bp = ButtonPalette::from_theme(theme); + + let titulo = if ed.id.is_some() { "Editar agente" } else { "Nuevo agente" }; + + // Campo de texto etiquetado. + let campo = |etq: &str, st: &TextInputState, foco: bool, c: Campo| { + let lift = lift.clone(); + View::new(Style { + flex_direction: FlexDirection::Column, + size: Size { width: percent(1.0_f32), height: Dimension::auto() }, + gap: Size { width: length(0.0_f32), height: length(2.0_f32) }, + ..Default::default() + }) + .children(vec![ + View::new(Style { size: Size { width: percent(1.0_f32), height: length(14.0_f32) }, ..Default::default() }) + .text(etq, 10.0, theme.fg_muted), + text_input_view_full(st, "", foco, &tp, move |ev| lift(Msg::CampoEditor(c, ev))), + ]) + }; + + let backend_lbl = { + let b = BACKENDS[ed.backend_idx]; + let nombre = if b.is_empty() { "(global del SO)" } else { b }; + format!("backend: {nombre}") + }; + let control_lbl = format!("control: {}", if ed.control { "sí" } else { "no" }); + + let mut hijos = vec![ + View::new(Style { size: Size { width: percent(1.0_f32), height: length(22.0_f32) }, ..Default::default() }) + .text(titulo, 14.0, theme.fg_text), + campo("Nombre", &ed.nombre, ed.foco == Campo::Nombre, Campo::Nombre), + campo("Modelo (vacío = default del backend)", &ed.modelo, ed.foco == Campo::Modelo, Campo::Modelo), + campo("Persona / system prompt", &ed.persona, ed.foco == Campo::Persona, Campo::Persona), + // Backend (cicla) + control (toggle). + View::new(Style { + flex_direction: FlexDirection::Row, + size: Size { width: percent(1.0_f32), height: length(34.0_f32) }, + gap: Size { width: length(8.0_f32), height: length(0.0_f32) }, + ..Default::default() + }) + .children(vec![ + View::new(Style { flex_grow: 2.0, ..Default::default() }) + .children(vec![button_view(backend_lbl, &bp, lift(Msg::EditorCiclarBackend))]), + View::new(Style { flex_grow: 1.0, ..Default::default() }) + .children(vec![button_view(control_lbl, &bp, lift(Msg::EditorToggleControl))]), + ]), + ]; + + // Botonera: guardar / cancelar (+ borrar si edita). + let mut botones = vec![ + View::new(Style { flex_grow: 1.0, ..Default::default() }) + .children(vec![button_view("guardar", &bp, lift(Msg::GuardarAgente))]), + View::new(Style { flex_grow: 1.0, ..Default::default() }) + .children(vec![button_view("cancelar", &bp, lift(Msg::CancelarEditor))]), + ]; + if ed.id.is_some() { + botones.push( + View::new(Style { flex_grow: 1.0, ..Default::default() }) + .children(vec![button_view("borrar", &bp, lift(Msg::BorrarAgente))]), + ); + } + hijos.push( + View::new(Style { + flex_direction: FlexDirection::Row, + size: Size { width: percent(1.0_f32), height: length(34.0_f32) }, + gap: Size { width: length(8.0_f32), height: length(0.0_f32) }, + ..Default::default() + }) + .children(botones), + ); + + View::new(Style { + flex_direction: FlexDirection::Column, + flex_grow: 1.0, + size: Size { width: Dimension::auto(), height: percent(1.0_f32) }, + gap: Size { width: length(0.0_f32), height: length(10.0_f32) }, + padding: rect_xy(16.0, 14.0), + ..Default::default() + }) + .fill(theme.bg_app) + .children(hijos) +} + +/// Pinta un turno; devuelve la vista y una estimación de su alto (px) para el +/// scroll (no hay medición exacta de texto en tiempo de view). +fn turno_view( + turno_idx: usize, + turno: &shuma_agente::Turno, + theme: &Theme, + lift: impl Fn(Msg) -> HostMsg + Send + Sync + 'static + Clone, +) -> (View, f32) { + use shuma_agente::Rol; + let es_usuario = turno.rol == Rol::Usuario; + let prefijo = if es_usuario { "Vos" } else { "IA" }; + let color_pref = if es_usuario { theme.accent } else { theme.fg_muted }; + + let mut hijos: Vec> = vec![View::new(Style { + size: Size { width: percent(1.0_f32), height: length(16.0_f32) }, + ..Default::default() + }) + .text(prefijo, 11.0, color_pref)]; + + let mut alto = 20.0_f32; + for (bi, b) in turno.bloques.iter().enumerate() { + let (v, h) = bloque_view(turno_idx, bi, b, theme, lift.clone()); + alto += h; + hijos.push(v); + } + + // Conteo de tokens del turno (paridad con Claude CLI), si lo hay. + if let Some(u) = turno.uso.filter(|u| u.hay()) { + hijos.push( + View::new(Style { size: Size { width: percent(1.0_f32), height: length(14.0_f32) }, ..Default::default() }) + .text(format!("↑{} ↓{} tokens", u.entrada, u.salida), 10.0, theme.fg_muted), + ); + alto += 16.0; + } + + let v = View::new(Style { + flex_direction: FlexDirection::Column, + size: Size { width: percent(1.0_f32), height: Dimension::auto() }, + gap: Size { width: length(0.0_f32), height: length(4.0_f32) }, + padding: rect_xy(12.0, 8.0), + ..Default::default() + }) + .fill(if es_usuario { theme.bg_panel } else { theme.bg_panel_alt }) + .children(hijos); + (v, alto) +} + +fn bloque_view( + turno: usize, + bloque: usize, + b: &BloqueSalida, + theme: &Theme, + lift: impl Fn(Msg) -> HostMsg + Send + Sync + 'static + Clone, +) -> (View, f32) { + match b { + BloqueSalida::Texto(t) => { + let alto = estimar_alto_texto(t, 13.0); + ( + View::new(Style { size: Size { width: percent(1.0_f32), height: Dimension::auto() }, ..Default::default() }) + .text(t, 13.0, theme.fg_text), + alto, + ) + } + BloqueSalida::Codigo { lenguaje, codigo } => { + let etiqueta = lenguaje.clone().unwrap_or_default(); + let alto = estimar_alto_texto(codigo, 12.5) + 16.0; + let v = View::new(Style { + flex_direction: FlexDirection::Column, + size: Size { width: percent(1.0_f32), height: Dimension::auto() }, + padding: rect_xy(10.0, 8.0), + ..Default::default() + }) + .fill(theme.bg_app) + .children(vec![ + View::new(Style { size: Size { width: percent(1.0_f32), height: length(12.0_f32) }, ..Default::default() }) + .text(etiqueta, 10.0, theme.fg_muted), + View::new(Style { size: Size { width: percent(1.0_f32), height: Dimension::auto() }, ..Default::default() }) + .text(codigo, 12.5, theme.fg_text), + ]); + (v, alto) + } + BloqueSalida::Accion(a) => (accion_view(turno, bloque, a, theme, lift), 70.0), + BloqueSalida::Imagen { data_base64, .. } => { + let alto = 180.0; + match decodificar_imagen(data_base64) { + Some(img) => ( + View::new(Style { + size: Size { width: length(240.0_f32), height: length(alto) }, + ..Default::default() + }) + .image(img), + alto + 6.0, + ), + None => ( + View::new(Style { size: Size { width: percent(1.0_f32), height: length(18.0_f32) }, ..Default::default() }) + .text("🖼 imagen (no se pudo mostrar)", 12.0, theme.fg_muted), + 22.0, + ), + } + } + BloqueSalida::Error(e) => ( + View::new(Style { size: Size { width: percent(1.0_f32), height: Dimension::auto() }, ..Default::default() }) + .text(format!("⚠ {e}"), 12.5, theme.fg_destructive), + estimar_alto_texto(e, 12.5), + ), + } +} + +/// Tarjeta de acción de control: línea de comando + peligro + aprobar/rechazar. +fn accion_view( + turno: usize, + bloque: usize, + a: &shuma_agente::AccionPropuesta, + theme: &Theme, + lift: impl Fn(Msg) -> HostMsg + Send + Sync + 'static + Clone, +) -> View { + let acento = match a.peligro { + Peligro::Seguro => theme.accent, + Peligro::Reversible => theme.accent, + Peligro::Disruptivo => theme.fg_destructive, + }; + let cabecera = format!("⚡ {} · [{}]", a.id, a.peligro.etiqueta()); + + let mut hijos = vec![ + View::new(Style { size: Size { width: percent(1.0_f32), height: length(16.0_f32) }, ..Default::default() }) + .text(cabecera, 11.0, acento), + View::new(Style { size: Size { width: percent(1.0_f32), height: Dimension::auto() }, ..Default::default() }) + .text(&a.linea_comando, 12.5, theme.fg_text), + ]; + + // Botonera según estado. + let bp = ButtonPalette::from_theme(theme); + let fila = match a.estado { + EstadoAccion::Propuesta => { + let aprobar = View::new(Style { size: Size { width: length(96.0_f32), height: Dimension::auto() }, ..Default::default() }) + .children(vec![button_view("aprobar", &bp, lift.clone()(Msg::Aprobar { turno, bloque }))]); + let rechazar = View::new(Style { size: Size { width: length(96.0_f32), height: Dimension::auto() }, ..Default::default() }) + .children(vec![button_view("rechazar", &bp, lift(Msg::Rechazar { turno, bloque }))]); + View::new(Style { + flex_direction: FlexDirection::Row, + gap: Size { width: length(8.0_f32), height: length(0.0_f32) }, + size: Size { width: percent(1.0_f32), height: length(34.0_f32) }, + ..Default::default() + }) + .children(vec![aprobar, rechazar]) + } + estado => { + let (txt, col) = match estado { + EstadoAccion::Aprobada => ("✓ aprobada — ejecutando…", theme.accent), + EstadoAccion::Ejecutada => ("✓ ejecutada", theme.accent), + EstadoAccion::Rechazada => ("✗ rechazada", theme.fg_muted), + EstadoAccion::Fallida => ("⚠ falló", theme.fg_destructive), + EstadoAccion::Propuesta => ("", theme.fg_muted), + }; + View::new(Style { size: Size { width: percent(1.0_f32), height: length(20.0_f32) }, ..Default::default() }) + .text(txt, 11.0, col) + } + }; + hijos.push(fila); + + View::new(Style { + flex_direction: FlexDirection::Column, + size: Size { width: percent(1.0_f32), height: Dimension::auto() }, + gap: Size { width: length(0.0_f32), height: length(6.0_f32) }, + padding: rect_xy(10.0, 8.0), + ..Default::default() + }) + .fill(theme.bg_app) + .children(hijos) +} + +// ─── Helpers de vista ─────────────────────────────────────────────────────── + +fn rotulo(txt: &str, theme: &Theme) -> View { + View::new(Style { + size: Size { width: percent(1.0_f32), height: length(20.0_f32) }, + padding: rect_xy(10.0, 4.0), + ..Default::default() + }) + .text_aligned(txt, 10.0, theme.fg_muted, Alignment::Start) +} + +fn fila_seleccionable( + texto: &str, + sel: bool, + theme: &Theme, +) -> View { + let bg = if sel { theme.bg_selected } else { theme.bg_panel_alt }; + let fg = if sel { theme.fg_text } else { theme.fg_muted }; + View::new(Style { + size: Size { width: percent(1.0_f32), height: length(26.0_f32) }, + padding: rect_xy(10.0, 0.0), + align_items: Some(AlignItems::Center), + ..Default::default() + }) + .fill(bg) + .text_aligned(texto, 12.5, fg, Alignment::Start) +} + +fn rect_xy(x: f32, y: f32) -> Rect { + Rect { left: length(x), right: length(x), top: length(y), bottom: length(y) } +} + +/// Tope de tamaño de imagen adjunta (5 MiB) — evita pegar archivos enormes. +const IMG_MAX_BYTES: usize = 5 * 1024 * 1024; + +/// Separa el input en texto y adjuntos: las líneas `img:` se leen del disco +/// y se devuelven como `(media_type, base64)`; el resto es el texto del mensaje. +/// Las que no se pueden leer se descartan en silencio (el mensaje igual sale). +fn cargar_imagenes(crudo: &str) -> (String, Vec<(String, String)>) { + use base64::Engine as _; + let mut texto: Vec<&str> = Vec::new(); + let mut imgs: Vec<(String, String)> = Vec::new(); + for linea in crudo.lines() { + let t = linea.trim(); + if let Some(ruta) = t.strip_prefix("img:") { + let ruta = ruta.trim(); + if let Ok(bytes) = std::fs::read(ruta) { + if !bytes.is_empty() && bytes.len() <= IMG_MAX_BYTES { + let b64 = base64::engine::general_purpose::STANDARD.encode(&bytes); + imgs.push((media_type_de(ruta), b64)); + } + } + } else { + texto.push(linea); + } + } + (texto.join("\n").trim().to_string(), imgs) +} + +/// Adivina el `media_type` por la extensión del archivo (default PNG). +fn media_type_de(ruta: &str) -> String { + let l = ruta.to_lowercase(); + if l.ends_with(".jpg") || l.ends_with(".jpeg") { + "image/jpeg" + } else if l.ends_with(".webp") { + "image/webp" + } else if l.ends_with(".gif") { + "image/gif" + } else { + "image/png" + } + .to_string() +} + +/// Decodifica base64 → bytes → imagen lista para `View::image`. `None` si falla. +fn decodificar_imagen(data_base64: &str) -> Option { + use base64::Engine as _; + let bytes = base64::engine::general_purpose::STANDARD.decode(data_base64).ok()?; + llimphi_image::decode_bytes(&bytes).ok() +} + +/// Estimación grosera del alto de un texto (px): cuenta líneas reales y suma un +/// poco por wrap. No es exacto — sólo dimensiona el scroll. +fn estimar_alto_texto(t: &str, size: f32) -> f32 { + let alto_linea = size * 1.4; + let lineas: f32 = t + .lines() + .map(|l| (l.chars().count() as f32 / 64.0).ceil().max(1.0)) + .sum(); + (lineas.max(1.0)) * alto_linea + 4.0 +} + +#[cfg(test)] +mod tests { + use super::*; + + fn estado_con_agente() -> State { + let mut s = State::new(); + s.set_agentes(vec![Agente::nuevo("Asistente"), Agente::nuevo("Control").con_control()]); + s.fijar_reloj(1000); + s + } + + #[test] + fn toggle_mic_enciende_y_apaga_con_intent() { + let mut s = State::new(); + assert_eq!(s.escucha(), EstadoEscucha::Apagado); + // Encender. + s = update(s, Msg::ToggleMic); + assert_eq!(s.escucha(), EstadoEscucha::Esperando); + assert_eq!(s.tomar_mic_intent(), Some(true)); + assert_eq!(s.tomar_mic_intent(), None); // se consume una sola vez + // Apagar (desde cualquier estado activo). + s.fijar_escucha(EstadoEscucha::Dictando); + s = update(s, Msg::ToggleMic); + assert_eq!(s.escucha(), EstadoEscucha::Apagado); + assert_eq!(s.tomar_mic_intent(), Some(false)); + } + + #[test] + fn escucha_cambio_fija_el_estado() { + let mut s = State::new(); + s = update(s, Msg::EscuchaCambio(EstadoEscucha::Despierto)); + assert_eq!(s.escucha(), EstadoEscucha::Despierto); + assert!(s.escucha().activo()); + } + + #[test] + fn dictado_inserta_en_el_input_con_espacio() { + let mut s = State::new(); + s = update(s, Msg::Dictado("hola".into())); + assert_eq!(s.input.text(), "hola"); + assert!(s.focused); + // Un segundo dictado se separa con un espacio. + s = update(s, Msg::Dictado("mundo".into())); + assert_eq!(s.input.text(), "hola mundo"); + // Vacío no cambia nada. + s = update(s, Msg::Dictado(String::new())); + assert_eq!(s.input.text(), "hola mundo"); + } + + #[test] + fn enrolar_flujo_completo() { + let mut s = State::new(); + // Arrancar: deja intent y contador en 0. + s = update(s, Msg::EnrolarWake); + assert_eq!(s.enrolando(), Some(0)); + assert_eq!(s.tomar_enrol_intent(), Some(true)); + // El chasis reporta las 3 capturas. + for n in 1..=ENROL_OBJETIVO { + s = update(s, Msg::EnrolarCapturado); + assert_eq!(s.enrolando(), Some(n)); + } + // Capturas de más no pasan del objetivo. + s = update(s, Msg::EnrolarCapturado); + assert_eq!(s.enrolando(), Some(ENROL_OBJETIVO)); + // Terminar: sale de enrolando y marca wake listo. + s = update(s, Msg::EnrolarHecho); + assert_eq!(s.enrolando(), None); + assert!(s.wake_listo); + } + + #[test] + fn enrolar_cancelar_deja_intent_de_corte() { + let mut s = State::new(); + s = update(s, Msg::EnrolarWake); + let _ = s.tomar_enrol_intent(); + s = update(s, Msg::EnrolarCancelar); + assert_eq!(s.enrolando(), None); + assert_eq!(s.tomar_enrol_intent(), Some(false)); + } + + #[test] + fn no_se_enrola_mientras_escucha_ni_se_escucha_enrolando() { + let mut s = State::new(); + // Escuchando → EnrolarWake no arranca. + s.fijar_escucha(EstadoEscucha::Despierto); + s = update(s, Msg::EnrolarWake); + assert_eq!(s.enrolando(), None); + // Enrolando → ToggleMic no enciende el micrófono. + let mut s = State::new(); + s = update(s, Msg::EnrolarWake); + let _ = s.tomar_enrol_intent(); + s = update(s, Msg::ToggleMic); + assert_eq!(s.escucha(), EstadoEscucha::Apagado); + assert_eq!(s.tomar_mic_intent(), None); + } + + #[test] + fn enviar_crea_conversacion_y_pendiente() { + let mut s = estado_con_agente(); + s.input.set_text("hola"); + s = update(s, Msg::Enviar); + assert_eq!(s.conversaciones.len(), 1); + assert!(s.esperando); + let req = s.take_request().expect("debe haber petición"); + assert_eq!(req.conv.turnos.len(), 1); + assert_eq!(req.agente.nombre, "Asistente"); + assert_eq!(s.input.text(), ""); // input limpio + } + + #[test] + fn enviar_vacio_no_hace_nada() { + let mut s = estado_con_agente(); + s.input.set_text(" "); + s = update(s, Msg::Enviar); + assert!(s.conversaciones.is_empty()); + assert!(!s.esperando); + } + + #[test] + fn respuesta_agrega_turno_asistente() { + let mut s = estado_con_agente(); + s.input.set_text("¿hora?"); + s = update(s, Msg::Enviar); + let id = s.conversacion_activa().unwrap().id.clone(); + s = update( + s, + Msg::Respuesta { + conv_id: id, + bloques: vec![BloqueSalida::Texto("son las 3".into())], + ok: true, + entrada: 12, + salida: 5, + }, + ); + assert!(!s.esperando); + let conv = s.conversacion_activa().unwrap(); + assert_eq!(conv.turnos.len(), 2); + assert_eq!(conv.turnos[1].rol, shuma_agente::Rol::Asistente); + // Sin la lectura activada, una respuesta no deja intent de leer. + assert!(s.tomar_leer_intent().is_none()); + } + + #[test] + fn toggle_leer_voz_alterna_opt_in() { + let mut s = State::new(); + assert!(!s.leer_voz()); + s = update(s, Msg::ToggleLeerVoz); + assert!(s.leer_voz()); + s = update(s, Msg::ToggleLeerVoz); + assert!(!s.leer_voz()); + } + + #[test] + fn con_lectura_activa_una_respuesta_stagea_solo_la_prosa() { + let mut s = estado_con_agente(); + s = update(s, Msg::ToggleLeerVoz); // activa la lectura + s.input.set_text("¿hora?"); + s = update(s, Msg::Enviar); + let id = s.conversacion_activa().unwrap().id.clone(); + s = update( + s, + Msg::Respuesta { + conv_id: id, + // Prosa + código + acción: sólo la prosa se lee. + bloques: vec![ + BloqueSalida::Texto("son las 3".into()), + BloqueSalida::Codigo { lenguaje: Some("sh".into()), codigo: "date".into() }, + BloqueSalida::Texto("¿algo más?".into()), + ], + ok: true, + entrada: 0, + salida: 0, + }, + ); + // El código NO aparece; la prosa sí, en orden y separada por salto. + assert_eq!(s.tomar_leer_intent().as_deref(), Some("son las 3\n¿algo más?")); + } + + #[test] + fn una_respuesta_con_error_no_se_lee_aunque_este_activa() { + let mut s = estado_con_agente(); + s = update(s, Msg::ToggleLeerVoz); + s.input.set_text("x"); + s = update(s, Msg::Enviar); + let id = s.conversacion_activa().unwrap().id.clone(); + s = update( + s, + Msg::Respuesta { + conv_id: id, + bloques: vec![BloqueSalida::Error("el modelo se cayó".into())], + ok: false, + entrada: 0, + salida: 0, + }, + ); + assert!(s.tomar_leer_intent().is_none()); + } + + #[test] + fn prosa_vocalizable_descarta_no_texto() { + let bloques = vec![ + BloqueSalida::Codigo { lenguaje: None, codigo: "ls".into() }, + BloqueSalida::Texto("hola".into()), + BloqueSalida::Imagen { media_type: "image/png".into(), data_base64: "AAAA".into() }, + ]; + assert_eq!(prosa_vocalizable(&bloques), "hola"); + } + + #[test] + fn seleccionar_agente_de_control_y_responder_con_accion() { + let mut s = estado_con_agente(); + s = update(s, Msg::SeleccionarAgente(1)); // Control + s.input.set_text("subí el brillo"); + s = update(s, Msg::Enviar); + let req = s.take_request().unwrap(); + assert!(req.agente.capacidades.control); + } + + #[test] + fn aprobar_accion_deja_ejecucion_y_marca_estado() { + let mut s = estado_con_agente(); + s.input.set_text("x"); + s = update(s, Msg::Enviar); + let id = s.conversacion_activa().unwrap().id.clone(); + let accion = shuma_agente::AccionPropuesta { + id: "sistema.brillo".into(), + linea_comando: "brightnessctl set 80".into(), + peligro: Peligro::Reversible, + estado: EstadoAccion::Propuesta, + }; + s = update( + s, + Msg::Respuesta { conv_id: id, bloques: vec![BloqueSalida::Accion(accion)], ok: true, entrada: 0, salida: 0 }, + ); + // turno 1 = asistente, bloque 0 = acción. + s = update(s, Msg::Aprobar { turno: 1, bloque: 0 }); + let ej = s.take_ejecucion().expect("acción aprobada se ejecuta"); + assert_eq!(ej.id, "sistema.brillo"); + let conv = s.conversacion_activa().unwrap(); + match &conv.turnos[1].bloques[0] { + BloqueSalida::Accion(a) => assert_eq!(a.estado, EstadoAccion::Aprobada), + _ => panic!("esperaba acción"), + } + } + + #[test] + fn alta_de_agente_persiste_y_aparece() { + let mut s = estado_con_agente(); + let antes = s.agentes.len(); + s = update(s, Msg::NuevoAgente); + assert!(s.editor.is_some()); + // Escribe un nombre en el campo enfocado (Nombre). + if let Some(ed) = s.editor.as_mut() { + ed.nombre.set_text("Traductor"); + } + s = update(s, Msg::EditorCiclarBackend); // claude-cli → anthropic + s = update(s, Msg::GuardarAgente); + assert!(s.editor.is_none()); + assert_eq!(s.agentes.len(), antes + 1); + let ag = s.take_persist_agente().expect("debe pedir persistir"); + assert_eq!(ag.nombre, "Traductor"); + assert_eq!(ag.backend.backend, "anthropic"); + } + + #[test] + fn alta_sin_nombre_no_guarda() { + let mut s = estado_con_agente(); + s = update(s, Msg::NuevoAgente); + s = update(s, Msg::GuardarAgente); + assert!(s.editor.is_some()); // reabre para corregir + assert!(s.persist_agente.is_none()); + } + + #[test] + fn editar_agente_conserva_id() { + let mut s = estado_con_agente(); + let id0 = s.agentes[0].id.clone(); + s = update(s, Msg::SeleccionarAgente(0)); + s = update(s, Msg::EditarAgente); + if let Some(ed) = s.editor.as_mut() { + ed.nombre.set_text("Asistente Pro"); + } + s = update(s, Msg::GuardarAgente); + let ag = s.take_persist_agente().unwrap(); + assert_eq!(ag.id, id0); // mismo id (edición, no alta) + assert_eq!(ag.nombre, "Asistente Pro"); + assert_eq!(s.agentes[0].nombre, "Asistente Pro"); + } + + #[test] + fn borrar_agente_lo_saca_y_pide_borrado() { + let mut s = estado_con_agente(); + let id0 = s.agentes[0].id.clone(); + let antes = s.agentes.len(); + s = update(s, Msg::SeleccionarAgente(0)); + s = update(s, Msg::EditarAgente); + s = update(s, Msg::BorrarAgente); + assert_eq!(s.agentes.len(), antes - 1); + assert_eq!(s.take_borrar_agente().as_deref(), Some(id0.as_str())); + } + + #[test] + fn cargar_imagenes_parsea_lineas_img() { + let ruta = std::env::temp_dir().join("shuma-agente-test-img.png"); + std::fs::write(&ruta, b"\x89PNG fake bytes").unwrap(); + let entrada = format!("mira esto\nimg:{}\ny dime", ruta.display()); + let (texto, imgs) = cargar_imagenes(&entrada); + assert_eq!(texto, "mira esto\ny dime"); + assert_eq!(imgs.len(), 1); + assert_eq!(imgs[0].0, "image/png"); + assert!(!imgs[0].1.is_empty()); // base64 no vacío + // Una ruta inexistente se descarta sin romper. + let (t2, i2) = cargar_imagenes("texto\nimg:/no/existe.png"); + assert_eq!(t2, "texto"); + assert!(i2.is_empty()); + let _ = std::fs::remove_file(&ruta); + } + + #[test] + fn borrar_conversacion_la_saca_y_pide_borrado() { + let mut s = estado_con_agente(); + s.input.set_text("hola"); + s = update(s, Msg::Enviar); + let id = s.conversacion_activa().unwrap().id.clone(); + assert_eq!(s.conversaciones.len(), 1); + s = update(s, Msg::BorrarConversacion(0)); + assert!(s.conversaciones.is_empty()); + assert!(s.conv_activa.is_none()); // era la activa + assert_eq!(s.take_borrar_conversacion().as_deref(), Some(id.as_str())); + } + + #[test] + fn renombrar_conversacion_cambia_el_titulo() { + let mut s = estado_con_agente(); + s.input.set_text("hola mundo"); + s = update(s, Msg::Enviar); + s = update(s, Msg::RenombrarConversacion(0)); + assert!(s.renombrando.is_some()); + if let Some((_, input)) = s.renombrando.as_mut() { + input.set_text("Mi charla"); + } + s = update(s, Msg::ConfirmarRenombre); + assert!(s.renombrando.is_none()); + assert_eq!(s.conversaciones[0].titulo, "Mi charla"); + } + + #[test] + fn nueva_conversacion_deselecciona() { + let mut s = estado_con_agente(); + s.input.set_text("hola"); + s = update(s, Msg::Enviar); + assert!(s.conv_activa.is_some()); + s = update(s, Msg::NuevaConversacion); + assert!(s.conv_activa.is_none()); + } +} diff --git a/02_ruway/shuma/sandbox/shuma-module-canvas/LEEME.md b/02_ruway/shuma/sandbox/shuma-module-canvas/LEEME.md new file mode 100644 index 0000000..83e004a --- /dev/null +++ b/02_ruway/shuma/sandbox/shuma-module-canvas/LEEME.md @@ -0,0 +1,26 @@ +# shuma-module-canvas + +*Read this in English: [README.md](README.md).* + +El **Lienzo de Contexto** del shell. + +Tab/panel que dibuja el `SessionGraph` de `shuma-intent` como un +grafo visual: cada comando `%cN` es una caja, las dependencias +`%pN` son flechas hacia el comando que las produjo. El usuario ve +el flujo entero de la sesión y puede saltar atrás (referencia +`%c3`) o "tirar de un hilo" para reusarlo. + +El layout es columnar por profundidad (longest-path): la columna +`0` son los comandos sin dependencias, la `N` los que dependen de +columnas ` State { } }, Msg::InsertRef(_) => { - // No-op acá — el chasis intercepta esta variante antes de + // No-op aquí — el chasis intercepta esta variante antes de // que entre al update del canvas. Si llega es porque el // canvas está corriendo standalone (sin chasis); no podemos // hacer nada útil sin acceso al shell. diff --git a/02_ruway/shuma/sandbox/shuma-module-commandbar/Cargo.toml b/02_ruway/shuma/sandbox/shuma-module-commandbar/Cargo.toml index 687ad35..4a335e1 100644 --- a/02_ruway/shuma/sandbox/shuma-module-commandbar/Cargo.toml +++ b/02_ruway/shuma/sandbox/shuma-module-commandbar/Cargo.toml @@ -9,6 +9,15 @@ description = "shuma-module-commandbar — barra inferior fija (Placement::Botto [dependencies] shuma-module = { path = "../shuma-module" } +# Indicador de escucha por voz compartido (botón de mic + EstadoEscucha): el +# mismo widget que el panel de chat y el shell input — un solo «llamado shuma». +shuma-voz-ui = { path = "../shuma-voz-ui" } llimphi-ui = { workspace = true } llimphi-theme = { workspace = true } nucleo-matcher = { workspace = true } + +[dev-dependencies] +# Render headless de la marquesina en sus tiers de urgencia (`--example +# marquesina_tiers`) para verificar el look sin abrir ventana. +png = { workspace = true } +pollster = { workspace = true } diff --git a/02_ruway/shuma/sandbox/shuma-module-commandbar/examples/marquesina_tiers.rs b/02_ruway/shuma/sandbox/shuma-module-commandbar/examples/marquesina_tiers.rs new file mode 100644 index 0000000..2412257 --- /dev/null +++ b/02_ruway/shuma/sandbox/shuma-module-commandbar/examples/marquesina_tiers.rs @@ -0,0 +1,233 @@ +//! Volcado headless de la **marquesina dentro del input** de la command-bar, en +//! sus tres tiers de urgencia (feedback de diseño: DISENO-SHELL-NAVEGADOR §5.1). +//! La marquesina es el placeholder del input en reposo y cede al tipear. +//! +//! Renderiza apiladas, sobre el emblema PS1, las variantes: +//! 1. reposo sin avisos → placeholder default +//! 2. Leve → sugerencia tenue (muted), sin robar atención +//! 3. Urgente → titila: fase par = lleno +//! 4. Urgente → titila: fase impar = atenuado +//! 5. tipeando → el comando toma el lugar (la marquesina cede) +//! +//! `cargo run -p shuma-module-commandbar --example marquesina_tiers -- out.png` + +use std::fs::File; +use std::io::BufWriter; + +use llimphi_theme::Theme; +use llimphi_ui::llimphi_compositor::{measure_text_node, mount, paint}; +use llimphi_ui::llimphi_hal::{wgpu, Hal}; +use llimphi_ui::llimphi_layout::taffy; +use llimphi_ui::llimphi_layout::taffy::prelude::{length, percent, FlexDirection, Size, Style}; +use llimphi_ui::llimphi_layout::taffy::Rect; +use llimphi_ui::llimphi_layout::LayoutTree; +use llimphi_ui::llimphi_raster::kurbo::{Affine, Circle, RoundedRect, Stroke}; +use llimphi_ui::llimphi_raster::peniko::{Color, Fill}; +use llimphi_ui::llimphi_raster::{vello, Renderer}; +use llimphi_ui::llimphi_text::{Alignment, Typesetter}; +use llimphi_ui::View; +use shuma_module_commandbar::{Marquesina, Mode, State, Urgencia}; + +const W: u32 = 900; +const H: u32 = 460; +const FMT: wgpu::TextureFormat = wgpu::TextureFormat::Rgba8Unorm; + +/// Emblema PS1 — mismo dibujo que `dock_ps1` del chasis (anillos + núcleo + satélite). +fn ps1(theme: &Theme) -> View<()> { + let accent = theme.accent; + let panel_alt = theme.bg_panel_alt; + let verde = Color::from_rgb8(0x5A, 0xD0, 0x8A); + let ambar = Color::from_rgb8(0xE0, 0xB2, 0x4A); + View::new(Style { + size: Size { width: length(34.0), height: length(34.0) }, + flex_shrink: 0.0, + ..Default::default() + }) + .paint_with(move |scene, _ts, rect| { + if rect.w <= 0.0 || rect.h <= 0.0 { + return; + } + let cx = (rect.x + rect.w * 0.5) as f64; + let cy = (rect.y + rect.h * 0.5) as f64; + let lado = rect.w.min(rect.h) as f64; + let bg = RoundedRect::new( + rect.x as f64, + rect.y as f64, + (rect.x + rect.w) as f64, + (rect.y + rect.h) as f64, + 9.0, + ); + scene.fill(Fill::NonZero, Affine::IDENTITY, panel_alt, None, &bg); + for (i, c) in [accent, verde, ambar].iter().enumerate() { + let r = lado * 0.15 + i as f64 * lado * 0.105; + let a = 0.95 - i as f32 * 0.22; + scene.stroke(&Stroke::new(2.1), Affine::IDENTITY, c.with_alpha(a), None, &Circle::new((cx, cy), r)); + } + scene.fill(Fill::NonZero, Affine::IDENTITY, accent, None, &Circle::new((cx, cy), lado * 0.085)); + scene.fill( + Fill::NonZero, + Affine::IDENTITY, + verde, + None, + &Circle::new((cx + lado * 0.30, cy - lado * 0.30), lado * 0.05), + ); + }) +} + +/// Una fila: rótulo a la izquierda + PS1 + la command-bar con el `state` dado. +fn fila(rotulo: &str, state: State, theme: &Theme) -> View<()> { + let etq = View::new(Style { + size: Size { width: length(150.0), height: length(28.0) }, + flex_shrink: 0.0, + ..Default::default() + }) + .text_aligned(rotulo.to_string(), 11.0, theme.fg_muted, Alignment::Start); + let barra = View::new(Style { + flex_grow: 1.0, + flex_basis: length(0.0), + size: Size { width: length(0.0), height: length(28.0) }, + ..Default::default() + }) + .radius(8.0) + .children(vec![shuma_module_commandbar::view::<()>(&state, theme, |_| ())]); + View::new(Style { + flex_direction: FlexDirection::Row, + size: Size { width: percent(1.0), height: length(34.0) }, + gap: Size { width: length(12.0), height: length(0.0) }, + align_items: Some(taffy::AlignItems::Center), + ..Default::default() + }) + .children(vec![etq, ps1(theme), barra]) +} + +fn con_marquesina(texto: &str, urgencia: Urgencia, fase: u8) -> State { + State { + marquesina: Some(Marquesina { urgencia, ..Marquesina::leve(texto) }), + fase, + ..Default::default() + } +} + +fn main() { + let out = std::env::args().nth(1).unwrap_or_else(|| "marquesina_tiers.png".to_string()); + let theme = Theme::dark(); + + let tipeando = State { text: "cargo build --release".to_string(), mode: Mode::Shell, ..Default::default() }; + + let filas = vec![ + fila("reposo (sin avisos)", State::default(), &theme), + fila("Leve — sugerencia tenue", con_marquesina("pluma · borrador guardado", Urgencia::Leve, 0), &theme), + fila("Urgente — titila (lleno)", con_marquesina("CI · build failed en main", Urgencia::Urgente, 0), &theme), + fila("Urgente — titila (atenuado)", con_marquesina("CI · build failed en main", Urgencia::Urgente, 1), &theme), + fila("tipeando (la marquesina cede)", tipeando, &theme), + ]; + + let cap = View::new(Style { + size: Size { width: percent(1.0), height: length(24.0) }, + ..Default::default() + }) + .text_aligned("shuma · marquesina en el input — tiers de urgencia".to_string(), 16.0, theme.fg_text, Alignment::Start) + .text_weight(650.0); + + let mut hijos = vec![cap]; + hijos.extend(filas); + let v = View::new(Style { + flex_direction: FlexDirection::Column, + size: Size { width: percent(1.0), height: percent(1.0) }, + gap: Size { width: length(0.0), height: length(12.0) }, + padding: Rect { left: length(32.0), right: length(32.0), top: length(28.0), bottom: length(28.0) }, + ..Default::default() + }) + .fill(theme.bg_app) + .children(hijos); + + let mut layout = LayoutTree::new(); + let mounted = mount(&mut layout, v); + let mut ts = Typesetter::new(); + let computed = { + let tmap = &mounted.text_measures; + layout + .compute_with_measure(mounted.root, (W as f32, H as f32), |nid, known, avail| { + match tmap.get(&nid) { + Some(tm) => measure_text_node(&mut ts, tm, known, avail), + None => taffy::Size::ZERO, + } + }) + .expect("layout") + }; + let mut scene = vello::Scene::new(); + paint(&mut scene, &mounted, &computed, &mut ts, None, None); + + let hal = pollster::block_on(Hal::new(None)).expect("hal"); + let mut renderer = Renderer::new(&hal).expect("renderer"); + let target = hal.device.create_texture(&wgpu::TextureDescriptor { + label: Some("dump-marquesina"), + size: wgpu::Extent3d { width: W, height: H, depth_or_array_layers: 1 }, + mip_level_count: 1, + sample_count: 1, + dimension: wgpu::TextureDimension::D2, + format: FMT, + usage: wgpu::TextureUsages::STORAGE_BINDING + | wgpu::TextureUsages::RENDER_ATTACHMENT + | wgpu::TextureUsages::COPY_SRC, + view_formats: &[], + }); + let view = target.create_view(&wgpu::TextureViewDescriptor::default()); + let bg = Color::from_rgba8(6, 8, 12, 255); + renderer.render_to_view(&hal, &scene, &view, W, H, bg).expect("render_to_view"); + + write_png(&hal, &target, &out); + eprintln!("marquesina_tiers: escrito {out} ({W}x{H})"); +} + +fn write_png(hal: &Hal, target: &wgpu::Texture, path: &str) { + let unpadded = (W * 4) as usize; + let align = wgpu::COPY_BYTES_PER_ROW_ALIGNMENT as usize; + let padded = unpadded.div_ceil(align) * align; + let buf = hal.device.create_buffer(&wgpu::BufferDescriptor { + label: Some("readback"), + size: (padded * H as usize) as u64, + usage: wgpu::BufferUsages::MAP_READ | wgpu::BufferUsages::COPY_DST, + mapped_at_creation: false, + }); + let mut enc = hal.device.create_command_encoder(&wgpu::CommandEncoderDescriptor { label: None }); + enc.copy_texture_to_buffer( + wgpu::TexelCopyTextureInfo { + texture: target, + mip_level: 0, + origin: wgpu::Origin3d::ZERO, + aspect: wgpu::TextureAspect::All, + }, + wgpu::TexelCopyBufferInfo { + buffer: &buf, + layout: wgpu::TexelCopyBufferLayout { + offset: 0, + bytes_per_row: Some(padded as u32), + rows_per_image: Some(H), + }, + }, + wgpu::Extent3d { width: W, height: H, depth_or_array_layers: 1 }, + ); + hal.queue.submit(std::iter::once(enc.finish())); + let slice = buf.slice(..); + let (tx, rx) = std::sync::mpsc::channel(); + slice.map_async(wgpu::MapMode::Read, move |r| { + let _ = tx.send(r); + }); + let _ = hal.device.poll(wgpu::PollType::wait_indefinitely()); + rx.recv().unwrap().unwrap(); + let data = slice.get_mapped_range(); + let mut pixels = Vec::with_capacity((W * H * 4) as usize); + for row in 0..H as usize { + let s = row * padded; + pixels.extend_from_slice(&data[s..s + unpadded]); + } + drop(data); + buf.unmap(); + let file = File::create(path).expect("png"); + let mut enc = png::Encoder::new(BufWriter::new(file), W, H); + enc.set_color(png::ColorType::Rgba); + enc.set_depth(png::BitDepth::Eight); + let mut w = enc.write_header().unwrap(); + w.write_image_data(&pixels).unwrap(); +} diff --git a/02_ruway/shuma/sandbox/shuma-module-commandbar/src/lib.rs b/02_ruway/shuma/sandbox/shuma-module-commandbar/src/lib.rs index f3d8b15..2ae9af5 100644 --- a/02_ruway/shuma/sandbox/shuma-module-commandbar/src/lib.rs +++ b/02_ruway/shuma/sandbox/shuma-module-commandbar/src/lib.rs @@ -66,6 +66,14 @@ impl Mode { } } +// La marquesina es un contrato compartido (el shell también la usa como +// placeholder): vive en `shuma-module`. Re-exportada para el uso local. +pub use shuma_module::{Marquesina, Urgencia}; + +// El indicador de escucha por voz (botón de mic + estados) es compartido: vive +// en `shuma-voz-ui`, el mismo widget que pinta el panel de chat y el shell input. +pub use shuma_voz_ui::EstadoEscucha; + /// Una entrada del catálogo de comandos. El `kind` decide qué hace /// el chasis al activarla. #[derive(Debug, Clone, PartialEq, Eq)] @@ -102,6 +110,24 @@ pub struct State { pub catalog: Vec, /// Índice seleccionado dentro de la lista de matches actuales. pub selected: usize, + /// Aviso a narrar en el input cuando está en **reposo** (marquesina). `None` + /// = sin avisos → placeholder default. Lo fija el chasis desde el centro de + /// eventos willay. Cede el lugar apenas hay texto tipeado. + pub marquesina: Option, + /// Fase de parpadeo (la incrementa el chasis en cada tick); sólo la usan los + /// avisos `Urgencia::Urgente` para titilar. + pub fase: u8, + /// Estado de la **escucha por voz** — el «llamado shuma estilo alexa» en la + /// command-bar. Lo fija el chasis desde los `EventoEscucha` de rimay-voz-host; + /// el módulo sólo lo pinta (halo del botón de micrófono). + pub escucha: EstadoEscucha, + /// Reloj (epoch ms) inyectado por el chasis para animar el halo del mic + /// mientras escucha. El `update` no lo lee. + pub reloj_ms: u64, + /// Intent: el usuario tocó el micrófono para encender (`Some(true)`) o apagar + /// (`Some(false)`). El chasis lo toma con [`State::tomar_mic_intent`] y + /// arranca/para la captura de `rimay-voz-host`. `None` = nada pendiente. + pub mic_intent: Option, } impl State { @@ -110,6 +136,42 @@ impl State { self.selected = 0; } + /// Fija el aviso a narrar en reposo (o lo limpia con `None`). Lo llama el + /// chasis tras leer el centro de eventos willay. + pub fn set_marquesina(&mut self, m: Option) { + self.marquesina = m; + } + + /// Avanza la fase de parpadeo (el chasis pasa su contador de ticks). + pub fn set_fase(&mut self, fase: u8) { + self.fase = fase; + } + + // ── Voz: el «llamado shuma» en la command-bar ─────────────────────────── + + /// Fija el estado de escucha (lo llama el chasis al recibir un `EventoEscucha` + /// de `rimay-voz-host`). + pub fn fijar_escucha(&mut self, e: EstadoEscucha) { + self.escucha = e; + } + + /// Estado actual de la escucha (para el chasis / tests). + pub fn escucha(&self) -> EstadoEscucha { + self.escucha + } + + /// Fija el reloj para animar el halo del micrófono (el chasis lo refresca en + /// cada tick mientras escucha). + pub fn set_reloj(&mut self, reloj_ms: u64) { + self.reloj_ms = reloj_ms; + } + + /// Toma el intent de encender/apagar el micrófono y lo limpia. El chasis lo + /// consulta tras cada `update` y arranca/para `rimay-voz-host`. + pub fn tomar_mic_intent(&mut self) -> Option { + self.mic_intent.take() + } + /// Devuelve los índices de `catalog` que matchean `self.text`, /// ordenados por score descendente. Limita a `limit` resultados. pub fn matches(&self, limit: usize) -> Vec { @@ -144,7 +206,7 @@ impl State { #[derive(Debug, Clone)] pub enum Msg { /// Tecla recibida desde el chasis. Texto, Backspace, Up/Down y - /// Enter se procesan acá. + /// Enter se procesan aquí. Key(KeyEvent), /// El usuario togglea el modo (Ctrl+grave o similar). ToggleMode, @@ -153,6 +215,12 @@ pub enum Msg { /// Click sobre la barra (no en el dropdown). El chasis lo /// intercepta para abrir el drawer Quake; el módulo no lo procesa. BarClicked, + /// Click en el botón de micrófono: alterna encender/apagar la escucha por + /// voz. Deja el intent para que el chasis arranque/pare `rimay-voz-host`. + ToggleMic, + /// Texto dictado por voz (STT): se inserta en el input, como si se tipeara. + /// Lo dispatcha el chasis al mapear un `EventoEscucha::Dictar`. + Dictado(String), } /// El chasis observa este `Activated` después de llamar `update` y @@ -193,7 +261,7 @@ pub fn update(state: State, msg: Msg) -> State { s.selected = 0; } Key::Named(NamedKey::Enter) => { - // Enter NO clear-ea acá — el chasis intercepta el + // Enter NO clear-ea aquí — el chasis intercepta el // submit y limpia tras procesar el Activation. } _ => { @@ -216,6 +284,22 @@ pub fn update(state: State, msg: Msg) -> State { s.selected = idx; } Msg::BarClicked => {} + Msg::ToggleMic => { + // Alterna: si escucha, pedir apagar; si no, pedir encender. El estado + // real (Esperando/Oyendo/…) lo fija el chasis según arranque cpal. + s.mic_intent = Some(!s.escucha.activo()); + } + Msg::Dictado(text) => { + // El STT entrega la utterance; se inserta como texto tipeado. Un + // espacio de separación si ya había algo, para no pegar palabras. + if !text.is_empty() { + if !s.text.is_empty() && !s.text.ends_with(' ') { + s.text.push(' '); + } + s.text.push_str(text.trim()); + s.selected = 0; + } + } } s } @@ -264,18 +348,66 @@ pub fn view( theme: &Theme, lift: impl Fn(Msg) -> HostMsg + 'static + Clone, ) -> View { + use llimphi_ui::llimphi_raster::peniko::Color; let prompt = format!("{} ", state.mode.prompt()); let placeholder = format!( - "{}escribí — Enter ejecuta · Ctrl+` cambia a {}", + "{}escribe — Enter ejecuta · Ctrl+` cambia a {}", prompt, state.mode.toggle().label() ); - let display_text = if state.text.is_empty() { - placeholder + // Aviso urgente: color cálido de alerta (ámbar), como el emblema del PS1. + let alerta = Color::from_rgb8(0xE0, 0xB2, 0x4A); + // Texto y color según el estado: tipeando pinta el comando; en reposo, la + // marquesina si hay aviso narrable (no silenciado), o el placeholder default. + let (display_text, color) = if !state.text.is_empty() { + (format!("{}{}", prompt, state.text), theme.fg_text) + } else if let Some(m) = state + .marquesina + .as_ref() + .filter(|m| m.urgencia != Urgencia::Silencio) + { + let color = match m.urgencia { + // Calma: el murmullo idle — aún más suave que leve. + Urgencia::Calma => theme.fg_placeholder, + // Leve: tenue, sin robar atención. + Urgencia::Leve => theme.fg_muted, + // Urgente: titila alternando lleno/atenuado según la fase del chasis. + Urgencia::Urgente => { + if state.fase % 2 == 0 { + alerta + } else { + alerta.with_alpha(0.4) + } + } + Urgencia::Silencio => theme.fg_muted, // filtrado arriba; inalcanzable + }; + // Sin prompt: es narración ambiental, no una línea a ejecutar. + (format!(" {}", m.texto), color) } else { - format!("{}{}", prompt, state.text) + (placeholder, theme.fg_muted) }; + // El texto (comando/marquesina/placeholder) toma todo el ancho; a la derecha, + // el botón de micrófono con el halo animado — el «llamado shuma» también aquí. + let texto = View::new(Style { + size: Size { + width: Dimension::auto(), + height: percent(1.0_f32), + }, + flex_grow: 1.0, + align_items: Some(AlignItems::Center), + ..Default::default() + }) + .text_aligned(display_text, 12.0, color, Alignment::Start) + .on_click(lift.clone()(Msg::BarClicked)); + let mic = shuma_voz_ui::boton_mic( + state.escucha, + false, + state.reloj_ms, + 24.0, + theme, + lift.clone()(Msg::ToggleMic), + ); let bar = View::new(Style { flex_direction: FlexDirection::Row, size: Size { @@ -284,25 +416,19 @@ pub fn view( }, padding: Rect { left: length(14.0_f32), - right: length(14.0_f32), + right: length(8.0_f32), top: length(0.0_f32), bottom: length(0.0_f32), }, + gap: Size { + width: length(6.0_f32), + height: length(0.0_f32), + }, align_items: Some(AlignItems::Center), ..Default::default() }) .fill(theme.bg_panel) - .text_aligned( - display_text, - 12.0, - if state.text.is_empty() { - theme.fg_muted - } else { - theme.fg_text - }, - Alignment::Start, - ) - .on_click(lift.clone()(Msg::BarClicked)); + .children(vec![texto, mic]); // Dropdown sólo en modo Launcher con texto no vacío. if !matches!(state.mode, Mode::Launcher) || state.text.is_empty() { @@ -397,7 +523,7 @@ mod tests { CommandEntry { label: "Pluma editor".into(), category: "app".into(), - kind: CommandKind::Exec("pluma-app".into()), + kind: CommandKind::Exec("pluma-app-llimphi".into()), }, CommandEntry { label: "Focus shell".into(), @@ -439,6 +565,33 @@ mod tests { assert_eq!(s.catalog[m[0]].label, "Focus shell"); } + #[test] + fn toggle_mic_deja_intent_una_vez() { + let mut s = State::default(); + // Apagado → tocar el mic pide encender. + s = update(s, Msg::ToggleMic); + assert_eq!(s.tomar_mic_intent(), Some(true)); + // El intent se consume una sola vez. + assert_eq!(s.tomar_mic_intent(), None); + // Con la escucha activa, tocar el mic pide apagar. + s.fijar_escucha(EstadoEscucha::Oyendo); + s = update(s, Msg::ToggleMic); + assert_eq!(s.tomar_mic_intent(), Some(false)); + } + + #[test] + fn dictado_inserta_texto_con_espacio() { + let mut s = State::default(); + s = update(s, Msg::Dictado("abrir cosmos".into())); + assert_eq!(s.text, "abrir cosmos"); + // Una segunda utterance se separa con un espacio, no se pega. + s = update(s, Msg::Dictado("y pluma".into())); + assert_eq!(s.text, "abrir cosmos y pluma"); + // Vacío no toca nada. + s = update(s, Msg::Dictado(String::new())); + assert_eq!(s.text, "abrir cosmos y pluma"); + } + #[test] fn arrow_down_moves_selection() { let mut s = State::default(); @@ -462,7 +615,7 @@ mod tests { // selected = 0 → "Pluma editor" let enter = ev(Key::Named(NamedKey::Enter), None); let kind = activation_for(&s, &enter).expect("activación"); - assert!(matches!(kind, CommandKind::Exec(ref l) if l == "pluma-app")); + assert!(matches!(kind, CommandKind::Exec(ref l) if l == "pluma-app-llimphi")); } #[test] diff --git a/02_ruway/shuma/sandbox/shuma-module-consola/Cargo.toml b/02_ruway/shuma/sandbox/shuma-module-consola/Cargo.toml new file mode 100644 index 0000000..e3da38b --- /dev/null +++ b/02_ruway/shuma/sandbox/shuma-module-consola/Cargo.toml @@ -0,0 +1,23 @@ +[package] +name = "shuma-module-consola" +version.workspace = true +edition.workspace = true +license.workspace = true +authors.workspace = true +publish.workspace = true +description = "shuma — módulo de UI Llimphi de la consola de claudes: tira de tabs con badges de atención + transcript de la sesión activa reducido a etapas desplegables (pensamiento/herramienta/texto) + barra de input. Puro (State/Msg/update/view); el chasis lo alimenta con snapshots del ConsolaRegistro y ejecuta sus intents. El mismo módulo hospeda el escritorio y el Android." + +[dependencies] +shuma-consola-core = { path = "../shuma-consola-core" } +llimphi-ui = { workspace = true } +llimphi-theme = { workspace = true } +llimphi-widget-button = { workspace = true } +llimphi-widget-scroll = { workspace = true } +llimphi-widget-text-input = { workspace = true } +llimphi-widget-rag-sidebar = { workspace = true } +llimphi-widget-dock-rail = { workspace = true } +llimphi-icons = { workspace = true } + +[dev-dependencies] +# El chasis de escritorio (examples/consola_desktop.rs) maneja el registro real. +shuma-consola-host = { path = "../shuma-consola-host" } diff --git a/02_ruway/shuma/sandbox/shuma-module-consola/LEEME.md b/02_ruway/shuma/sandbox/shuma-module-consola/LEEME.md new file mode 100644 index 0000000..e084599 --- /dev/null +++ b/02_ruway/shuma/sandbox/shuma-module-consola/LEEME.md @@ -0,0 +1,25 @@ +# shuma-module-consola + +*Read this in English: [README.md](README.md).* + +La UI Llimphi de la **consola de claudes**. + +Es un módulo puro (`State`/`Msg`/`update`/`view`) en el bucle Elm de +Llimphi. NO habla con el registro: el **chasis** lo alimenta con snapshots +del `shuma_consola_core::Sesion` activo (via `State::refrescar`) y +ejecuta los **intents** que el módulo deja (`take_crear`/`take_enviar`/…), +igual que shuma-module-agente con el host. El mismo módulo hospeda el +escritorio (`examples/consola_desktop.rs`) y, mañana, el chasis Android. + +Layout: **el sidebar unificado** `llimphi_widget_rag_sidebar` es el ÚNICO +dueño del chrome — rail de dientes (una por sesión, con badge de `Atencion`) + +panel con cabezal uniforme + buscador + control de disposición. El cuerpo del +diente abierto = **subtítulo de estado** + **transcript** de la sesión activa +reducido a etapas desplegables (pensamiento/herramienta colapsados por defecto; +el texto siempre visible) + **barra de input**. "Logs por etapas, no un +terminal". La consola ya no pinta rail/panel/header propios: todo sale del +widget compartido, igual que agora/nakui/cosmos. + +--- + +Parte de **shuma** — ver [shuma](../../LEEME.md). diff --git a/02_ruway/shuma/sandbox/shuma-module-consola/README.md b/02_ruway/shuma/sandbox/shuma-module-consola/README.md new file mode 100644 index 0000000..da53b28 --- /dev/null +++ b/02_ruway/shuma/sandbox/shuma-module-consola/README.md @@ -0,0 +1,12 @@ +# shuma-module-consola + +The Llimphi UI of the **console of claudes**. + +It is a pure module (`State`/`Msg`/`update`/`view`) in Llimphi's Elm loop. It does +NOT talk to the registry: the **chassis** feeds it snapshots of the active +`shuma_consola_core::Sesion` (through `State::refrescar`) and executes the +**intents** the module leaves behind (`take_crear`/`take_enviar`/…). + +--- + +Part of **shuma** — see [shuma](../../README.md). diff --git a/02_ruway/shuma/sandbox/shuma-module-consola/examples/consola_desktop.rs b/02_ruway/shuma/sandbox/shuma-module-consola/examples/consola_desktop.rs new file mode 100644 index 0000000..b63b2de --- /dev/null +++ b/02_ruway/shuma/sandbox/shuma-module-consola/examples/consola_desktop.rs @@ -0,0 +1,133 @@ +//! Chasis de **escritorio** de la consola de claudes: maneja un +//! `ConsolaRegistro` real en proceso y le presta la UI del módulo. Es la misma +//! app que mañana hospeda el chasis Android — aquí sirve para ver la forma +//! (tabs + etapas desplegables + input) sobre `claude` de verdad. +//! +//! Correr: `cargo run -p shuma-module-consola --example consola_desktop` +//! (necesita `claude` en el PATH y logueado). "+ nuevo claude" → escribe el +//! primer mensaje + Enter → arranca una sesión agéntica; los tabs guardan cada +//! claude vivo, con badge de atención. + +use std::sync::Arc; +use std::time::Duration; + +use llimphi_ui::{App, Handle, Key, KeyEvent, KeyState, NamedKey, View}; +use llimphi_theme::Theme; +use llimphi_widget_text_input::TextInputEvent; +use shuma_consola_host::ConsolaRegistro; +use shuma_module_consola::{self as consola, State, Tab}; + +struct Modelo { + st: State, + theme: Theme, + reg: Arc, + cwd: String, +} + +#[derive(Clone, Debug)] +enum Msg { + M(consola::Msg), + Tick, + Medida(f32, f32), +} + +/// Refresca la vista desde el registro y ejecuta los intents del módulo. +fn sincronizar(m: &mut Modelo) { + // Ejecutar lo que el módulo pidió. + if let Some(texto) = m.st.take_crear() { + if let Ok(id) = m.reg.crear(&m.cwd, texto, None) { + m.st.set_activa(Some(id)); + } + } + if let Some((id, texto)) = m.st.take_enviar() { + let _ = m.reg.enviar(&id, texto); + } + if let Some(id) = m.st.take_seleccion() { + m.reg.marcar_leido(&id); + } + if let Some(id) = m.st.take_cerrar() { + m.reg.kill(&id); + } + // Traer el estado fresco. + let tabs = m + .reg + .list() + .into_iter() + .map(|r| Tab { id: r.id, titulo: r.titulo, atencion: r.atencion }) + .collect(); + let sesion = m + .st + .activa + .clone() + .and_then(|id| m.reg.snapshot(&id)); + m.st.refrescar(tabs, sesion); +} + +struct ConsolaApp; + +impl App for ConsolaApp { + type Model = Modelo; + type Msg = Msg; + + fn title() -> &'static str { + "Consola de claudes" + } + + fn initial_size() -> (u32, u32) { + (1040, 720) + } + + fn init(handle: &Handle) -> Modelo { + // Poll del registro (las sesiones corren en hilos propios). + handle.spawn_periodic(Duration::from_millis(200), || Msg::Tick); + let cwd = std::env::current_dir() + .map(|p| p.display().to_string()) + .unwrap_or_else(|_| ".".to_string()); + let mut st = State::new(); + st.fijar_vista_alto(720.0 - 98.0); + st.fijar_vista_ancho(1040.0); + Modelo { + st, + theme: Theme::dark(), + reg: Arc::new(ConsolaRegistro::con_claude()), + cwd, + } + } + + fn update(mut m: Modelo, msg: Msg, _handle: &Handle) -> Modelo { + match msg { + Msg::M(mm) => { + m.st = consola::update(m.st, mm); + sincronizar(&mut m); + } + Msg::Tick => sincronizar(&mut m), + Msg::Medida(w, h) => { + m.st.fijar_vista_ancho(w); + m.st.fijar_vista_alto(h); + } + } + m + } + + fn view(m: &Modelo) -> View { + consola::view(&m.st, &m.theme, Msg::M) + } + + fn on_key(_m: &Modelo, ev: &KeyEvent) -> Option { + // Enter envía; el resto va al input (single-line). + if ev.state == KeyState::Pressed && matches!(ev.key, Key::Named(NamedKey::Enter)) { + return Some(Msg::M(consola::Msg::Enviar)); + } + Some(Msg::M(consola::Msg::CampoInput(TextInputEvent::Key(ev.clone())))) + } + + fn on_resize(_m: &Modelo, w: u32, h: u32) -> Option { + // El transcript ocupa la ventana menos header (~46) + input (~52); el panel + // del sidebar unificado se estira al ancho de la ventana. + Some(Msg::Medida(w as f32, (h as f32 - 98.0).max(120.0))) + } +} + +fn main() { + llimphi_ui::run::(); +} diff --git a/02_ruway/shuma/sandbox/shuma-module-consola/src/lib.rs b/02_ruway/shuma/sandbox/shuma-module-consola/src/lib.rs new file mode 100644 index 0000000..be5f694 --- /dev/null +++ b/02_ruway/shuma/sandbox/shuma-module-consola/src/lib.rs @@ -0,0 +1,887 @@ +//! `shuma-module-consola` — la UI Llimphi de la **consola de claudes**. +//! +//! Es un módulo puro (`State`/`Msg`/[`update`]/[`view`]) en el bucle Elm de +//! Llimphi. NO habla con el registro: el **chasis** lo alimenta con snapshots +//! del [`shuma_consola_core::Sesion`] activo (via [`State::refrescar`]) y +//! ejecuta los **intents** que el módulo deja (`take_crear`/`take_enviar`/…), +//! igual que shuma-module-agente con el host. El mismo módulo hospeda el +//! escritorio (`examples/consola_desktop.rs`) y, mañana, el chasis Android. +//! +//! Layout: **el sidebar unificado** [`llimphi_widget_rag_sidebar`] es el ÚNICO +//! dueño del chrome — rail de dientes (una por sesión, con badge de [`Atencion`]) + +//! panel con cabezal uniforme + buscador + control de disposición. El cuerpo del +//! diente abierto = **subtítulo de estado** + **transcript** de la sesión activa +//! reducido a etapas desplegables (pensamiento/herramienta colapsados por defecto; +//! el texto siempre visible) + **barra de input**. "Logs por etapas, no un +//! terminal". La consola ya no pinta rail/panel/header propios: todo sale del +//! widget compartido, igual que agora/nakui/cosmos. + +#![forbid(unsafe_code)] + +use std::collections::HashSet; +use std::sync::Arc; + +use llimphi_ui::llimphi_layout::taffy::style::{LengthPercentage, Position}; +use llimphi_ui::llimphi_layout::taffy::{ + prelude::{auto, length, percent, Dimension, FlexDirection, Size, Style}, + AlignItems, JustifyContent, Rect, +}; +use llimphi_ui::llimphi_text::Alignment; +use llimphi_ui::{Key, KeyState, NamedKey, View}; +use llimphi_theme::Theme; +use llimphi_icons::{icon_view, Icon}; +use llimphi_widget_button::{button_view, ButtonPalette}; +use llimphi_widget_dock_rail::{BadgeKind, DockBadge}; +use llimphi_widget_rag_sidebar::{ + rag_multiselect_view, rag_sidebar_view, RagOptions, RagSidebarMsg, RagSidebarPalette, + RagSidebarState, RagSidebarView, RagSide, RagTooth, +}; +use llimphi_widget_scroll::{scroll_y, ScrollPalette}; +use llimphi_widget_text_input::{ + text_input_view_full, MemClipboard, TextInputEvent, TextInputPalette, TextInputState, +}; +use shuma_consola_core::{Atencion, EstadoHerramienta, EstadoSesion, Etapa, Sesion}; + +/// Ancho del rail de dientes (px) — mismo que cosmos/nakui/media/agora. +const RAIL_W: f32 = 44.0; +/// Id del diente sintético "nuevo claude" (aún sin sesión): mantiene el panel +/// abierto (con el input) cuando no hay tab activo. No hay diente real con este id. +const NUEVO_ID: u64 = u64::MAX; + +/// Una entrada de la tira de tabs (resumen liviano de una sesión). +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct Tab { + pub id: String, + pub titulo: String, + pub atencion: Atencion, +} + +/// Estado del módulo. El historial vive en `sesion` (snapshot del registro). +pub struct State { + /// Tabs, en el orden que da el registro. + pub tabs: Vec, + /// Id de la sesión activa (o `None` = "tab nuevo, aún sin crear"). + pub activa: Option, + /// Snapshot de la sesión activa (lo pone el chasis en cada tick). + pub sesion: Option, + /// Caja de mensaje. + pub input: TextInputState, + clipboard: MemClipboard, + pub focused: bool, + pub scroll: f32, + pub vista_alto: f32, + /// Ancho de la vista (px): el panel del sidebar unificado se estira a + /// `vista_ancho - RAIL_W` para llenar la ventana (la consola es todo panel, sin + /// canvas al lado). Lo fija el chasis en cada resize. + pub vista_ancho: f32, + /// Estado del **sidebar unificado** (los 4 ejes de disposición + buscador + + /// control). El widget lo lee; sus `RagSidebarMsg` entran por [`Msg::Sidebar`]. + pub sidebar: RagSidebarState, + /// Etapas (turno, idx) que están **desplegadas** (por defecto colapsadas). + expandidas: HashSet<(usize, usize)>, + + // Intents que el chasis drena y ejecuta contra el registro. + crear: Option, + enviar: Option<(String, String)>, + seleccion: Option, + cerrar: Option, +} + +impl Default for State { + fn default() -> Self { + Self { + tabs: Vec::new(), + activa: None, + sesion: None, + input: TextInputState::new(), + clipboard: MemClipboard::new(), + focused: true, + scroll: 0.0, + vista_alto: 400.0, + vista_ancho: 1040.0, + sidebar: RagSidebarState::default(), + expandidas: HashSet::new(), + crear: None, + enviar: None, + seleccion: None, + cerrar: None, + } + } +} + +impl State { + pub fn new() -> Self { + Self::default() + } + + /// El chasis pisa la vista con lo último del registro. Si el snapshot es de + /// otra sesión (cambió el tab), resetea el scroll al fondo. + pub fn refrescar(&mut self, tabs: Vec, sesion: Option) { + self.tabs = tabs; + let cambio = self.sesion.as_ref().map(|s| &s.id) != sesion.as_ref().map(|s| &s.id); + self.sesion = sesion; + if cambio { + self.scroll = f32::MAX; // salta al final del hilo nuevo + } + } + + pub fn fijar_vista_alto(&mut self, h: f32) { + self.vista_alto = h; + } + + /// El chasis fija el ancho de la vista (para que el panel del sidebar unificado + /// llene la ventana). Opcional: sin él, se usa un ancho por defecto razonable. + pub fn fijar_vista_ancho(&mut self, w: f32) { + self.vista_ancho = w.max(RAIL_W + 160.0); + } + + /// El chasis pregunta qué debe crear/enviar/seleccionar/cerrar. + pub fn take_crear(&mut self) -> Option { + self.crear.take() + } + pub fn take_enviar(&mut self) -> Option<(String, String)> { + self.enviar.take() + } + pub fn take_seleccion(&mut self) -> Option { + self.seleccion.take() + } + pub fn take_cerrar(&mut self) -> Option { + self.cerrar.take() + } + + /// El chasis fija el id activo tras crear una sesión (para auto-seleccionarla). + pub fn set_activa(&mut self, id: Option) { + self.activa = id; + } +} + +/// Mensajes del módulo. +#[derive(Clone, Debug)] +pub enum Msg { + /// Seleccionar un tab por id. + SeleccionarTab(String), + /// "+ nuevo": el próximo Enviar creará una sesión. + NuevoTab, + /// Cerrar (matar) un tab. + CerrarTab(String), + /// Reordenar un tab: mover el de índice `from` a la posición `to`. Lo emite el + /// rail del sidebar al soltar un diente sobre otro (drag-reorder). Como `activa` + /// apunta por id-string, el orden de `tabs` cambia sin tocar la selección. + ReorderTab(usize, usize), + /// Enviar el contenido del input (crea sesión si no hay activa). + Enviar, + /// Evento del text-input (el `handle` procesa caret/selección/clipboard). + CampoInput(TextInputEvent), + /// El input tomó foco. + FocusInput, + /// Desplegar/colapsar la etapa (turno, idx). + ToggleEtapa(usize, usize), + /// Delta de scroll del transcript. + Scroll(f32), + /// Mensaje del **sidebar unificado** (ejes de disposición, buscador, control). + /// El `Activate` de un diente NO llega por aquí: la vista lo intercepta y lo + /// traduce a [`Msg::SeleccionarTab`]. + Sidebar(RagSidebarMsg), +} + +/// Paso puro. +pub fn update(mut s: State, msg: Msg) -> State { + match msg { + Msg::SeleccionarTab(id) => { + s.activa = Some(id.clone()); + s.seleccion = Some(id); + s.scroll = f32::MAX; + // Cambiar de claude limpia el filtro previo (como agora al cambiar módulo). + s.sidebar.search.clear(); + s.sidebar.search_focused = false; + } + Msg::NuevoTab => { + s.activa = None; + s.sesion = None; + s.focused = true; + s.sidebar.search.clear(); + s.sidebar.search_focused = false; + } + Msg::CerrarTab(id) => { + s.cerrar = Some(id.clone()); + if s.activa.as_deref() == Some(&id) { + s.activa = None; + s.sesion = None; + } + } + Msg::ReorderTab(from, to) => { + let len = s.tabs.len(); + if from < len && to < len && from != to { + let t = s.tabs.remove(from); + s.tabs.insert(to, t); + } + } + Msg::Enviar => { + // Con el buscador enfocado, Enter lo suelta (no envía): las teclas venían + // yendo al filtro, no al input. + if s.sidebar.search_focused { + s.sidebar.search_focused = false; + return s; + } + let texto = s.input.text(); + if texto.trim().is_empty() { + return s; + } + match &s.activa { + Some(id) => s.enviar = Some((id.clone(), texto)), + None => s.crear = Some(texto), + } + s.input.set_text(""); + s.scroll = f32::MAX; + } + Msg::CampoInput(ev) => { + // El chasis reenvía TODAS las teclas como `CampoInput`. Si el buscador del + // sidebar tiene el foco, las teclas van al filtro (no al input de mensaje), + // igual que agora rutea al buscador en su `on_key`. Así la búsqueda funciona + // sin tocar el `on_key` del chasis (ni el del Android). + if s.sidebar.search_focused { + if let TextInputEvent::Key(ke) = &ev { + if ke.state == KeyState::Pressed { + match &ke.key { + Key::Named(NamedKey::Escape) | Key::Named(NamedKey::Enter) => { + s.sidebar.search_focused = false; + } + Key::Named(NamedKey::Backspace) => { + s.sidebar.search.pop(); + } + _ => { + if let Some(t) = &ke.text { + if !t.is_empty() && !t.chars().any(char::is_control) { + s.sidebar.search.push_str(t); + } + } + } + } + } + } + return s; + } + if matches!(ev, TextInputEvent::Press(_)) { + s.focused = true; + } + s.input.handle(ev, &mut s.clipboard); + } + Msg::FocusInput => { + s.focused = true; + s.sidebar.search_focused = false; + } + Msg::ToggleEtapa(t, e) => { + if !s.expandidas.remove(&(t, e)) { + s.expandidas.insert((t, e)); + } + } + Msg::Scroll(d) => { + s.scroll = (s.scroll + d).max(0.0); + } + Msg::Sidebar(rm) => { + // Al enfocar el buscador, sacale el foco al input de mensaje (y viceversa). + if let RagSidebarMsg::SearchFocus(f) = &rm { + if *f { + s.focused = false; + } + } + s.sidebar.update(rm); + } + } + s +} + +// ─────────────────────────────── View ────────────────────────────── + +/// Vista del módulo: el **sidebar unificado** (rail de dientes + panel) es el único +/// dueño del chrome. Cada tab = un diente (id = su índice); el diente activo abre su +/// panel con el transcript + el input como cuerpo. `lift` sube los `Msg` al chasis. +pub fn view( + state: &State, + theme: &Theme, + lift: impl Fn(Msg) -> H + Send + Sync + 'static + Clone, +) -> View { + let palette = RagSidebarPalette::from_theme(theme); + + // Los dientes son los tabs, en orden; el id del diente = su índice (el widget usa + // u64, los tabs son String). El orden guardado desdobla `Activate(idx)` de vuelta + // al id-string del tab. + let teeth: Vec> = state + .tabs + .iter() + .enumerate() + .map(|(i, tab)| { + let mut t = RagTooth::new( + i as u64, + if tab.titulo.trim().is_empty() { "(sin título)".to_string() } else { tab.titulo.clone() }, + Arc::new(|size, color| { + View::new(Style { + size: Size { width: length(size), height: length(size) }, + align_items: Some(AlignItems::Center), + ..Default::default() + }) + .children(vec![icon_view(Icon::Code, color, 1.6)]) + }), + ); + t.badge = badge_de_atencion(tab.atencion); + t + }) + .collect(); + + // El sidebar es un **selector**: siempre hay panel. El diente abierto/seleccionado + // = el índice del tab activo (o el sintético NUEVO_ID cuando no hay tab, para no + // perder el panel de "nuevo claude" con su input). Se clona el estado y se fuerza + // `open`/`selected` a la fuente de verdad del módulo (`state.activa`); el `Activate` + // se intercepta en el `map` hacia `SeleccionarTab`, así el widget nunca lo aplica. + let activa_idx = state + .activa + .as_ref() + .and_then(|id| state.tabs.iter().position(|t| &t.id == id)); + let open_id = activa_idx.map(|i| i as u64).unwrap_or(NUEVO_ID); + let mut sb = state.sidebar.clone(); + sb.open = Some(open_id); + sb.selected = Some(open_id); + // La consola es todo panel (sin canvas al costado): el panel se estira a la ventana. + sb.panel_w = (state.vista_ancho - RAIL_W).max(160.0); + + // Cuerpo del diente abierto: subtítulo de estado (tenue) + transcript + input. + let body = body_view(state, theme, lift.clone()); + + // "×" cerrar el claude abierto: va como accesorio del cabezal del widget (los + // dientes del rail no traen cerrar nativo). Sólo cuando hay una sesión real. + let accessory = state + .activa + .clone() + .map(|id| close_button(id, theme, lift.clone())); + + // El "+ nuevo claude" es la **encía** al final del rail (`grow`). + let grow = Some(("+".to_string(), "Nuevo claude".to_string(), lift(Msg::NuevoTab))); + + // Enruta los `Msg` del widget: `Activate(idx)` → seleccionar ese tab; el resto + // (ejes/buscador/control) → `Msg::Sidebar`. + let map: Arc H + Send + Sync> = { + let lift = lift.clone(); + let ids: Vec = state.tabs.iter().map(|t| t.id.clone()).collect(); + Arc::new(move |rm| match rm { + RagSidebarMsg::Activate(id) => match ids.get(id as usize) { + Some(tid) => lift(Msg::SeleccionarTab(tid.clone())), + None => lift(Msg::NuevoTab), + }, + other => lift(Msg::Sidebar(other)), + }) + }; + + let spec = RagSidebarView { + teeth, + body, + accessory, + search_hits: !state.sidebar.search.is_empty() && hay_coincidencias(state), + t: 0.0, + rail_w: RAIL_W, + map, + on_drop: None, + // Reorden por arrastre: los dientes llevan id = índice del tab; soltar el + // arrastrado sobre otro lo mueve a esa posición. Se descarta el diente + // sintético "nuevo claude" (NUEVO_ID) — no es un tab reordenable. + on_reorder: Some({ + let lift = lift.clone(); + Arc::new(move |payload: u64, target: u64| { + if payload == NUEVO_ID || target == NUEVO_ID { + return None; + } + (payload != target) + .then(|| lift(Msg::ReorderTab(payload as usize, target as usize))) + }) + }), + grow, + }; + + let base = rag_sidebar_view(&sb, spec, RagSide::Left, &palette); + + // El multiselect de disposición NO va inline en el panel (lo clipearía): el widget + // lo expone como card externa que el host posiciona. Se pinta como overlay con + // backdrop cuando el control está abierto, arrimado al borde derecho del panel. + if !state.sidebar.control_open { + return base; + } + let map_ms: Arc H + Send + Sync> = { + let lift = lift.clone(); + Arc::new(move |rm| lift(Msg::Sidebar(rm))) + }; + let card = rag_multiselect_view(&sb, RagOptions::default(), map_ms, &palette); + let left = (RAIL_W + sb.panel_w - 238.0).max(RAIL_W + 4.0); + let card_pos = View::new(Style { + position: Position::Absolute, + inset: Rect { left: length(left), right: auto(), top: length(40.0_f32), bottom: auto() }, + ..Default::default() + }) + .children(vec![card]); + let backdrop = View::new(Style { + position: Position::Absolute, + inset: Rect { + left: length(0.0_f32), + right: length(0.0_f32), + top: length(0.0_f32), + bottom: length(0.0_f32), + }, + size: Size { width: percent(1.0_f32), height: percent(1.0_f32) }, + ..Default::default() + }) + .on_click(lift(Msg::Sidebar(RagSidebarMsg::ControlToggle))); + View::new(Style { + position: Position::Relative, + size: Size { width: percent(1.0_f32), height: percent(1.0_f32) }, + ..Default::default() + }) + .children(vec![base, backdrop, card_pos]) +} + +/// El **cuerpo** del diente abierto (lo pinta el widget bajo su cabezal): subtítulo +/// de estado (tenue) + transcript filtrado por el buscador + barra de input. +fn body_view( + state: &State, + theme: &Theme, + lift: impl Fn(Msg) -> H + Send + Sync + 'static + Clone, +) -> View { + let sub = match &state.sesion { + Some(s) => estado_texto(&s.estado), + // Sin sesión: el cabezal del widget queda sin título, así que el subtítulo + // lleva el rótulo "Nuevo claude" para no perderlo. + None => "Nuevo claude · escribe el primer mensaje y Enter".to_string(), + }; + let subtitulo = View::new(Style { + size: Size { width: percent(1.0_f32), height: length(18.0_f32) }, + align_items: Some(AlignItems::Center), + flex_shrink: 0.0, + padding: pad(16.0, 0.0), + ..Default::default() + }) + .text(sub, 11.0, theme.fg_muted); + let transcript = transcript_view(state, theme, lift.clone()); + let barra = input_bar(state, theme, lift); + View::new(Style { + flex_direction: FlexDirection::Column, + size: Size { width: percent(1.0_f32), height: percent(1.0_f32) }, + min_size: Size { width: length(0.0_f32), height: length(0.0_f32) }, + ..Default::default() + }) + .children(vec![subtitulo, transcript, barra]) +} + +/// El botón "×" del cabezal que **cierra** (mata) el claude abierto. +fn close_button( + id: String, + theme: &Theme, + lift: impl Fn(Msg) -> H + Send + Sync + 'static + Clone, +) -> View { + View::new(Style { + size: Size { width: length(22.0_f32), height: length(22.0_f32) }, + align_items: Some(AlignItems::Center), + justify_content: Some(JustifyContent::Center), + flex_shrink: 0.0, + ..Default::default() + }) + .radius(5.0) + .hover_fill(theme.bg_button_hover) + .tooltip("Cerrar este claude".to_string()) + .text_aligned("×".to_string(), 15.0, theme.fg_muted, Alignment::Center) + .on_click(lift(Msg::CerrarTab(id))) +} + +/// Mapea la [`Atencion`] de un tab al distintivo del diente en el rail. +fn badge_de_atencion(a: Atencion) -> Option { + match a { + Atencion::Nada => None, + Atencion::Corriendo => Some(DockBadge::Dot(BadgeKind::Info)), + Atencion::SinLeer(n) => Some(DockBadge::Count(n, BadgeKind::Success)), + Atencion::PideAlgo => Some(DockBadge::Dot(BadgeKind::Warning)), + } +} + +fn transcript_view( + state: &State, + theme: &Theme, + lift: impl Fn(Msg) -> H + Send + Sync + 'static + Clone, +) -> View { + let mut turnos: Vec> = Vec::new(); + let mut alto = 0.0_f32; + // El buscador del sidebar **filtra** el transcript: sólo entran los turnos/etapas + // cuyo texto contiene la consulta (case-insensitive). Vacío = todo pasa. + let filtro = state.sidebar.search.to_lowercase(); + let coincide = |txt: &str| filtro.is_empty() || txt.to_lowercase().contains(&filtro); + + if let Some(sesion) = &state.sesion { + for (ti, turno) in sesion.turnos.iter().enumerate() { + match turno.rol { + shuma_consola_core::Rol::Usuario => { + let texto = turno + .etapas + .iter() + .filter_map(|e| if let Etapa::Texto(t) = e { Some(t.as_str()) } else { None }) + .collect::>() + .join("\n"); + if !coincide(&texto) { + continue; + } + alto += estimar_alto(&texto, 13.0) + 16.0; + turnos.push(burbuja_usuario(&texto, theme)); + } + shuma_consola_core::Rol::Asistente => { + for (ei, etapa) in turno.etapas.iter().enumerate() { + if !coincide(&etapa_texto(etapa)) { + continue; + } + let expandida = state.expandidas.contains(&(ti, ei)); + let (v, h) = etapa_view(etapa, ti, ei, expandida, theme, lift.clone()); + alto += h; + turnos.push(v); + } + } + } + } + } + if turnos.is_empty() { + let vacio = if filtro.is_empty() { + "— sin actividad todavía —".to_string() + } else { + format!("— sin coincidencias para «{}» —", state.sidebar.search) + }; + turnos.push( + View::new(Style { size: Size { width: percent(1.0_f32), height: Dimension::auto() }, ..Default::default() }) + .text(vacio, 12.0, theme.fg_muted), + ); + alto = 30.0; + } + + let contenido = View::new(Style { + flex_direction: FlexDirection::Column, + size: Size { width: percent(1.0_f32), height: Dimension::auto() }, + gap: Size { width: length(0.0_f32), height: length(8.0_f32) }, + padding: pad(16.0, 12.0), + ..Default::default() + }) + .children(turnos); + + let sp = ScrollPalette::from_theme(theme); + let lift_scroll = lift; + let hilo = scroll_y( + state.scroll.min(alto), + alto, + state.vista_alto, + contenido, + move |d| lift_scroll(Msg::Scroll(-d)), + &sp, + ); + View::new(Style { + flex_grow: 1.0, + size: Size { width: percent(1.0_f32), height: Dimension::auto() }, + min_size: Size { width: length(0.0_f32), height: length(0.0_f32) }, + ..Default::default() + }) + .children(vec![hilo]) +} + +/// El texto plano de una etapa (para el filtro del buscador). +fn etapa_texto(etapa: &Etapa) -> String { + match etapa { + Etapa::Texto(t) => t.clone(), + Etapa::Error(e) => e.clone(), + Etapa::Pensamiento(t) => t.clone(), + Etapa::Herramienta(hr) => { + let mut s = format!("{} {}", hr.nombre, hr.resumen); + if let Some(res) = &hr.resultado { + s.push(' '); + s.push_str(res); + } + s + } + } +} + +/// `true` si el buscador tiene alguna coincidencia en el transcript de la sesión. +fn hay_coincidencias(state: &State) -> bool { + let filtro = state.sidebar.search.to_lowercase(); + if filtro.is_empty() { + return false; + } + let Some(sesion) = &state.sesion else { return false }; + sesion.turnos.iter().any(|turno| match turno.rol { + shuma_consola_core::Rol::Usuario => turno.etapas.iter().any(|e| { + if let Etapa::Texto(t) = e { + t.to_lowercase().contains(&filtro) + } else { + false + } + }), + shuma_consola_core::Rol::Asistente => { + turno.etapas.iter().any(|e| etapa_texto(e).to_lowercase().contains(&filtro)) + } + }) +} + +/// Una etapa del asistente → (View, alto estimado). +fn etapa_view( + etapa: &Etapa, + ti: usize, + ei: usize, + expandida: bool, + theme: &Theme, + lift: impl Fn(Msg) -> H + Send + Sync + 'static + Clone, +) -> (View, f32) { + match etapa { + Etapa::Texto(t) => ( + View::new(Style { size: Size { width: percent(1.0_f32), height: Dimension::auto() }, ..Default::default() }) + .text(t.clone(), 13.0, theme.fg_text), + estimar_alto(t, 13.0) + 8.0, + ), + Etapa::Error(e) => ( + View::new(Style { size: Size { width: percent(1.0_f32), height: Dimension::auto() }, padding: pad(8.0, 6.0), ..Default::default() }) + .fill(theme.bg_panel_alt) + .radius(6.0) + .text(format!("✘ {e}"), 12.0, theme.fg_destructive), + estimar_alto(e, 12.0) + 20.0, + ), + Etapa::Pensamiento(t) => { + let flecha = if expandida { "▾" } else { "▸" }; + let mut hijos = vec![encabezado_plegable( + format!("{flecha} 🧠 pensó"), + theme.fg_muted, + theme, + lift(Msg::ToggleEtapa(ti, ei)), + )]; + let mut h = 24.0; + if expandida { + hijos.push( + View::new(Style { size: Size { width: percent(1.0_f32), height: Dimension::auto() }, padding: pad(18.0, 2.0), ..Default::default() }) + .text(t.clone(), 12.0, theme.fg_muted), + ); + h += estimar_alto(t, 12.0); + } + (columna(hijos), h) + } + Etapa::Herramienta(hr) => { + let (glifo, color) = match hr.estado { + EstadoHerramienta::EnCurso => ("⏳", theme.accent), + EstadoHerramienta::Ok => ("✓", theme.accent), + EstadoHerramienta::Error => ("✘", theme.fg_destructive), + }; + let flecha = if expandida { "▾" } else { "▸" }; + let resumen = recortar(&hr.resumen, 60); + let mut hijos = vec![encabezado_plegable( + format!("{flecha} {glifo} {} {resumen}", hr.nombre), + color, + theme, + lift(Msg::ToggleEtapa(ti, ei)), + )]; + let mut h = 26.0; + if expandida { + if let Some(res) = &hr.resultado { + hijos.push( + View::new(Style { size: Size { width: percent(1.0_f32), height: Dimension::auto() }, padding: pad(18.0, 4.0), ..Default::default() }) + .fill(theme.bg_panel_alt) + .radius(6.0) + .text(recortar(res, 4000), 12.0, theme.fg_muted), + ); + h += estimar_alto(res, 12.0) + 12.0; + } + } + (columna(hijos), h) + } + } +} + +fn input_bar( + state: &State, + theme: &Theme, + lift: impl Fn(Msg) -> H + Send + Sync + 'static + Clone, +) -> View { + let tp = TextInputPalette::from_theme(theme); + let bp = ButtonPalette::from_theme(theme); + let corriendo = matches!(state.sesion.as_ref().map(|s| &s.estado), Some(EstadoSesion::Corriendo)); + let placeholder = if corriendo { + "el claude está trabajando… (Enter encola igual)" + } else { + "escribe tu mensaje… (Enter envía)" + }; + + let campo = { + let lift = lift.clone(); + View::new(Style { flex_grow: 1.0, ..Default::default() }).children(vec![text_input_view_full( + &state.input, + placeholder, + // Con el buscador enfocado, el caret vive en el filtro, no aquí. + state.focused && !state.sidebar.search_focused, + &tp, + move |ev| lift(Msg::CampoInput(ev)), + )]) + }; + let enviar = View::new(Style { size: Size { width: length(96.0_f32), height: Dimension::auto() }, ..Default::default() }) + .children(vec![button_view("Enviar", &bp, lift(Msg::Enviar))]); + + View::new(Style { + flex_direction: FlexDirection::Row, + size: Size { width: percent(1.0_f32), height: length(52.0_f32) }, + align_items: Some(AlignItems::Center), + gap: Size { width: length(8.0_f32), height: length(0.0_f32) }, + padding: pad(12.0, 8.0), + ..Default::default() + }) + .fill(theme.bg_panel) + .children(vec![campo, enviar]) +} + +// ─────────────────────────────── Helpers ─────────────────────────── + +fn encabezado_plegable( + etiqueta: String, + color: llimphi_theme::Color, + _theme: &Theme, + on_click: H, +) -> View { + View::new(Style { + size: Size { width: percent(1.0_f32), height: length(20.0_f32) }, + align_items: Some(AlignItems::Center), + ..Default::default() + }) + .text(etiqueta, 12.0, color) + .on_click(on_click) +} + +fn burbuja_usuario(texto: &str, theme: &Theme) -> View { + View::new(Style { + size: Size { width: percent(1.0_f32), height: Dimension::auto() }, + padding: pad(10.0, 6.0), + ..Default::default() + }) + .fill(theme.bg_selected) + .radius(8.0) + .text(texto.to_string(), 13.0, theme.fg_text) +} + +fn columna(hijos: Vec>) -> View { + View::new(Style { + flex_direction: FlexDirection::Column, + size: Size { width: percent(1.0_f32), height: Dimension::auto() }, + gap: Size { width: length(0.0_f32), height: length(2.0_f32) }, + ..Default::default() + }) + .children(hijos) +} + +fn pad(x: f32, y: f32) -> Rect { + Rect { left: length(x), right: length(x), top: length(y), bottom: length(y) } +} + +fn estado_texto(e: &EstadoSesion) -> String { + match e { + EstadoSesion::Arrancando => "arrancando…".into(), + EstadoSesion::Corriendo => "⏳ trabajando…".into(), + EstadoSesion::Idle => "listo · esperando tu mensaje".into(), + EstadoSesion::Fallida(m) => format!("✘ {}", recortar(m, 80)), + } +} + +/// Estimación grosera de alto de un texto (para el content_len del scroll). +fn estimar_alto(texto: &str, tam: f32) -> f32 { + let linea = tam * 1.4; + let cols = 60.0_f32; + texto + .lines() + .map(|l| (l.chars().count() as f32 / cols).ceil().max(1.0)) + .sum::() + .max(1.0) + * linea +} + +fn recortar(s: &str, max: usize) -> String { + if s.chars().count() <= max { + s.to_string() + } else { + let corte: String = s.chars().take(max).collect(); + format!("{corte}…") + } +} + +#[cfg(test)] +mod tests { + use super::*; + use shuma_consola_core::{Herramienta, Rol, Turno}; + + fn sesion_demo() -> Sesion { + let mut s = Sesion::nueva("consola-1", "/tmp", 0); + s.titulo = "arregla el bug".into(); + s.estado = EstadoSesion::Idle; + s.turnos.push(Turno { rol: Rol::Usuario, etapas: vec![Etapa::Texto("hola".into())], uso: None, ts: 0 }); + s.turnos.push(Turno { + rol: Rol::Asistente, + etapas: vec![ + Etapa::Herramienta(Herramienta { + tool_id: "t1".into(), + nombre: "Bash".into(), + resumen: "echo hola".into(), + input_json: r#"{"command":"echo hola"}"#.into(), + resultado: Some("hola".into()), + estado: EstadoHerramienta::Ok, + }), + Etapa::Texto("listo".into()), + ], + uso: None, + ts: 0, + }); + s + } + + fn contar(v: &View) -> usize { + 1 + v.children.iter().map(contar).sum::() + } + + #[test] + fn view_monta_con_sesion() { + let mut st = State::new(); + st.refrescar( + vec![Tab { id: "consola-1".into(), titulo: "arregla el bug".into(), atencion: Atencion::SinLeer(2) }], + Some(sesion_demo()), + ); + st.activa = Some("consola-1".into()); + let v = view(&st, &Theme::dark(), |m| m); + assert!(contar(&v) > 12, "árbol sospechosamente chico"); + } + + #[test] + fn enviar_sin_activa_produce_intent_crear() { + let mut st = State::new(); + st.input.set_text("haz algo"); + st = update(st, Msg::Enviar); + assert_eq!(st.take_crear().as_deref(), Some("haz algo")); + assert!(st.take_enviar().is_none()); + assert!(st.input.is_empty(), "el input se limpia al enviar"); + } + + #[test] + fn enviar_con_activa_produce_intent_enviar() { + let mut st = State::new(); + st.activa = Some("consola-9".into()); + st.input.set_text("sigue"); + st = update(st, Msg::Enviar); + assert_eq!(st.take_enviar(), Some(("consola-9".into(), "sigue".into()))); + assert!(st.take_crear().is_none()); + } + + #[test] + fn toggle_etapa_despliega_y_colapsa() { + let mut st = State::new(); + assert!(!st.expandidas.contains(&(1, 0))); + st = update(st, Msg::ToggleEtapa(1, 0)); + assert!(st.expandidas.contains(&(1, 0))); + st = update(st, Msg::ToggleEtapa(1, 0)); + assert!(!st.expandidas.contains(&(1, 0))); + } + + #[test] + fn seleccionar_tab_marca_intent() { + let mut st = State::new(); + st = update(st, Msg::SeleccionarTab("consola-3".into())); + assert_eq!(st.activa.as_deref(), Some("consola-3")); + assert_eq!(st.take_seleccion().as_deref(), Some("consola-3")); + } +} diff --git a/02_ruway/shuma/sandbox/shuma-module-launcher/Cargo.toml b/02_ruway/shuma/sandbox/shuma-module-launcher/Cargo.toml index 61d2246..945a779 100644 --- a/02_ruway/shuma/sandbox/shuma-module-launcher/Cargo.toml +++ b/02_ruway/shuma/sandbox/shuma-module-launcher/Cargo.toml @@ -13,3 +13,6 @@ llimphi-ui = { workspace = true } llimphi-theme = { workspace = true } serde = { workspace = true } toml = { workspace = true } +# Puente de lanzamiento: si arje está levantado, la entry entra al grafo como +# Ente OneShot supervisado (RunCard); si no, spawn crudo detached como antes. +arje-applaunch = { workspace = true } diff --git a/02_ruway/shuma/sandbox/shuma-module-launcher/src/lib.rs b/02_ruway/shuma/sandbox/shuma-module-launcher/src/lib.rs index 44eeff0..395f020 100644 --- a/02_ruway/shuma/sandbox/shuma-module-launcher/src/lib.rs +++ b/02_ruway/shuma/sandbox/shuma-module-launcher/src/lib.rs @@ -11,13 +11,13 @@ //! //! ```toml //! label = "Pluma" -//! exec = "pluma-app" # opcional; si está, click → spawn detached +//! exec = "pluma-app-llimphi" # opcional; si está, click → spawn detached //! action_id = "focus:pluma" # opcional; si no hay exec, el chasis lo dispatchea //! ``` //! //! Si `~/.config/shuma/apps/` no existe (o está vacío), el launcher -//! cae al `State::demo()` con tres entries fijas (Files/Shell/Matilda) -//! para que el chasis sea exploratorio desde el día uno. +//! arranca **vacío** (sólo la marca). Antes caía a entries de placeholder +//! (Files/Shell/Matilda); se quitaron porque no eran apps reales. #![forbid(unsafe_code)] @@ -86,31 +86,16 @@ impl LauncherEntry { } impl State { - /// State de demo con entries fijas: Files / Shell / Matilda. El - /// loader real las reemplaza si encuentra manifests en disco. - pub fn demo() -> Self { - Self { - entries: vec![ - LauncherEntry::new("Files", "open:files"), - LauncherEntry::new("Shell", "focus:shell"), - LauncherEntry::new("Matilda", "focus:matilda"), - ], - } - } - /// Lee `$XDG_CONFIG_HOME/shuma/apps/*.toml` (orden alfabético) y - /// arma las entries. Si el dir no existe o no hay manifests - /// válidos, devuelve `State::demo()` — el chasis arranca usable. + /// arma las entries. Si el dir no existe o no hay manifests válidos, + /// arranca **vacío** (sólo la marca) — antes caía a `State::demo()` + /// (Files/Shell/Matilda), entries de placeholder que no eran apps + /// reales y confundían; se quitaron de producción. pub fn from_apps_dir() -> Self { - let Some(dir) = apps_dir() else { - return Self::demo(); - }; - let entries = load_entries_from_dir(&dir); - if entries.is_empty() { - Self::demo() - } else { - Self { entries } - } + let entries = apps_dir() + .map(|dir| load_entries_from_dir(&dir)) + .unwrap_or_default(); + Self { entries } } } @@ -167,23 +152,13 @@ pub fn update(state: State, _msg: Msg) -> State { state } -/// Spawnea el `exec` de una entry detached del shell. Parseo simple -/// por whitespace; quoting avanzado no soportado (un launcher quiere -/// invocar binarios, no scripts). +/// Lanza el `exec` de una entry. Si el orquestador (arje) está levantado, la +/// entrega como Ente OneShot supervisado (vía `arje-applaunch`); si no, cae al +/// spawn crudo **detached** (nuevo grupo de proceso + stdio mudo) que tenía +/// antes. Parseo simple por whitespace; quoting avanzado no soportado (un +/// launcher quiere invocar binarios, no scripts). pub fn spawn_exec(exec_line: &str) { - use std::os::unix::process::CommandExt; - let mut parts = exec_line.split_whitespace(); - let Some(program) = parts.next() else { - return; - }; - let args: Vec<&str> = parts.collect(); - let _ = std::process::Command::new(program) - .args(args) - .stdin(std::process::Stdio::null()) - .stdout(std::process::Stdio::null()) - .stderr(std::process::Stdio::null()) - .process_group(0) - .spawn(); + let _ = arje_applaunch::launch_exec_line(exec_line); } /// Mapea `action_id` a `Msg`. El launcher expone `launcher.toggle` como @@ -320,11 +295,11 @@ mod tests { } #[test] - fn demo_state_has_three_entries() { - let s = State::demo(); - assert_eq!(s.entries.len(), 3); - assert_eq!(s.entries[0].label, "Files"); - assert_eq!(s.entries[1].action_id, "focus:shell"); + fn no_manifests_means_empty_launcher() { + // Sin manifests reales, el launcher arranca vacío — ya no inventa + // entries de placeholder (Files/Shell/Matilda). + let empty = load_entries_from_dir(std::path::Path::new("/nonexistent/shuma/apps")); + assert!(empty.is_empty()); } #[test] @@ -353,7 +328,7 @@ mod tests { std::fs::create_dir_all(&dir).unwrap(); std::fs::write( dir.join("01-pluma.toml"), - "label = \"Pluma\"\nexec = \"pluma-app\"\n", + "label = \"Pluma\"\nexec = \"pluma-app-llimphi\"\n", ) .unwrap(); std::fs::write( @@ -365,7 +340,7 @@ mod tests { let entries = load_entries_from_dir(&dir); assert_eq!(entries.len(), 2); assert_eq!(entries[0].label, "Pluma"); - assert_eq!(entries[0].exec.as_deref(), Some("pluma-app")); + assert_eq!(entries[0].exec.as_deref(), Some("pluma-app-llimphi")); assert_eq!(entries[1].action_id, "focus:shell"); let _ = std::fs::remove_dir_all(&dir); } diff --git a/02_ruway/shuma/sandbox/shuma-module-matilda/Cargo.toml b/02_ruway/shuma/sandbox/shuma-module-matilda/Cargo.toml index c8e3e4f..d24f7ae 100644 --- a/02_ruway/shuma/sandbox/shuma-module-matilda/Cargo.toml +++ b/02_ruway/shuma/sandbox/shuma-module-matilda/Cargo.toml @@ -15,8 +15,11 @@ matilda-apply = { path = "../../baremetal/matilda-apply" } matilda-discover = { path = "../../baremetal/matilda-discover" } matilda-ghost = { path = "../../baremetal/matilda-ghost" } matilda-linker = { path = "../../baremetal/matilda-linker" } -ssh = { workspace = true } tokio = { workspace = true } llimphi-ui = { workspace = true } llimphi-theme = { workspace = true } llimphi-widget-splitter = { workspace = true } + +[dev-dependencies] +pollster = { workspace = true } +png = { workspace = true } diff --git a/02_ruway/shuma/sandbox/shuma-module-matilda/examples/runtime_monitor.rs b/02_ruway/shuma/sandbox/shuma-module-matilda/examples/runtime_monitor.rs new file mode 100644 index 0000000..1da7732 --- /dev/null +++ b/02_ruway/shuma/sandbox/shuma-module-matilda/examples/runtime_monitor.rs @@ -0,0 +1,202 @@ +//! Verificación headless del **monitoreo runtime del bloque de matilda**: +//! el panel de inventario muestra cada contenedor con su semáforo (● vivo / +//! ○ parado) + el `status` de Docker, lista los huérfanos que corren fuera +//! del inventario, y el header cuenta up/down. Es la administración de +//! servidores/contenedores desde la interfaz de shuma, sin ir a la terminal. +//! +//! `cargo run -p shuma-module-matilda --example runtime_monitor -- [out.png]` + +use std::fs::File; +use std::io::BufWriter; + +use llimphi_ui::llimphi_compositor::{measure_text_node, mount, paint}; +use llimphi_ui::llimphi_hal::{wgpu, Hal}; +use llimphi_ui::llimphi_layout::taffy; +use llimphi_ui::llimphi_layout::LayoutTree; +use llimphi_ui::llimphi_raster::peniko::Color; +use llimphi_ui::llimphi_raster::{vello, Renderer}; +use llimphi_ui::llimphi_text::Typesetter; + +use matilda_discover::{ + ContainerStatus, RunState, RuntimeState, ServiceState, ServiceStatus, +}; +use shuma_module::Source; +use shuma_module_matilda::{update, Msg, State}; + +const W: u32 = 1040; +const H: u32 = 780; +const FMT: wgpu::TextureFormat = wgpu::TextureFormat::Rgba8Unorm; + +fn cs(name: &str, image: &str, state: RunState, status: &str, ports: &str) -> ContainerStatus { + ContainerStatus { + name: name.into(), + image: image.into(), + state, + status: status.into(), + ports: ports.into(), + } +} + +fn main() { + let out = std::env::args().nth(1).unwrap_or_else(|| "runtime_monitor.png".to_string()); + let theme = llimphi_theme::Theme::default(); + + // Inventario de ejemplo (web + api deseados) + estado runtime observado: + // web corriendo, api caído, y un `legacy` huérfano que corre fuera del + // inventario. La UI lo refleja todo. + // Inventario con varios hosts para mostrar la flota (M5). + let mut inv = shuma_module_matilda::example_inventory(); + inv.add_host(matilda_core::Host::new("db-1", "10.0.0.2").with_tag("db")); + inv.add_host(matilda_core::Host::new("edge-2", "10.0.0.3")); + let mut state = State::with_inventory(Source::Local, inv); + let rt = RuntimeState { + containers: vec![ + cs("web", "nginx:1.27", RunState::Running, "Up 2 hours", "0.0.0.0:8080->80/tcp"), + cs("api", "ghcr.io/ejemplo/api:1.0", RunState::Exited, "Exited (1) 5 min ago", ""), + cs("legacy", "redis:6", RunState::Running, "Up 9 days", "6379/tcp"), + ], + services: vec![ + ServiceStatus { + name: "sshd.service".into(), + state: ServiceState::Active, + sub: "running".into(), + description: "OpenSSH server daemon".into(), + }, + ServiceStatus { + name: "nginx.service".into(), + state: ServiceState::Active, + sub: "running".into(), + description: "A high performance web server".into(), + }, + ServiceStatus { + name: "backup.service".into(), + state: ServiceState::Failed, + sub: "failed".into(), + description: "Nightly backup".into(), + }, + ], + vhosts: vec![], + }; + state = update(state, Msg::SetRuntime(rt)); + + // Flota (M5): edge-1 alcanzado (con su runtime), db-1 caído, edge-2 aún + // consultando. edge-1 seleccionado → expande sus contenedores/servicios. + state = update(state, Msg::RefreshFleet); + let edge1 = RuntimeState { + containers: vec![ + cs("web", "nginx:1.27", RunState::Running, "Up 6 days", "0.0.0.0:80->80/tcp"), + cs("worker", "ghcr.io/ejemplo/worker:2", RunState::Exited, "Exited (137)", ""), + ], + services: vec![ServiceStatus { + name: "sshd.service".into(), + state: ServiceState::Active, + sub: "running".into(), + description: "OpenSSH server daemon".into(), + }], + vhosts: vec![], + }; + state = update(state, Msg::SetHostRuntime { host: "edge-1".into(), runtime: edge1 }); + state = update(state, Msg::SetHostError { + host: "db-1".into(), + error: "ssh connect: connection timed out".into(), + }); + state = update(state, Msg::SelectHost("edge-1".to_string())); + + // Seleccionamos `web` (barra de acciones de contenedor) y un servicio + // fallado (barra de acciones de servicio) para que ambas se vean. + state = update(state, Msg::SelectContainer("web".to_string())); + state = update(state, Msg::SelectService("backup.service".to_string())); + + let v = shuma_module_matilda::view::<()>(&state, &theme, |_m| ()); + let mut layout = LayoutTree::new(); + let mounted = mount(&mut layout, v); + let mut ts = Typesetter::new(); + let computed = { + let tmap = &mounted.text_measures; + layout + .compute_with_measure(mounted.root, (W as f32, H as f32), |nid, known, avail| { + match tmap.get(&nid) { + Some(tm) => measure_text_node(&mut ts, tm, known, avail), + None => taffy::Size::ZERO, + } + }) + .expect("layout") + }; + let mut scene = vello::Scene::new(); + paint(&mut scene, &mounted, &computed, &mut ts, None, None); + + let hal = pollster::block_on(Hal::new(None)).expect("hal"); + let mut renderer = Renderer::new(&hal).expect("renderer"); + let target = hal.device.create_texture(&wgpu::TextureDescriptor { + label: Some("runtime-monitor"), + size: wgpu::Extent3d { width: W, height: H, depth_or_array_layers: 1 }, + mip_level_count: 1, + sample_count: 1, + dimension: wgpu::TextureDimension::D2, + format: FMT, + usage: wgpu::TextureUsages::STORAGE_BINDING + | wgpu::TextureUsages::RENDER_ATTACHMENT + | wgpu::TextureUsages::COPY_SRC, + view_formats: &[], + }); + let view = target.create_view(&wgpu::TextureViewDescriptor::default()); + renderer + .render_to_view(&hal, &scene, &view, W, H, Color::from_rgba8(20, 20, 26, 255)) + .expect("render_to_view"); + write_png(&hal, &target, &out); + eprintln!("runtime_monitor: {out} ({W}x{H})"); +} + +fn write_png(hal: &Hal, target: &wgpu::Texture, path: &str) { + let unpadded = (W * 4) as usize; + let align = wgpu::COPY_BYTES_PER_ROW_ALIGNMENT as usize; + let padded = unpadded.div_ceil(align) * align; + let buf = hal.device.create_buffer(&wgpu::BufferDescriptor { + label: Some("readback"), + size: (padded * H as usize) as u64, + usage: wgpu::BufferUsages::MAP_READ | wgpu::BufferUsages::COPY_DST, + mapped_at_creation: false, + }); + let mut enc = hal + .device + .create_command_encoder(&wgpu::CommandEncoderDescriptor { label: None }); + enc.copy_texture_to_buffer( + wgpu::TexelCopyTextureInfo { + texture: target, + mip_level: 0, + origin: wgpu::Origin3d::ZERO, + aspect: wgpu::TextureAspect::All, + }, + wgpu::TexelCopyBufferInfo { + buffer: &buf, + layout: wgpu::TexelCopyBufferLayout { + offset: 0, + bytes_per_row: Some(padded as u32), + rows_per_image: Some(H), + }, + }, + wgpu::Extent3d { width: W, height: H, depth_or_array_layers: 1 }, + ); + hal.queue.submit(std::iter::once(enc.finish())); + let slice = buf.slice(..); + let (tx, rx) = std::sync::mpsc::channel(); + slice.map_async(wgpu::MapMode::Read, move |r| { + let _ = tx.send(r); + }); + hal.device.poll(wgpu::PollType::wait_indefinitely()); + rx.recv().unwrap().unwrap(); + let data = slice.get_mapped_range(); + let mut pixels = Vec::with_capacity((W * H * 4) as usize); + for row in 0..H as usize { + let s = row * padded; + pixels.extend_from_slice(&data[s..s + unpadded]); + } + drop(data); + buf.unmap(); + let file = File::create(path).expect("png"); + let mut enc = png::Encoder::new(BufWriter::new(file), W, H); + enc.set_color(png::ColorType::Rgba); + enc.set_depth(png::BitDepth::Eight); + let mut w = enc.write_header().unwrap(); + w.write_image_data(&pixels).unwrap(); +} diff --git a/02_ruway/shuma/sandbox/shuma-module-matilda/src/lib.rs b/02_ruway/shuma/sandbox/shuma-module-matilda/src/lib.rs index 3db35fb..366f63c 100644 --- a/02_ruway/shuma/sandbox/shuma-module-matilda/src/lib.rs +++ b/02_ruway/shuma/sandbox/shuma-module-matilda/src/lib.rs @@ -35,25 +35,43 @@ #![forbid(unsafe_code)] use llimphi_ui::llimphi_layout::taffy::{ - prelude::{length, percent, FlexDirection, Size, Style}, - AlignItems, Rect, + prelude::{auto, length, percent, FlexDirection, Size, Style}, + AlignItems, JustifyContent, Rect, }; use llimphi_ui::llimphi_text::Alignment; use llimphi_ui::{DragPhase, View}; use llimphi_theme::Theme; use llimphi_widget_splitter::{splitter_two, Direction, PaneSize, SplitterPalette}; -use matilda_apply::plan_to_steps; +use matilda_apply::{plan_to_steps, ContainerAction, ServiceAction}; use matilda_core::{Container, Host, Inventory, RestartPolicy, VHost}; -use matilda_discover::{discover_inventory, observed_inventory, ServerState}; +use matilda_discover::{ + discover_inventory, discover_runtime, observed_inventory, RuntimeState, ServerState, +}; use matilda_ghost::{apply, dry_run, ApplyReport}; use matilda_linker::{Linker, SshAuth, SshConfig}; use matilda_plan::{plan, Op, Plan}; use shuma_module::{ModuleContributions, MonitorSpec, Rgb, Sample, ShortcutSpec, Source}; use std::path::PathBuf; +use std::sync::atomic::{AtomicBool, Ordering}; use std::sync::{Arc, Mutex}; +mod remote; +mod render; +pub use remote::*; +pub use render::view; + pub const ID: &str = "matilda"; +/// Estado de un host de la flota (M5): el runtime observado de un servidor +/// declarado, o un error si no se pudo alcanzar. `Pending` mientras el +/// fetch por SSH está en vuelo (lo corre el chasis en un thread por host). +#[derive(Debug, Clone)] +pub enum FleetEntry { + Pending, + Ready(RuntimeState), + Failed(String), +} + /// Estado del módulo. El `desired` se llena con un ejemplo arrancable /// hasta que el bloque 5 cablee `--inventory` desde el shumarc. El /// `pending_steps` se comparte por `Arc>` para que el sampler @@ -70,7 +88,47 @@ pub struct State { /// expone para que el chasis sepa de dónde recargar al pulsar /// «Reload»; el módulo mismo no hace IO, sólo recibe `SetDesired`. pub inventory_path: Option, + /// Estado runtime observado (qué corre AHORA: estado, status, puertos). + /// `None` hasta el primer discover. Es la base del monitoreo en vivo, + /// distinto del inventario declarativo (`desired`/`current`). + pub runtime: Option, + /// Historial CPU/mem por contenedor (M2): un ring de muestras alimentado + /// por el polling (`docker stats --no-stream`). La sparkline de la fila + /// seleccionada lo lee. Capado a `STATS_HISTORY_CAP` por contenedor. + pub stats_history: std::collections::BTreeMap>, + /// Live-tail de logs activo (M2), si lo hay — `docker logs -f` de un + /// contenedor streameado a un buffer. `None` = sin stream. + pub log_stream: Option, + /// Contenedor seleccionado en el panel — abre la barra de acciones + /// (start/stop/restart/logs/rm). `None` = nada seleccionado. + pub selected_container: Option, + /// Servicio systemd seleccionado — abre su barra de acciones. + pub selected_service: Option, + /// Flota (M5): runtime por host declarado (`name` → estado). Lo llena el + /// chasis vía SSH, un host por thread. Vacío hasta el primer Refresh. + pub fleet: std::collections::BTreeMap, + /// Host de la flota seleccionado — expande sus contenedores/servicios. + pub selected_host: Option, + /// Hosts de la flota con un fetch SSH en vuelo — guarda anti-apilamiento + /// del polling periódico (M5): un host colgado no debe acumular threads + /// tick tras tick. Lo comparte el chasis con el thread de polling; el + /// thread se borra a sí mismo al terminar. Vacío = nada en vuelo. + pub fleet_poll_inflight: Arc>>, + /// `true` mientras un fetch de runtime del Source montado remoto está en + /// vuelo (M4) — guard anti-apilamiento del polling, igual criterio que + /// `fleet_poll_inflight` pero para el host montado. Compartido con el + /// thread, que lo baja al terminar. + pub runtime_poll_inflight: Arc, + /// Contenedor de la flota seleccionado dentro del host expandido — abre + /// la barra de acciones remotas (M5). Scoped al `selected_host`; se + /// limpia al cambiar de host. `None` = nada seleccionado. + pub selected_fleet_container: Option, + /// Servicio de la flota seleccionado dentro del host expandido — abre su + /// barra de acciones remotas. Scoped al `selected_host`. + pub selected_fleet_service: Option, pending_steps: Arc>, + /// `(up, down)` compartido con el sampler del monitor de runtime. + runtime_counts: Arc>, } impl State { @@ -89,7 +147,19 @@ impl State { log: Vec::new(), split_width: 380.0, inventory_path: None, + runtime: None, + stats_history: std::collections::BTreeMap::new(), + log_stream: None, + selected_container: None, + selected_service: None, + fleet: std::collections::BTreeMap::new(), + selected_host: None, + fleet_poll_inflight: Arc::new(Mutex::new(std::collections::HashSet::new())), + runtime_poll_inflight: Arc::new(std::sync::atomic::AtomicBool::new(false)), + selected_fleet_container: None, + selected_fleet_service: None, pending_steps: Arc::new(Mutex::new(0)), + runtime_counts: Arc::new(Mutex::new((0, 0))), } } @@ -111,6 +181,77 @@ impl State { pub fn pending_count(&self) -> usize { self.plan.as_ref().map(|p| p.len()).unwrap_or(0) } + + /// Fija el estado runtime observado y publica `(up, down)` al sampler + /// del monitor (el thread de polling lee el `Arc` sin tocar el UI). + pub fn set_runtime(&mut self, rt: RuntimeState) { + *self.runtime_counts.lock().unwrap() = (rt.up_count(), rt.down_count()); + self.runtime = Some(rt); + } + + /// Incorpora una tanda de muestras CPU/mem (M2): empuja cada una a su ring + /// por contenedor (capado a [`STATS_HISTORY_CAP`]) y descarta el historial + /// de contenedores que ya no aparecen (se fueron). Idempotente por tanda. + pub fn record_stats( + &mut self, + stats: &std::collections::BTreeMap, + ) { + for (name, sample) in stats { + let ring = self.stats_history.entry(name.clone()).or_default(); + ring.push_back(*sample); + while ring.len() > STATS_HISTORY_CAP { + ring.pop_front(); + } + } + // Limpia rings de contenedores ausentes de esta tanda (evita fugas y + // sparklines fantasma de contenedores borrados). + self.stats_history.retain(|name, _| stats.contains_key(name)); + } + + /// La sparkline CPU del contenedor `name`, o `None` si no hay historial. + pub fn cpu_sparkline(&self, name: &str) -> Option { + let ring = self.stats_history.get(name)?; + if ring.is_empty() { + return None; + } + let cpu: Vec = ring.iter().map(|s| s.cpu_pct).collect(); + Some(sparkline(&cpu)) + } + + /// La última muestra CPU/mem del contenedor `name`, si la hay. + pub fn last_stats(&self, name: &str) -> Option { + self.stats_history.get(name).and_then(|r| r.back().copied()) + } +} + +/// Cuántas muestras CPU/mem guarda el ring por contenedor (M2). A ~5 s de +/// polling local, 40 muestras ≈ 3-4 min de ventana en la sparkline. +pub const STATS_HISTORY_CAP: usize = 40; + +/// Caracteres de barra para la sparkline (8 niveles, de menor a mayor). +const SPARK_BARS: [char; 8] = ['▁', '▂', '▃', '▄', '▅', '▆', '▇', '█']; + +/// Sparkline de bloques Unicode para una serie de muestras. Auto-escala al +/// máximo observado (mín. 1.0) — CPU% puede pasar 100 en multi-core, así que +/// escala relativa lee mejor que un techo fijo. Serie vacía → cadena vacía. +/// Puro y testeable (no toca `View`). +pub fn sparkline(samples: &[f32]) -> String { + if samples.is_empty() { + return String::new(); + } + let max = samples + .iter() + .cloned() + .fold(0.0_f32, f32::max) + .max(1.0); + samples + .iter() + .map(|&v| { + let frac = (v / max).clamp(0.0, 1.0); + let idx = (frac * (SPARK_BARS.len() as f32 - 1.0)).round() as usize; + SPARK_BARS[idx.min(SPARK_BARS.len() - 1)] + }) + .collect() } #[derive(Debug, Clone)] @@ -130,9 +271,34 @@ pub enum Msg { /// resultado del discover remoto desde el chasis (cuando el SSH /// terminó en un thread aparte). SetCurrent(Inventory), + /// Inyecta el estado runtime observado — usado para el discover remoto + /// (el chasis corre `docker ps` por SSH en un thread y reenvía esto). + SetRuntime(RuntimeState), + /// Como `SetRuntime` pero **sin loguear** — para el polling periódico + /// (M4), que no debe spamear el log cada 5 s. + SetRuntimeQuiet(RuntimeState), + /// M2 — incorpora una tanda de muestras CPU/mem (`docker stats`) al + /// historial por contenedor. Silencioso por diseño (lo manda el polling). + SetStatsQuiet(std::collections::BTreeMap), + /// M2 — arranca el live-tail (`docker logs -f`) de un contenedor. El + /// módulo prepara el `LogStream` (buffer + bandera stop); el chasis toma + /// la bandera + source y spawnea el thread lector. + StartLogStream(String), + /// M2 — una línea nueva del live-tail (la manda el thread lector). + LogStreamLine(String), + /// M2 — el operador corta el live-tail (alza la bandera stop; el thread + /// sale y manda `LogStreamEnded`). + StopLogStream, + /// M2 — el thread lector terminó (proceso cerrado o stop). Marca el stream + /// como cerrado; el buffer queda visible. + LogStreamEnded, /// Línea informativa para el log — útil para que el chasis avise /// "conectando", "fallo de SSH", etc., sin acoplarse al módulo. LogLine(String), + /// Varias líneas de una sola vez — el chasis vuelca aquí la salida de una + /// acción remota (`container_action_remote_blocking`) que corrió en un + /// thread. Equivale a N `LogLine` pero en un único Msg. + LogLines(Vec), /// Inyecta el reporte de un dry-run remoto que el chasis corrió en /// un thread aparte (cada `String` es una línea del log). DryRunReport(Vec), @@ -148,6 +314,54 @@ pub enum Msg { SetDesired(Inventory), /// Drag del splitter inventario|plan. ResizeSplit(f32), + /// Click en un contenedor: lo selecciona (toggle) y abre su barra de + /// acciones. Re-clickear el mismo lo deselecciona. + SelectContainer(String), + /// Acción de ciclo de vida sobre un contenedor (start/stop/restart/ + /// logs/rm). Local sincrónico; remoto delegado al chasis. + ContainerActionMsg { name: String, action: ContainerAction }, + /// Click en un servicio systemd: lo selecciona (toggle). + SelectService(String), + /// Acción sobre un servicio systemd (start/stop/restart/enable/disable/ + /// status). Local sincrónico; remoto delegado al chasis. + ServiceActionMsg { name: String, action: ServiceAction }, + /// M5 — refrescar la flota: marca cada host declarado como `Pending`. + /// El chasis spawnea el fetch por SSH (uno por host) y reenvía + /// `SetHostRuntime`/`SetHostError`. + RefreshFleet, + /// Resultado del fetch de un host de la flota. + SetHostRuntime { host: String, runtime: RuntimeState }, + /// Error al alcanzar un host de la flota. + SetHostError { host: String, error: String }, + /// Como `SetHostRuntime`/`SetHostError` pero **sin loguear** — los usa el + /// polling periódico de la flota (M5), que refresca cada host cada ~30 s y + /// no debe spamear el log ni parpadear el host a «consultando». + SetHostRuntimeQuiet { host: String, runtime: RuntimeState }, + /// Variante silenciosa del error de host para el polling de la flota. + SetHostErrorQuiet { host: String, error: String }, + /// Click en un host de la flota: lo selecciona (toggle) y expande su + /// runtime (contenedores + servicios). + SelectHost(String), + /// Click en un contenedor dentro del host expandido de la flota: lo + /// selecciona (toggle) y abre su barra de acciones remotas. + SelectFleetContainer(String), + /// Click en un servicio dentro del host expandido de la flota. + SelectFleetService(String), + /// M5 — acción de ciclo de vida sobre un contenedor de un host de la + /// flota. Siempre remota: el módulo sólo registra la intención en el log; + /// el chasis la toma, corre el SSH en un thread (`fleet_container_action_ + /// blocking`) y re-observa el host (`SetHostRuntime`). + FleetContainerAction { host: String, name: String, action: ContainerAction }, + /// M5 — acción sobre un servicio systemd de un host de la flota. + FleetServiceAction { host: String, name: String, action: ServiceAction }, + /// M5 — resultado de una acción de flota que el chasis corrió por SSH: + /// líneas para el log y, si fue mutante y exitosa, el runtime re-observado + /// del host para refrescar su `FleetEntry` sin volver a pulsar «Fleet». + FleetActionDone { + host: String, + lines: Vec, + runtime: Option, + }, } /// Mapea el `action_id` de un `ShortcutAction::ModuleAction` al `Msg` @@ -167,7 +381,7 @@ pub fn update(state: State, msg: Msg) -> State { let mut s = state; match msg { Msg::Discover => match &s.source { - Source::Local | Source::Daemon { .. } | Source::DaemonTcp { .. } => { + Source::Local | Source::Daemon { .. } | Source::DaemonTcp { .. } | Source::Container { .. } => { // Matilda no habla todavía con el daemon de shuma — corre // siempre sobre el FS local cuando no es SSH. let current = discover_inventory(&s.desired); @@ -177,8 +391,17 @@ pub fn update(state: State, msg: Msg) -> State { current.vhosts().count() )); s.current = Some(current); + // Además del inventario declarativo, capturamos el estado + // runtime (qué corre, parado, sus puertos) para el monitoreo. + let rt = discover_runtime(); + s.log.push(format!( + " runtime: {} up · {} down", + rt.up_count(), + rt.down_count() + )); + s.set_runtime(rt); } - Source::Remote { host, .. } => { + Source::Remote { host, .. } | Source::RemoteContainer { host, .. } => { // El discover remoto necesita un runtime tokio y vive // en un thread del chasis (ver `discover_remote_blocking`). // Aquí sólo registramos que el módulo no puede hacerlo @@ -303,10 +526,63 @@ pub fn update(state: State, msg: Msg) -> State { )); s.current = Some(inv); } + Msg::SetRuntime(rt) => { + s.log.push(format!( + "✔ runtime: {} up · {} down", + rt.up_count(), + rt.down_count() + )); + s.set_runtime(rt); + } + Msg::SetRuntimeQuiet(rt) => { + s.set_runtime(rt); + } + Msg::SetStatsQuiet(stats) => { + s.record_stats(&stats); + } + Msg::StartLogStream(name) => { + // Corta cualquier stream previo (su thread verá la bandera y sale). + if let Some(prev) = &s.log_stream { + prev.stop.store(true, Ordering::Relaxed); + } + s.log_stream = Some(LogStream { + container: name.clone(), + lines: std::collections::VecDeque::new(), + stop: Arc::new(AtomicBool::new(false)), + ended: false, + }); + s.log.push(format!("▶ siguiendo logs de {name} (logs -f)")); + cap_log(&mut s.log); + } + Msg::LogStreamLine(line) => { + if let Some(ls) = &mut s.log_stream { + ls.lines.push_back(line); + while ls.lines.len() > LOG_STREAM_CAP { + ls.lines.pop_front(); + } + } + } + Msg::StopLogStream => { + if let Some(ls) = &mut s.log_stream { + ls.stop.store(true, Ordering::Relaxed); + ls.ended = true; + } + } + Msg::LogStreamEnded => { + if let Some(ls) = &mut s.log_stream { + ls.ended = true; + } + } Msg::LogLine(line) => { s.log.push(line); cap_log(&mut s.log); } + Msg::LogLines(lines) => { + for l in lines { + s.log.push(l); + } + cap_log(&mut s.log); + } Msg::SetDesired(inv) => { s.log.push(format!( "✔ inventario recargado: {} hosts, {} containers, {} vhosts", @@ -321,10 +597,204 @@ pub fn update(state: State, msg: Msg) -> State { Msg::ResizeSplit(dx) => { s.split_width = (s.split_width + dx).clamp(220.0, 720.0); } + Msg::SelectContainer(name) => { + s.selected_container = if s.selected_container.as_deref() == Some(name.as_str()) { + None + } else { + Some(name) + }; + } + Msg::ContainerActionMsg { name, action } => { + if s.source.is_remote() { + // El remoto necesita SSH + thread: lo corre el chasis y + // reenvía el log por `Msg::LogLine` (ver + // `container_action_remote_blocking`). + s.log.push(format!( + "→ {} {name} remoto delegado al chasis", + action.label() + )); + } else { + let cmd = action.command(&name); + s.log.push(format!("$ {cmd}")); + let (ok, out) = run_shell_capture(&cmd); + for line in out.into_iter().take(30) { + s.log.push(format!(" {line}")); + } + s.log.push(if ok { + format!("✔ {} {name}", action.label()) + } else { + format!("✘ {} {name} falló", action.label()) + }); + // Una acción mutante cambia el runtime: lo re-observamos para + // que el semáforo del panel quede al día sin pulsar Discover. + if ok && action.is_mutating() { + s.set_runtime(discover_runtime()); + } + } + cap_log(&mut s.log); + } + Msg::SelectService(name) => { + s.selected_service = if s.selected_service.as_deref() == Some(name.as_str()) { + None + } else { + Some(name) + }; + } + Msg::ServiceActionMsg { name, action } => { + if s.source.is_remote() { + s.log.push(format!( + "→ {} {name} remoto delegado al chasis", + action.label() + )); + } else { + let cmd = action.command(&name); + s.log.push(format!("$ {cmd}")); + let (ok, out) = run_shell_capture(&cmd); + for line in out.into_iter().take(30) { + s.log.push(format!(" {line}")); + } + s.log.push(if ok { + format!("✔ {} {name}", action.label()) + } else { + format!("✘ {} {name} falló (¿privilegios?)", action.label()) + }); + if ok && action.is_mutating() { + s.set_runtime(discover_runtime()); + } + } + cap_log(&mut s.log); + } + Msg::RefreshFleet => { + // Marcamos cada host declarado como Pending; el chasis dispara el + // fetch por SSH (un thread por host) y reenvía los resultados. + s.fleet.clear(); + for h in s.desired.hosts() { + s.fleet.insert(h.name.clone(), FleetEntry::Pending); + } + s.log.push(format!("→ refrescando flota ({} hosts)…", s.fleet.len())); + cap_log(&mut s.log); + } + Msg::SetHostRuntime { host, runtime } => { + s.log.push(format!( + "✔ {host}: {} up · {} down · {} svc", + runtime.up_count(), + runtime.down_count(), + runtime.services.len() + )); + s.fleet.insert(host, FleetEntry::Ready(runtime)); + cap_log(&mut s.log); + } + Msg::SetHostError { host, error } => { + s.log.push(format!("✘ {host}: {error}")); + s.fleet.insert(host, FleetEntry::Failed(error)); + cap_log(&mut s.log); + } + Msg::SetHostRuntimeQuiet { host, runtime } => { + s.fleet.insert(host, FleetEntry::Ready(runtime)); + } + Msg::SetHostErrorQuiet { host, error } => { + s.fleet.insert(host, FleetEntry::Failed(error)); + } + Msg::SelectHost(name) => { + s.selected_host = if s.selected_host.as_deref() == Some(name.as_str()) { + None + } else { + Some(name) + }; + // Cambiar de host expandido invalida la selección de recurso de la + // flota — sus action bars pertenecen al host anterior. + s.selected_fleet_container = None; + s.selected_fleet_service = None; + } + Msg::SelectFleetContainer(name) => { + s.selected_fleet_container = + if s.selected_fleet_container.as_deref() == Some(name.as_str()) { + None + } else { + s.selected_fleet_service = None; + Some(name) + }; + } + Msg::SelectFleetService(name) => { + s.selected_fleet_service = + if s.selected_fleet_service.as_deref() == Some(name.as_str()) { + None + } else { + s.selected_fleet_container = None; + Some(name) + }; + } + Msg::FleetContainerAction { host, name, action } => { + // Siempre remota: el SSH lo corre el chasis en un thread y reenvía + // el log + el `SetHostRuntime` re-observado. + s.log.push(format!("→ {} {name} en {host} (flota) delegado al chasis", action.label())); + cap_log(&mut s.log); + } + Msg::FleetServiceAction { host, name, action } => { + s.log.push(format!("→ {} {name} en {host} (flota) delegado al chasis", action.label())); + cap_log(&mut s.log); + } + Msg::FleetActionDone { host, lines, runtime } => { + for l in lines { + s.log.push(l); + } + cap_log(&mut s.log); + // Si la acción re-observó el host, su `FleetEntry` queda al día. + if let Some(rt) = runtime { + s.fleet.insert(host, FleetEntry::Ready(rt)); + } + } } s } + + + + +/// Estado del live-tail de logs (M2): el contenedor seguido, su buffer de +/// líneas (capado a [`LOG_STREAM_CAP`]) y la bandera `stop` compartida con el +/// thread lector del chasis. `ended` se marca cuando el stream terminó (el +/// proceso cerró o el usuario pulsó Stop) — el buffer queda visible. +#[derive(Debug, Clone)] +pub struct LogStream { + pub container: String, + pub lines: std::collections::VecDeque, + pub stop: Arc, + pub ended: bool, +} + +/// Cuántas líneas retiene el buffer del live-tail antes de tirar las viejas. +pub const LOG_STREAM_CAP: usize = 500; + +/// Acumula bytes de un stream y emite líneas completas (split por `\n`, sin el +/// `\n` ni el `\r` final). El resto incompleto queda para el próximo chunk — +/// los chunks de un canal SSH no vienen alineados a línea. +#[derive(Default)] +struct LineSplitter { + buf: Vec, +} + +impl LineSplitter { + fn push(&mut self, data: &[u8], mut emit: impl FnMut(String)) { + self.buf.extend_from_slice(data); + while let Some(pos) = self.buf.iter().position(|&b| b == b'\n') { + let line: Vec = self.buf.drain(..=pos).collect(); + let end = line.len().saturating_sub(1); // sin el '\n' + let s = String::from_utf8_lossy(&line[..end]); + emit(s.trim_end_matches('\r').to_string()); + } + } +} + + + + + + + + + fn cap_log(log: &mut Vec) { const MAX: usize = 200; let len = log.len(); @@ -333,244 +803,14 @@ fn cap_log(log: &mut Vec) { } } -// ─── Discover y dry-run remotos ───────────────────────────────────── -/// Ruta default de la clave SSH del usuario; coincide con el matilda CLI. -fn default_ssh_key() -> PathBuf { - let home = std::env::var("HOME").unwrap_or_else(|_| "/root".into()); - PathBuf::from(format!("{home}/.ssh/id_ed25519")) -} -/// Descubre el inventario actual del servidor remoto. **Bloqueante**: -/// crea un runtime tokio efímero, conecta por SSH y corre -/// `docker ps -a --format '{{.Names}}'` + `ls /etc/nginx/sites-enabled`. -/// Pensado para que el chasis lo invoque dentro de `Handle::spawn` -/// (un thread aparte) — no llamar desde el hilo de UI. -/// -/// Para Source::Local fallback a `discover_inventory` (no necesita -/// SSH, pero usa el mismo entrypoint para uniformidad). -pub fn discover_remote_blocking(source: &Source, desired: &Inventory) -> Result { - match source { - Source::Local | Source::Daemon { .. } | Source::DaemonTcp { .. } => { - Ok(discover_inventory(desired)) - } - Source::Remote { .. } => { - let config = ssh_config_for(source)?; - let rt = blocking_runtime()?; - rt.block_on(async move { - let linker = Linker::connect(&config) - .await - .map_err(|e| format!("ssh connect: {e}"))?; - fetch_remote_inventory(&linker, desired).await - }) - } - } -} -/// Equivalente remoto de `Msg::DryRun`: conecta por SSH, descubre el -/// inventory actual, calcula el plan deseado-vs-actual y enumera los -/// pasos que SE EJECUTARÍAN — sin invocar ninguno. Útil para validar -/// que el `Source::Remote` está bien configurado y previsualizar el -/// cambio antes de un eventual Apply real (fuera de scope aquí). -/// -/// Devuelve un `Vec` con líneas listas para insertar al log -/// (incluyendo el reporte de dry-run de cada paso). El chasis las -/// envuelve en `Msg::DryRunReport`. -pub fn dry_run_remote_blocking( - source: &Source, - desired: &Inventory, -) -> Result, String> { - let mut lines = Vec::new(); - let current = match source { - Source::Local | Source::Daemon { .. } | Source::DaemonTcp { .. } => { - discover_inventory(desired) - } - Source::Remote { .. } => { - let config = ssh_config_for(source)?; - let rt = blocking_runtime()?; - rt.block_on(async move { - let linker = Linker::connect(&config) - .await - .map_err(|e| format!("ssh connect: {e}"))?; - fetch_remote_inventory(&linker, desired).await - })? - } - }; - lines.push(format!( - "✔ current: {} containers, {} vhosts", - current.containers().count(), - current.vhosts().count() - )); - let p = plan(¤t, desired); - if p.is_empty() { - lines.push("Sin cambios: el servidor ya está al día.".into()); - return Ok(lines); - } - lines.push(format!( - "plan: {} acciones ({} crear, {} actualizar, {} eliminar)", - p.len(), - p.count(Op::Create), - p.count(Op::Update), - p.count(Op::Remove) - )); - let steps = plan_to_steps(&p, desired); - let report: ApplyReport = dry_run(&steps); - for r in &report.results { - lines.push(format!( - "{} {}", - if r.ok { "✔" } else { "✘" }, - r.describe - )); - for line in &r.log { - lines.push(format!(" {line}")); - } - } - Ok(lines) -} -/// Aplica el plan deseado-vs-actual en el servidor remoto: conecta por -/// SSH, descubre el inventario, calcula el plan, ejecuta los pasos en -/// orden y re-descubre el estado final. **Bloqueante** — pensado para -/// que el chasis lo invoque dentro de `Handle::spawn` y reenvíe el -/// resultado por `Msg::ApplyReport`. -/// -/// Devuelve `(lines, new_current)`: el log textual y, si todos los -/// pasos completaron, el inventario re-observado (para resetear el -/// plan/pendientes del módulo). Si algún paso falla, `new_current` es -/// `None` — la UI conserva el plan vigente para que el operador vea -/// dónde se rompió. -pub fn apply_remote_blocking( - source: &Source, - desired: &Inventory, -) -> Result<(Vec, Option), String> { - let mut lines = Vec::new(); - match source { - Source::Local | Source::Daemon { .. } | Source::DaemonTcp { .. } => { - // Local lo maneja `Msg::Apply` sincrónicamente. Para - // uniformidad damos un fallback síncrono sin tocar el UI. - let current = discover_inventory(desired); - let p = plan(¤t, desired); - if p.is_empty() { - lines.push("Sin cambios: nada que aplicar.".into()); - return Ok((lines, Some(current))); - } - let steps = plan_to_steps(&p, desired); - let report: ApplyReport = apply(&steps); - push_apply_log(&mut lines, &report); - let new_current = if report.all_ok() { - Some(discover_inventory(desired)) - } else { - None - }; - Ok((lines, new_current)) - } - Source::Remote { .. } => { - let config = ssh_config_for(source)?; - let rt = blocking_runtime()?; - rt.block_on(async move { - let linker = Linker::connect(&config) - .await - .map_err(|e| format!("ssh connect: {e}"))?; - let current = fetch_remote_inventory(&linker, desired).await?; - lines.push(format!( - "✔ current: {} containers, {} vhosts", - current.containers().count(), - current.vhosts().count() - )); - let p = plan(¤t, desired); - if p.is_empty() { - lines.push("Sin cambios: el servidor ya está al día.".into()); - return Ok((lines, Some(current))); - } - lines.push(format!( - "plan: {} acciones ({} crear, {} actualizar, {} eliminar)", - p.len(), - p.count(Op::Create), - p.count(Op::Update), - p.count(Op::Remove) - )); - let steps = plan_to_steps(&p, desired); - lines.push(format!("— aplicando {} pasos por SSH —", steps.len())); - let report = linker.apply(&steps).await; - push_apply_log(&mut lines, &report); - let new_current = if report.all_ok() { - Some(fetch_remote_inventory(&linker, desired).await?) - } else { - None - }; - Ok((lines, new_current)) - }) - } - } -} - -fn push_apply_log(lines: &mut Vec, report: &ApplyReport) { - for r in &report.results { - lines.push(format!( - "{} {}", - if r.ok { "✔" } else { "✘" }, - r.describe - )); - for line in &r.log { - lines.push(format!(" {line}")); - } - } - lines.push(format!( - "{} de {} pasos aplicados.", - report.applied(), - report.results.len() - )); - if !report.all_ok() { - lines.push("✘ se detuvo en el primer error.".into()); - } -} - -fn ssh_config_for(source: &Source) -> Result { - match source { - Source::Remote { host, user, port, .. } => { - let auth = SshAuth::Key { - path: default_ssh_key(), - passphrase: None, - }; - let mut config = SshConfig::new(host.as_str(), user.as_str(), auth); - config.port = *port; - Ok(config) - } - Source::Local | Source::Daemon { .. } | Source::DaemonTcp { .. } => { - Err("ssh_config_for esperaba Source::Remote".into()) - } - } -} - -fn blocking_runtime() -> Result { - tokio::runtime::Builder::new_current_thread() - .enable_all() - .build() - .map_err(|e| format!("tokio runtime: {e}")) -} - -async fn fetch_remote_inventory( - linker: &Linker, - desired: &Inventory, -) -> Result { - let containers_text = linker - .exec("docker ps -a --format '{{.Names}}' 2>/dev/null || true") - .await - .map_err(|e| format!("docker ps: {e}"))?; - let vhosts_text = linker - .exec("ls -1 /etc/nginx/sites-enabled 2>/dev/null || true") - .await - .map_err(|e| format!("ls sites-enabled: {e}"))?; - let state = ServerState { - containers: matilda_discover::parse_docker_names(&containers_text), - vhosts: matilda_discover::parse_nginx_sites(&vhosts_text), - }; - Ok(observed_inventory(&state, desired)) -} /// Inventario de ejemplo — equivale al `matilda example`. Permite /// arrancar el módulo sin un archivo de inventario y demostrar el @@ -595,220 +835,10 @@ pub fn example_inventory() -> Inventory { .with_alias("www.sitio.com") .with_tls(), ); + inv.add_service(matilda_core::Service::new("nginx")); inv } -// ─── view ────────────────────────────────────────────────────────── - -pub fn view( - state: &State, - theme: &Theme, - lift: impl Fn(Msg) -> HostMsg + Clone + Send + Sync + 'static, -) -> View { - let header = matilda_header(state, theme); - - let inv_pane = inventory_pane(state, theme); - let plan_pane = plan_and_log_pane(state, theme); - - let splitter_palette = SplitterPalette::from_theme(theme); - let lift_resize = lift.clone(); - let body = splitter_two( - Direction::Row, - inv_pane, - PaneSize::Fixed(state.split_width), - plan_pane, - PaneSize::Flex, - move |phase, dx| match phase { - DragPhase::Move => Some(lift_resize(Msg::ResizeSplit(dx))), - DragPhase::End => None, - }, - &splitter_palette, - ); - - View::new(Style { - flex_direction: FlexDirection::Column, - size: Size { - width: percent(1.0_f32), - height: percent(1.0_f32), - }, - ..Default::default() - }) - .fill(theme.bg_app) - .children(vec![header, body]) -} - -fn matilda_header(state: &State, theme: &Theme) -> View { - let label = format!( - "Matilda · {} · {} hosts · {} containers · {} vhosts", - state.source.label(), - state.desired.hosts().count(), - state.desired.containers().count(), - state.desired.vhosts().count(), - ); - View::new(Style { - size: Size { - width: percent(1.0_f32), - height: length(28.0_f32), - }, - padding: Rect { - left: length(14.0_f32), - right: length(14.0_f32), - top: length(0.0_f32), - bottom: length(0.0_f32), - }, - align_items: Some(AlignItems::Center), - ..Default::default() - }) - .fill(theme.bg_panel) - .text_aligned(label, 12.0, theme.fg_text, Alignment::Start) -} - -/// Panel izquierdo: el inventario deseado en 3 secciones (hosts / -/// containers / vhosts). Compuesto como Views planos — el -/// `llimphi-widget-list` exigiría un `on_click` por fila, y en este -/// tab las filas son informativas (no se seleccionan todavía). -fn inventory_pane(state: &State, theme: &Theme) -> View { - let mut children: Vec> = Vec::new(); - - children.push(section_label( - &format!("HOSTS ({})", state.desired.hosts().count()), - theme, - )); - for h in state.desired.hosts() { - children.push(inv_row(&format!(" {} {}", h.name, h.address), theme)); - } - - children.push(section_label( - &format!("CONTAINERS ({})", state.desired.containers().count()), - theme, - )); - for c in state.desired.containers() { - children.push(inv_row(&format!(" {} {}", c.name, c.image), theme)); - } - - children.push(section_label( - &format!("VHOSTS ({})", state.desired.vhosts().count()), - theme, - )); - for v in state.desired.vhosts() { - children.push(inv_row( - &format!(" {} → {}", v.domain, describe_upstream(&v.upstream)), - theme, - )); - } - - View::new(Style { - flex_direction: FlexDirection::Column, - size: Size { - width: percent(1.0_f32), - height: percent(1.0_f32), - }, - padding: Rect { - left: length(10.0_f32), - right: length(10.0_f32), - top: length(8.0_f32), - bottom: length(8.0_f32), - }, - gap: Size { - width: length(0.0_f32), - height: length(2.0_f32), - }, - ..Default::default() - }) - .fill(theme.bg_panel) - .children(children) -} - -fn describe_upstream(u: &matilda_core::Upstream) -> String { - use matilda_core::Upstream::*; - match u { - Container { name, port } => format!("{name}:{port}"), - Address(addr) => addr.clone(), - } -} - -fn inv_row(text: &str, theme: &Theme) -> View { - text_row(text, theme.fg_text, theme) -} - -fn plan_and_log_pane(state: &State, theme: &Theme) -> View { - let plan_label = match &state.plan { - Some(p) if p.is_empty() => "Plan · sin cambios".to_string(), - Some(p) => format!("Plan · {} acciones", p.len()), - None => "Plan · sin calcular (pulsá «Plan» en la toolbar)".to_string(), - }; - - let plan_header = section_label(&plan_label, theme); - - let mut plan_children: Vec> = vec![plan_header]; - if let Some(p) = &state.plan { - for (i, action) in p.actions.iter().enumerate() { - plan_children.push(text_row( - &format!("{:>2}. {}", i + 1, action.describe()), - theme.fg_text, - theme, - )); - } - } - - plan_children.push(section_label("Log", theme)); - for line in state.log.iter().rev().take(40).rev() { - plan_children.push(text_row(line, theme.fg_muted, theme)); - } - - View::new(Style { - flex_direction: FlexDirection::Column, - size: Size { - width: percent(1.0_f32), - height: percent(1.0_f32), - }, - padding: Rect { - left: length(10.0_f32), - right: length(10.0_f32), - top: length(8.0_f32), - bottom: length(8.0_f32), - }, - gap: Size { - width: length(0.0_f32), - height: length(2.0_f32), - }, - ..Default::default() - }) - .fill(theme.bg_app) - .children(plan_children) -} - -fn section_label(text: &str, theme: &Theme) -> View { - View::new(Style { - size: Size { - width: percent(1.0_f32), - height: length(18.0_f32), - }, - margin: Rect { - left: length(0.0_f32), - right: length(0.0_f32), - top: length(6.0_f32), - bottom: length(2.0_f32), - }, - ..Default::default() - }) - .text_aligned(text.to_string(), 11.0, theme.accent, Alignment::Start) -} - -fn text_row( - text: &str, - color: llimphi_ui::llimphi_raster::peniko::Color, - _theme: &Theme, -) -> View { - View::new(Style { - size: Size { - width: percent(1.0_f32), - height: length(16.0_f32), - }, - ..Default::default() - }) - .text_aligned(text.to_string(), 11.0, color, Alignment::Start) -} // ─── contributions ────────────────────────────────────────────────── @@ -826,8 +856,23 @@ pub fn contributions(state: &State) -> ModuleContributions { }), }; + // Monitor de runtime: contenedores vivos (la serie es el #up; el + // detalle lleva up/down). Es el monitoreo en vivo del servidor. + let counts = state.runtime_counts.clone(); + let runtime_monitor = MonitorSpec { + id: "matilda.runtime", + label: format!("matilda · {} · up", state.source.label()), + accent: Rgb::new(0x82, 0xCD, 0x8C), + history_capacity: 60, + period_secs: 5.0, + sampler: Box::new(move || { + let (up, down) = *counts.lock().unwrap(); + Sample::new(up as f32, format!("{up} up · {down} down")) + }), + }; + ModuleContributions { - monitors: vec![monitor], + monitors: vec![monitor, runtime_monitor], shortcuts: vec![ ShortcutSpec::module_action("Discover", "matilda.discover") .with_hint("Lee el estado actual del servidor"), @@ -837,6 +882,8 @@ pub fn contributions(state: &State) -> ModuleContributions { .with_hint("Previsualiza los pasos sin aplicar"), ShortcutSpec::module_action("Apply", "matilda.apply") .with_hint("Reconcilia el servidor con el inventario deseado"), + ShortcutSpec::module_action("Fleet", "matilda.fleet") + .with_hint("Consulta el runtime de todos los hosts por SSH"), ShortcutSpec::module_action("Reload", "matilda.reload") .with_hint("Relee el inventario JSON desde disco"), ], @@ -883,10 +930,9 @@ mod tests { let s = State::new(Source::Local); let s = update(s, Msg::MakePlan); let plan = s.plan.as_ref().expect("plan se debe haber calculado"); - // 2 containers + 1 vhost (los hosts no producen acción si no hay - // current, pero el example_inventory tiene 1 → cuenta como create). - assert_eq!(plan.count(Op::Create), 4); - assert_eq!(s.pending_count(), 4); + // 1 host + 2 containers + 1 vhost + 1 service = 5 creates. + assert_eq!(plan.count(Op::Create), 5); + assert_eq!(s.pending_count(), 5); } #[test] @@ -1016,13 +1062,342 @@ mod tests { fn contributions_expose_monitor_and_five_shortcuts() { let s = State::new(Source::Local); let c = contributions(&s); - assert_eq!(c.monitors.len(), 1); - assert_eq!(c.shortcuts.len(), 5); + // pending + runtime. + assert_eq!(c.monitors.len(), 2); + assert_eq!(c.monitors[1].id, "matilda.runtime"); + assert_eq!(c.shortcuts.len(), 6); assert_eq!(c.shortcuts[0].label, "Discover"); assert_eq!(c.shortcuts[1].label, "Plan"); assert_eq!(c.shortcuts[2].label, "Dry-run"); assert_eq!(c.shortcuts[3].label, "Apply"); - assert_eq!(c.shortcuts[4].label, "Reload"); + assert_eq!(c.shortcuts[4].label, "Fleet"); + assert_eq!(c.shortcuts[5].label, "Reload"); + } + + #[test] + fn set_runtime_actualiza_estado_y_contadores() { + use matilda_discover::{ContainerStatus, RunState, RuntimeState}; + let mut s = State::new(Source::Local); + assert!(s.runtime.is_none()); + let rt = RuntimeState { + containers: vec![ + ContainerStatus { + name: "web".into(), + image: "nginx:1.27".into(), + state: RunState::Running, + status: "Up 2 hours".into(), + ports: "0.0.0.0:80->80/tcp".into(), + }, + ContainerStatus { + name: "viejo".into(), + image: "img".into(), + state: RunState::Exited, + status: "Exited (0)".into(), + ports: String::new(), + }, + ], + services: vec![], + vhosts: vec![], + }; + s = update(s, Msg::SetRuntime(rt)); + let rt = s.runtime.as_ref().expect("runtime fijado"); + assert_eq!(rt.up_count(), 1); + assert_eq!(rt.down_count(), 1); + // `viejo` no está en el inventario deseado → es huérfano observado. + assert!(s.desired.container("viejo").is_none()); + assert!(s.log.iter().any(|l| l.contains("1 up") && l.contains("1 down"))); + } + + #[test] + fn select_container_es_toggle() { + let mut s = State::new(Source::Local); + s = update(s, Msg::SelectContainer("web".into())); + assert_eq!(s.selected_container.as_deref(), Some("web")); + // Re-click deselecciona. + s = update(s, Msg::SelectContainer("web".into())); + assert!(s.selected_container.is_none()); + // Otro contenedor reemplaza. + s = update(s, Msg::SelectContainer("a".into())); + s = update(s, Msg::SelectContainer("b".into())); + assert_eq!(s.selected_container.as_deref(), Some("b")); + } + + #[test] + fn container_action_local_loguea_comando() { + // Sin docker en el entorno de test, el comando falla — pero el log + // debe contener la línea del comando y un cierre. (No depende de + // que docker exista; sólo del path de ejecución local.) + let mut s = State::new(Source::Local); + s = update( + s, + Msg::ContainerActionMsg { + name: "web".into(), + action: ContainerAction::Start, + }, + ); + assert!(s.log.iter().any(|l| l.contains("docker start web"))); + assert!(s.log.iter().any(|l| l.contains("Start web"))); + } + + #[test] + fn service_action_local_loguea_comando() { + let mut s = State::new(Source::Local); + s = update(s, Msg::SelectService("sshd.service".into())); + assert_eq!(s.selected_service.as_deref(), Some("sshd.service")); + s = update( + s, + Msg::ServiceActionMsg { + name: "sshd.service".into(), + action: ServiceAction::Restart, + }, + ); + assert!(s.log.iter().any(|l| l.contains("systemctl restart sshd.service"))); + } + + #[test] + fn fleet_refresh_marca_pending_y_resultados_aterrizan() { + use matilda_discover::RuntimeState; + let mut s = State::new(Source::Local); // example tiene 1 host: edge-1 + s = update(s, Msg::RefreshFleet); + assert!(matches!(s.fleet.get("edge-1"), Some(FleetEntry::Pending))); + // Aterriza el runtime de ese host. + let rt = RuntimeState { + containers: vec![matilda_discover::ContainerStatus { + name: "web".into(), + image: "nginx".into(), + state: matilda_discover::RunState::Running, + status: "Up".into(), + ports: String::new(), + }], + services: vec![], + vhosts: vec![], + }; + s = update(s, Msg::SetHostRuntime { host: "edge-1".into(), runtime: rt }); + assert!(matches!(s.fleet.get("edge-1"), Some(FleetEntry::Ready(_)))); + // Y un error en otro. + s = update(s, Msg::SetHostError { host: "edge-1".into(), error: "timeout".into() }); + assert!(matches!(s.fleet.get("edge-1"), Some(FleetEntry::Failed(_)))); + // Selección toggle. + s = update(s, Msg::SelectHost("edge-1".into())); + assert_eq!(s.selected_host.as_deref(), Some("edge-1")); + s = update(s, Msg::SelectHost("edge-1".into())); + assert!(s.selected_host.is_none()); + } + + #[test] + fn fleet_container_action_se_delega_al_chasis() { + // El módulo no abre SSH: sólo deja la intención en el log; el chasis + // corre `fleet_container_action_blocking` en un thread. + let mut s = State::new(Source::Local); + s = update(s, Msg::FleetContainerAction { + host: "edge-1".into(), + name: "web".into(), + action: ContainerAction::Restart, + }); + assert!(s.log.iter().any(|l| l.contains("Restart") + && l.contains("web") + && l.contains("edge-1") + && l.contains("delegado al chasis"))); + } + + #[test] + fn fleet_service_action_se_delega_al_chasis() { + let mut s = State::new(Source::Local); + s = update(s, Msg::FleetServiceAction { + host: "edge-1".into(), + name: "nginx.service".into(), + action: ServiceAction::Stop, + }); + assert!(s.log.iter().any(|l| l.contains("nginx.service") + && l.contains("edge-1") + && l.contains("delegado al chasis"))); + } + + #[test] + fn select_fleet_resource_es_toggle_y_excluyente() { + let mut s = State::new(Source::Local); + s = update(s, Msg::SelectFleetContainer("web".into())); + assert_eq!(s.selected_fleet_container.as_deref(), Some("web")); + // Seleccionar un servicio limpia el contenedor (mutuamente excluyentes). + s = update(s, Msg::SelectFleetService("sshd.service".into())); + assert!(s.selected_fleet_container.is_none()); + assert_eq!(s.selected_fleet_service.as_deref(), Some("sshd.service")); + // Re-click deselecciona. + s = update(s, Msg::SelectFleetService("sshd.service".into())); + assert!(s.selected_fleet_service.is_none()); + } + + #[test] + fn cambiar_de_host_limpia_la_seleccion_de_recurso() { + let mut s = State::new(Source::Local); + s = update(s, Msg::SelectHost("edge-1".into())); + s = update(s, Msg::SelectFleetContainer("web".into())); + assert_eq!(s.selected_fleet_container.as_deref(), Some("web")); + // Expandir otro host abandona la selección anterior. + s = update(s, Msg::SelectHost("edge-2".into())); + assert!(s.selected_fleet_container.is_none()); + assert!(s.selected_fleet_service.is_none()); + } + + #[test] + fn sparkline_escala_y_mapea_niveles() { + // Vacío → cadena vacía. + assert_eq!(sparkline(&[]), ""); + // Monótona creciente: el último carácter es el bloque lleno, el + // primero el más bajo. Auto-escala al máximo (8.0 aquí). + let s = sparkline(&[0.0, 2.0, 4.0, 8.0]); + assert_eq!(s.chars().count(), 4); + assert_eq!(s.chars().next_back().unwrap(), '█'); + assert_eq!(s.chars().next().unwrap(), '▁'); + } + + #[test] + fn record_stats_acumula_y_capa_y_limpia_ausentes() { + use matilda_discover::ContainerStats; + let mut s = State::new(Source::Local); + // Empuja CAP+5 muestras de "web" → el ring se capa. + for i in 0..(STATS_HISTORY_CAP + 5) { + let mut m = std::collections::BTreeMap::new(); + m.insert("web".to_string(), ContainerStats { cpu_pct: i as f32, mem_pct: 1.0 }); + s.record_stats(&m); + } + assert_eq!(s.stats_history["web"].len(), STATS_HISTORY_CAP); + // La última muestra es la más reciente. + assert_eq!(s.last_stats("web").unwrap().cpu_pct, (STATS_HISTORY_CAP + 4) as f32); + assert!(s.cpu_sparkline("web").is_some()); + // Una tanda sin "web" lo borra del historial (se fue el contenedor). + let mut other = std::collections::BTreeMap::new(); + other.insert("db".to_string(), ContainerStats { cpu_pct: 3.0, mem_pct: 2.0 }); + s.record_stats(&other); + assert!(s.cpu_sparkline("web").is_none()); + assert!(s.cpu_sparkline("db").is_some()); + } + + #[test] + fn set_stats_quiet_no_loguea() { + use matilda_discover::ContainerStats; + let mut s = State::new(Source::Local); + let log_before = s.log.len(); + let mut m = std::collections::BTreeMap::new(); + m.insert("web".to_string(), ContainerStats { cpu_pct: 10.0, mem_pct: 20.0 }); + s = update(s, Msg::SetStatsQuiet(m)); + assert_eq!(s.log.len(), log_before); + assert_eq!(s.last_stats("web").unwrap().mem_pct, 20.0); + } + + #[test] + fn line_splitter_emite_lineas_completas() { + let mut sp = LineSplitter::default(); + let mut out: Vec = Vec::new(); + // Chunk parcial: nada se emite hasta el '\n'. + sp.push(b"hola mun", |l| out.push(l)); + assert!(out.is_empty()); + // Completa la línea y arranca otra; el '\r' final se recorta. + sp.push(b"do\r\nseg", |l| out.push(l)); + assert_eq!(out, vec!["hola mundo".to_string()]); + sp.push(b"unda\n", |l| out.push(l)); + assert_eq!(out, vec!["hola mundo".to_string(), "segunda".to_string()]); + } + + #[test] + fn start_log_stream_prepara_buffer_y_loguea() { + let mut s = State::new(Source::Local); + s = update(s, Msg::StartLogStream("web".into())); + let ls = s.log_stream.as_ref().expect("stream armado"); + assert_eq!(ls.container, "web"); + assert!(!ls.stop.load(Ordering::Relaxed)); + assert!(!ls.ended); + assert!(s.log.iter().any(|l| l.contains("siguiendo logs de web"))); + } + + #[test] + fn log_stream_line_acumula_y_capa() { + let mut s = State::new(Source::Local); + s = update(s, Msg::StartLogStream("web".into())); + for i in 0..(LOG_STREAM_CAP + 10) { + s = update(s, Msg::LogStreamLine(format!("línea {i}"))); + } + let ls = s.log_stream.as_ref().unwrap(); + assert_eq!(ls.lines.len(), LOG_STREAM_CAP); + // La más vieja se descartó; la más nueva está. + assert_eq!(ls.lines.back().unwrap(), &format!("línea {}", LOG_STREAM_CAP + 9)); + } + + #[test] + fn stop_log_stream_alza_bandera_y_marca_ended() { + let mut s = State::new(Source::Local); + s = update(s, Msg::StartLogStream("web".into())); + let stop = s.log_stream.as_ref().unwrap().stop.clone(); + s = update(s, Msg::StopLogStream); + assert!(stop.load(Ordering::Relaxed)); + assert!(s.log_stream.as_ref().unwrap().ended); + } + + #[test] + fn start_log_stream_corta_el_anterior() { + let mut s = State::new(Source::Local); + s = update(s, Msg::StartLogStream("web".into())); + let old_stop = s.log_stream.as_ref().unwrap().stop.clone(); + // Arrancar otro corta el viejo (su thread vería la bandera) y crea uno + // nuevo con bandera fresca. + s = update(s, Msg::StartLogStream("api".into())); + assert!(old_stop.load(Ordering::Relaxed), "el stream anterior se cortó"); + let ls = s.log_stream.as_ref().unwrap(); + assert_eq!(ls.container, "api"); + assert!(!ls.stop.load(Ordering::Relaxed)); + } + + #[test] + fn source_stats_remote_blocking_local_no_abre_ssh() { + // Source::Local → discover_stats local (sin SSH). En CI sin docker da + // un mapa vacío, pero Ok. + let res = source_stats_remote_blocking(&Source::Local); + assert!(res.is_ok()); + } + + #[test] + fn source_runtime_remote_blocking_local_no_abre_ssh() { + // Para Source::Local cae a `discover_runtime` (sin SSH). En CI sin + // docker retorna un runtime vacío, pero Ok. + let res = source_runtime_remote_blocking(&Source::Local); + assert!(res.is_ok()); + } + + #[test] + fn fleet_quiet_actualiza_sin_loguear() { + use matilda_discover::RuntimeState; + let mut s = State::new(Source::Local); + let log_before = s.log.len(); + let rt = RuntimeState { containers: vec![], services: vec![], vhosts: vec![] }; + s = update(s, Msg::SetHostRuntimeQuiet { host: "edge-1".into(), runtime: rt }); + assert!(matches!(s.fleet.get("edge-1"), Some(FleetEntry::Ready(_)))); + // El polling no debe agregar nada al log. + assert_eq!(s.log.len(), log_before); + s = update(s, Msg::SetHostErrorQuiet { host: "edge-1".into(), error: "timeout".into() }); + assert!(matches!(s.fleet.get("edge-1"), Some(FleetEntry::Failed(_)))); + assert_eq!(s.log.len(), log_before); + } + + #[test] + fn fleet_action_done_loguea_y_refresca_el_host() { + use matilda_discover::RuntimeState; + let mut s = State::new(Source::Local); + // Sin runtime → sólo loguea, no toca el FleetEntry. + s = update(s, Msg::FleetActionDone { + host: "edge-1".into(), + lines: vec!["$ docker logs web".into(), "✔ Logs web en edge-1 (remoto)".into()], + runtime: None, + }); + assert!(s.log.iter().any(|l| l.contains("Logs web en edge-1"))); + assert!(s.fleet.get("edge-1").is_none()); + // Con runtime (acción mutante re-observada) → el host queda Ready. + let rt = RuntimeState { containers: vec![], services: vec![], vhosts: vec![] }; + s = update(s, Msg::FleetActionDone { + host: "edge-1".into(), + lines: vec!["✔ Restart web en edge-1 (remoto)".into()], + runtime: Some(rt), + }); + assert!(matches!(s.fleet.get("edge-1"), Some(FleetEntry::Ready(_)))); } #[test] @@ -1119,10 +1494,10 @@ mod tests { #[test] fn monitor_sampler_reflects_pending_steps() { let mut s = State::new(Source::Local); - s = update(s, Msg::MakePlan); // 4 pendientes + s = update(s, Msg::MakePlan); // 5 pendientes (host+2 cont+vhost+service) let c = contributions(&s); let sample = (c.monitors[0].sampler)(); - assert_eq!(sample.value, 4.0); - assert_eq!(sample.display, "4 pendientes"); + assert_eq!(sample.value, 5.0); + assert_eq!(sample.display, "5 pendientes"); } } diff --git a/02_ruway/shuma/sandbox/shuma-module-matilda/src/remote.rs b/02_ruway/shuma/sandbox/shuma-module-matilda/src/remote.rs new file mode 100644 index 0000000..613821f --- /dev/null +++ b/02_ruway/shuma/sandbox/shuma-module-matilda/src/remote.rs @@ -0,0 +1,596 @@ +//! Helpers bloqueantes de descubrimiento/apply remoto (SSH) y ejecución +//! local de comandos para `shuma-module-matilda`. Extraídos de `lib.rs` +//! (split por responsabilidad, sin cambio de comportamiento). El chasis +//! los corre en threads y reenvía los resultados por `Msg`. + +use super::*; + +/// M5 — fetch del runtime de un host de la flota por SSH. **Bloqueante**: +/// conecta por SSH (usuario/puerto del `Host`, clave default) y corre +/// `docker ps` + `systemctl` + `ls sites-enabled`, parseando con los mismos +/// parsers del discover local. Pensado para que el chasis lo corra en un +/// thread por host y reenvíe `Msg::SetHostRuntime`/`SetHostError`. +pub fn host_runtime_remote_blocking(host: &Host) -> Result { + let config = ssh_config_for_host(host); + let rt = blocking_runtime()?; + rt.block_on(async move { + let linker = Linker::connect(&config) + .await + .map_err(|e| format!("ssh connect: {e}"))?; + fetch_remote_runtime(&linker).await + }) +} + +/// Corre las consultas de runtime (`docker ps` + `systemctl` + `ls +/// sites-enabled`) sobre un `Linker` ya conectado y arma el `RuntimeState` +/// con los mismos parsers del discover local. Compartido por el fetch de un +/// host de la flota (M5) y el polling del Source montado remoto (M4). +async fn fetch_remote_runtime(linker: &Linker) -> Result { + let ps = linker + .exec(&format!( + "docker ps -a --format '{}' 2>/dev/null || true", + matilda_discover::DOCKER_PS_FORMAT + )) + .await + .map_err(|e| format!("docker ps: {e}"))?; + let svc = linker + .exec( + "systemctl list-units --type=service --state=running,failed \ + --no-legend --plain 2>/dev/null || true", + ) + .await + .map_err(|e| format!("systemctl: {e}"))?; + let nginx = linker + .exec("ls -1 /etc/nginx/sites-enabled 2>/dev/null || true") + .await + .map_err(|e| format!("ls sites-enabled: {e}"))?; + Ok(RuntimeState { + containers: matilda_discover::parse_docker_ps(&ps), + services: matilda_discover::parse_systemctl_units(&svc), + vhosts: matilda_discover::parse_nginx_sites(&nginx), + }) +} + +/// M2 — re-observa el uso CPU/mem de los contenedores (`docker stats +/// --no-stream`) del Source montado. **Bloqueante** (~1-2 s): el chasis lo +/// corre en un thread y reenvía `Msg::SetStatsQuiet`. Local → `discover_stats`; +/// remoto → SSH. Vacío si docker no está (no es error). +pub fn source_stats_remote_blocking( + source: &Source, +) -> Result, String> { + match source { + Source::Local | Source::Daemon { .. } | Source::DaemonTcp { .. } | Source::Container { .. } => { + Ok(matilda_discover::discover_stats()) + } + Source::Remote { .. } | Source::RemoteContainer { .. } => { + let config = ssh_config_for(source)?; + let rt = blocking_runtime()?; + rt.block_on(async move { + let linker = Linker::connect(&config) + .await + .map_err(|e| format!("ssh connect: {e}"))?; + let text = linker + .exec(&format!( + "docker stats --no-stream --format '{}' 2>/dev/null || true", + matilda_discover::DOCKER_STATS_FORMAT + )) + .await + .map_err(|e| format!("docker stats: {e}"))?; + Ok(matilda_discover::parse_docker_stats(&text)) + }) + } + } +} + +/// M4 — re-observa el runtime del **Source montado** cuando es remoto (lo que +/// `poll_runtime` hace local). **Bloqueante**: el chasis lo corre en un thread +/// a cadencia lenta y reenvía `Msg::SetRuntimeQuiet`. Para Source local cae a +/// `discover_runtime` por uniformidad (sin abrir SSH). +pub fn source_runtime_remote_blocking(source: &Source) -> Result { + match source { + Source::Local | Source::Daemon { .. } | Source::DaemonTcp { .. } | Source::Container { .. } => { + Ok(discover_runtime()) + } + Source::Remote { .. } | Source::RemoteContainer { .. } => { + let config = ssh_config_for(source)?; + let rt = blocking_runtime()?; + rt.block_on(async move { + let linker = Linker::connect(&config) + .await + .map_err(|e| format!("ssh connect: {e}"))?; + fetch_remote_runtime(&linker).await + }) + } + } +} + +/// M2 — streamea `docker logs -f` de `name` línea por línea a `on_line`, hasta +/// que `stop` se ponga en true o el proceso termine. **Bloqueante**: corre en +/// un thread del chasis. Local = subproceso `sh -c`; remoto = canal SSH +/// incremental (`Linker::exec_streaming`). El corte remoto es responsivo aún +/// con el stream inactivo (poll de 400 ms); el local corta en la próxima línea +/// o al terminar el proceso (luego lo mata). +pub fn stream_logs_blocking( + source: &Source, + name: &str, + tail: usize, + stop: &AtomicBool, + mut on_line: impl FnMut(String), +) -> Result<(), String> { + let cmd = format!("docker logs -f --tail {tail} {name} 2>&1"); + match source { + Source::Local | Source::Daemon { .. } | Source::DaemonTcp { .. } | Source::Container { .. } => { + use std::io::{BufRead, BufReader}; + use std::process::{Command, Stdio}; + let mut child = Command::new("sh") + .arg("-c") + .arg(&cmd) + .stdout(Stdio::piped()) + .stderr(Stdio::null()) + .spawn() + .map_err(|e| format!("spawn docker logs: {e}"))?; + let stdout = child.stdout.take().ok_or("sin stdout del subproceso")?; + for line in BufReader::new(stdout).lines() { + if stop.load(Ordering::Relaxed) { + break; + } + match line { + Ok(l) => on_line(l), + Err(_) => break, + } + } + let _ = child.kill(); + let _ = child.wait(); + Ok(()) + } + Source::Remote { .. } | Source::RemoteContainer { .. } => { + let config = ssh_config_for(source)?; + let rt = blocking_runtime()?; + let mut splitter = LineSplitter::default(); + rt.block_on(async move { + let linker = Linker::connect(&config) + .await + .map_err(|e| format!("ssh connect: {e}"))?; + linker + .exec_streaming( + &cmd, + std::time::Duration::from_millis(400), + |data| splitter.push(data, &mut on_line), + || stop.load(Ordering::Relaxed), + ) + .await + .map_err(|e| format!("docker logs -f: {e}")) + }) + } + } +} + +/// Config SSH para un `Host` de la flota: clave default del usuario + +/// usuario/puerto declarados en el inventario. Espeja la conexión de +/// `host_runtime_remote_blocking` para que acción y discovery usen el mismo +/// criterio de credenciales. +fn ssh_config_for_host(host: &Host) -> SshConfig { + let auth = SshAuth::Key { path: default_ssh_key(), passphrase: None }; + let mut config = SshConfig::new(host.address.as_str(), host.ssh_user(), auth); + config.port = host.ssh_port(); + config +} + +/// M5 — corre un comando de acción contra un host de la flota por SSH. +/// **Bloqueante**: pensado para que el chasis lo corra en un thread y reenvíe +/// las líneas por `Msg::LogLine`. Devuelve `(éxito, líneas)` — el éxito sale +/// del exit code real del comando remoto (no de la conexión). +fn host_action_blocking(host: &Host, cmd: &str, label: &str, name: &str) -> (bool, Vec) { + let config = ssh_config_for_host(host); + let rt = match blocking_runtime() { + Ok(rt) => rt, + Err(e) => return (false, vec![format!("✘ runtime: {e}")]), + }; + // `; echo __rc:$?` deja el exit code del comando en la última línea para + // distinguir "conectó pero el comando falló" de "no conectó". + let probe = format!("{cmd} 2>&1; echo __rc:$?"); + let result: Result = rt.block_on(async move { + let linker = Linker::connect(&config) + .await + .map_err(|e| format!("ssh connect: {e}"))?; + linker.exec(&probe).await.map_err(|e| format!("{cmd}: {e}")) + }); + match result { + Ok(text) => { + let mut ok = true; + let mut lines = vec![format!("$ {cmd}")]; + for l in text.lines() { + if let Some(rc) = l.strip_prefix("__rc:") { + ok = rc.trim() == "0"; + } else { + lines.push(l.to_string()); + } + } + // Cap defensivo: un `docker logs` largo no debe inundar el log. + lines.truncate(31); + lines.push(if ok { + format!("✔ {label} {name} en {} (remoto)", host.name) + } else { + format!("✘ {label} {name} en {} falló", host.name) + }); + (ok, lines) + } + Err(e) => (false, vec![format!("✘ {label} {name} en {}: {e}", host.name)]), + } +} + +/// M5 — acción de ciclo de vida sobre un contenedor de un host de la flota. +pub fn fleet_container_action_blocking( + host: &Host, + name: &str, + action: ContainerAction, +) -> (bool, Vec) { + host_action_blocking(host, &action.command(name), action.label(), name) +} + +/// M5 — acción sobre un servicio systemd de un host de la flota. +pub fn fleet_service_action_blocking( + host: &Host, + name: &str, + action: ServiceAction, +) -> (bool, Vec) { + host_action_blocking(host, &action.command(name), action.label(), name) +} + +/// Ejecuta un comando de shell local y captura stdout+stderr como líneas. +/// Devuelve `(éxito, líneas)`. Usado por las acciones de ciclo de vida de +/// contenedores (`docker start/stop/…`); el remoto va por `Linker`. +pub(crate) fn run_shell_capture(cmd: &str) -> (bool, Vec) { + match std::process::Command::new("sh").arg("-c").arg(cmd).output() { + Ok(out) => { + let mut lines: Vec = Vec::new(); + for l in String::from_utf8_lossy(&out.stdout).lines() { + lines.push(l.to_string()); + } + for l in String::from_utf8_lossy(&out.stderr).lines() { + lines.push(l.to_string()); + } + (out.status.success(), lines) + } + Err(e) => (false, vec![format!("no se pudo ejecutar: {e}")]), + } +} + +/// Ejecuta una acción de ciclo de vida en el servidor remoto por SSH. +/// **Bloqueante** — pensado para que el chasis lo corra en un thread y +/// reenvíe las líneas por `Msg::LogLine`. Devuelve las líneas del log. +pub fn container_action_remote_blocking( + source: &Source, + name: &str, + action: ContainerAction, +) -> Result, String> { + let cmd = action.command(name); + match source { + Source::Local | Source::Daemon { .. } | Source::DaemonTcp { .. } | Source::Container { .. } => { + let (ok, mut out) = run_shell_capture(&cmd); + out.insert(0, format!("$ {cmd}")); + out.push(if ok { format!("✔ {} {name}", action.label()) } else { format!("✘ {} {name} falló", action.label()) }); + Ok(out) + } + Source::Remote { .. } | Source::RemoteContainer { .. } => { + let config = ssh_config_for(source)?; + let rt = blocking_runtime()?; + rt.block_on(async move { + let linker = Linker::connect(&config) + .await + .map_err(|e| format!("ssh connect: {e}"))?; + let text = linker + .exec(&format!("{cmd} 2>&1")) + .await + .map_err(|e| format!("{cmd}: {e}"))?; + let mut lines = vec![format!("$ {cmd}")]; + lines.extend(text.lines().take(30).map(str::to_string)); + lines.push(format!("✔ {} {name} (remoto)", action.label())); + Ok(lines) + }) + } + } +} + +/// Ejecuta una acción sobre el Source montado, dado el comando ya armado y +/// su etiqueta. **Bloqueante** — el chasis lo corre en un thread y vuelca las +/// líneas por `Msg::LogLines`. Generaliza el path de servicios (que no tiene +/// un enum-con-`command()` propio para el Source remoto como contenedores). +pub fn service_action_remote_blocking( + source: &Source, + cmd: &str, + label: &str, + name: &str, +) -> Result, String> { + match source { + Source::Local | Source::Daemon { .. } | Source::DaemonTcp { .. } | Source::Container { .. } => { + let (ok, mut out) = run_shell_capture(cmd); + out.insert(0, format!("$ {cmd}")); + out.push(if ok { format!("✔ {label} {name}") } else { format!("✘ {label} {name} falló") }); + Ok(out) + } + Source::Remote { .. } | Source::RemoteContainer { .. } => { + let config = ssh_config_for(source)?; + let rt = blocking_runtime()?; + let cmd = cmd.to_string(); + rt.block_on(async move { + let linker = Linker::connect(&config) + .await + .map_err(|e| format!("ssh connect: {e}"))?; + let text = linker + .exec(&format!("{cmd} 2>&1")) + .await + .map_err(|e| format!("{cmd}: {e}"))?; + let mut lines = vec![format!("$ {cmd}")]; + lines.extend(text.lines().take(30).map(str::to_string)); + lines.push(format!("✔ {label} {name} (remoto)")); + Ok(lines) + }) + } + } +} + +// ─── Discover y dry-run remotos ───────────────────────────────────── + +/// Re-observa el estado runtime local (`docker ps` + `systemctl`). El +/// chasis lo llama en un thread a cadencia lenta (M4 — polling) y reenvía +/// el resultado por `Msg::SetRuntime`. Es lo más barato del discover (no +/// corre `docker inspect` por contenedor como `discover_inventory`). +pub fn poll_runtime() -> RuntimeState { + discover_runtime() +} + +/// Ruta default de la clave SSH del usuario; coincide con el matilda CLI. +fn default_ssh_key() -> PathBuf { + let home = std::env::var("HOME").unwrap_or_else(|_| "/root".into()); + PathBuf::from(format!("{home}/.ssh/id_ed25519")) +} + +/// Descubre el inventario actual del servidor remoto. **Bloqueante**: +/// crea un runtime tokio efímero, conecta por SSH y corre +/// `docker ps -a --format '{{.Names}}'` + `ls /etc/nginx/sites-enabled`. +/// Pensado para que el chasis lo invoque dentro de `Handle::spawn` +/// (un thread aparte) — no llamar desde el hilo de UI. +/// +/// Para Source::Local fallback a `discover_inventory` (no necesita +/// SSH, pero usa el mismo entrypoint para uniformidad). +pub fn discover_remote_blocking(source: &Source, desired: &Inventory) -> Result { + match source { + Source::Local | Source::Daemon { .. } | Source::DaemonTcp { .. } | Source::Container { .. } => { + Ok(discover_inventory(desired)) + } + Source::Remote { .. } | Source::RemoteContainer { .. } => { + let config = ssh_config_for(source)?; + let rt = blocking_runtime()?; + rt.block_on(async move { + let linker = Linker::connect(&config) + .await + .map_err(|e| format!("ssh connect: {e}"))?; + fetch_remote_inventory(&linker, desired).await + }) + } + } +} + +/// Equivalente remoto de `Msg::DryRun`: conecta por SSH, descubre el +/// inventory actual, calcula el plan deseado-vs-actual y enumera los +/// pasos que SE EJECUTARÍAN — sin invocar ninguno. Útil para validar +/// que el `Source::Remote` está bien configurado y previsualizar el +/// cambio antes de un eventual Apply real (fuera de scope aquí). +/// +/// Devuelve un `Vec` con líneas listas para insertar al log +/// (incluyendo el reporte de dry-run de cada paso). El chasis las +/// envuelve en `Msg::DryRunReport`. +pub fn dry_run_remote_blocking( + source: &Source, + desired: &Inventory, +) -> Result, String> { + let mut lines = Vec::new(); + + let current = match source { + Source::Local | Source::Daemon { .. } | Source::DaemonTcp { .. } | Source::Container { .. } => { + discover_inventory(desired) + } + Source::Remote { .. } | Source::RemoteContainer { .. } => { + let config = ssh_config_for(source)?; + let rt = blocking_runtime()?; + rt.block_on(async move { + let linker = Linker::connect(&config) + .await + .map_err(|e| format!("ssh connect: {e}"))?; + fetch_remote_inventory(&linker, desired).await + })? + } + }; + lines.push(format!( + "✔ current: {} containers, {} vhosts", + current.containers().count(), + current.vhosts().count() + )); + + let p = plan(¤t, desired); + if p.is_empty() { + lines.push("Sin cambios: el servidor ya está al día.".into()); + return Ok(lines); + } + lines.push(format!( + "plan: {} acciones ({} crear, {} actualizar, {} eliminar)", + p.len(), + p.count(Op::Create), + p.count(Op::Update), + p.count(Op::Remove) + )); + + let steps = plan_to_steps(&p, desired); + let report: ApplyReport = dry_run(&steps); + for r in &report.results { + lines.push(format!( + "{} {}", + if r.ok { "✔" } else { "✘" }, + r.describe + )); + for line in &r.log { + lines.push(format!(" {line}")); + } + } + Ok(lines) +} + +/// Aplica el plan deseado-vs-actual en el servidor remoto: conecta por +/// SSH, descubre el inventario, calcula el plan, ejecuta los pasos en +/// orden y re-descubre el estado final. **Bloqueante** — pensado para +/// que el chasis lo invoque dentro de `Handle::spawn` y reenvíe el +/// resultado por `Msg::ApplyReport`. +/// +/// Devuelve `(lines, new_current)`: el log textual y, si todos los +/// pasos completaron, el inventario re-observado (para resetear el +/// plan/pendientes del módulo). Si algún paso falla, `new_current` es +/// `None` — la UI conserva el plan vigente para que el operador vea +/// dónde se rompió. +pub fn apply_remote_blocking( + source: &Source, + desired: &Inventory, +) -> Result<(Vec, Option), String> { + let mut lines = Vec::new(); + + match source { + Source::Local | Source::Daemon { .. } | Source::DaemonTcp { .. } | Source::Container { .. } => { + // Local lo maneja `Msg::Apply` sincrónicamente. Para + // uniformidad damos un fallback síncrono sin tocar el UI. + let current = discover_inventory(desired); + let p = plan(¤t, desired); + if p.is_empty() { + lines.push("Sin cambios: nada que aplicar.".into()); + return Ok((lines, Some(current))); + } + let steps = plan_to_steps(&p, desired); + let report: ApplyReport = apply(&steps); + push_apply_log(&mut lines, &report); + let new_current = if report.all_ok() { + Some(discover_inventory(desired)) + } else { + None + }; + Ok((lines, new_current)) + } + Source::Remote { .. } | Source::RemoteContainer { .. } => { + let config = ssh_config_for(source)?; + let rt = blocking_runtime()?; + rt.block_on(async move { + let linker = Linker::connect(&config) + .await + .map_err(|e| format!("ssh connect: {e}"))?; + let current = fetch_remote_inventory(&linker, desired).await?; + lines.push(format!( + "✔ current: {} containers, {} vhosts", + current.containers().count(), + current.vhosts().count() + )); + let p = plan(¤t, desired); + if p.is_empty() { + lines.push("Sin cambios: el servidor ya está al día.".into()); + return Ok((lines, Some(current))); + } + lines.push(format!( + "plan: {} acciones ({} crear, {} actualizar, {} eliminar)", + p.len(), + p.count(Op::Create), + p.count(Op::Update), + p.count(Op::Remove) + )); + let steps = plan_to_steps(&p, desired); + lines.push(format!("— aplicando {} pasos por SSH —", steps.len())); + let report = linker.apply(&steps).await; + push_apply_log(&mut lines, &report); + let new_current = if report.all_ok() { + Some(fetch_remote_inventory(&linker, desired).await?) + } else { + None + }; + Ok((lines, new_current)) + }) + } + } +} + +fn push_apply_log(lines: &mut Vec, report: &ApplyReport) { + for r in &report.results { + lines.push(format!( + "{} {}", + if r.ok { "✔" } else { "✘" }, + r.describe + )); + for line in &r.log { + lines.push(format!(" {line}")); + } + } + lines.push(format!( + "{} de {} pasos aplicados.", + report.applied(), + report.results.len() + )); + if !report.all_ok() { + lines.push("✘ se detuvo en el primer error.".into()); + } +} + +fn ssh_config_for(source: &Source) -> Result { + match source { + Source::Remote { host, user, port, .. } + | Source::RemoteContainer { host, user, port, .. } => { + let auth = SshAuth::Key { + path: default_ssh_key(), + passphrase: None, + }; + let mut config = SshConfig::new(host.as_str(), user.as_str(), auth); + config.port = *port; + Ok(config) + } + Source::Local | Source::Daemon { .. } | Source::DaemonTcp { .. } | Source::Container { .. } => { + Err("ssh_config_for esperaba Source::Remote".into()) + } + } +} + +fn blocking_runtime() -> Result { + tokio::runtime::Builder::new_current_thread() + .enable_all() + .build() + .map_err(|e| format!("tokio runtime: {e}")) +} + +async fn fetch_remote_inventory( + linker: &Linker, + desired: &Inventory, +) -> Result { + let containers_text = linker + .exec("docker ps -a --format '{{.Names}}' 2>/dev/null || true") + .await + .map_err(|e| format!("docker ps: {e}"))?; + let vhosts_text = linker + .exec("ls -1 /etc/nginx/sites-enabled 2>/dev/null || true") + .await + .map_err(|e| format!("ls sites-enabled: {e}"))?; + // M3 — estado declarativo de los servicios por SSH: sondeamos + // `is-enabled`/`is-active` de TODAS las unidades declaradas en un solo + // round-trip (un loop shell), para que el plan emita Update si difieren + // del deseado en vez de un Create espurio. + let units: Vec<&str> = desired.services().map(|s| s.unit.as_str()).collect(); + let services = if units.is_empty() { + Vec::new() + } else { + let probe = matilda_discover::remote_service_probe_command(&units); + let services_text = linker + .exec(&format!("{probe} 2>/dev/null || true")) + .await + .map_err(|e| format!("systemctl is-enabled/active: {e}"))?; + matilda_discover::parse_service_states(&services_text) + }; + let state = ServerState { + containers: matilda_discover::parse_docker_names(&containers_text), + vhosts: matilda_discover::parse_nginx_sites(&vhosts_text), + services, + }; + Ok(observed_inventory(&state, desired)) +} + diff --git a/02_ruway/shuma/sandbox/shuma-module-matilda/src/render.rs b/02_ruway/shuma/sandbox/shuma-module-matilda/src/render.rs new file mode 100644 index 0000000..cf2a799 --- /dev/null +++ b/02_ruway/shuma/sandbox/shuma-module-matilda/src/render.rs @@ -0,0 +1,827 @@ +//! Construcción de la vista (`View`) del tab Matilda: header, +//! panel de inventario/flota, panel de plan+log y sus filas/barras de +//! acción. Extraído de `lib.rs` (split por responsabilidad). + +use super::*; + +pub fn view( + state: &State, + theme: &Theme, + lift: impl Fn(Msg) -> HostMsg + Clone + Send + Sync + 'static, +) -> View { + let header = matilda_header(state, theme); + + let inv_pane = inventory_pane(state, theme, lift.clone()); + let plan_pane = plan_and_log_pane(state, theme); + + let splitter_palette = SplitterPalette::from_theme(theme); + let lift_resize = lift.clone(); + let body = splitter_two( + Direction::Row, + inv_pane, + PaneSize::Fixed(state.split_width), + plan_pane, + PaneSize::Flex, + move |phase, dx| match phase { + DragPhase::Move => Some(lift_resize(Msg::ResizeSplit(dx))), + DragPhase::End => None, + }, + &splitter_palette, + ); + + View::new(Style { + flex_direction: FlexDirection::Column, + size: Size { + width: percent(1.0_f32), + height: percent(1.0_f32), + }, + ..Default::default() + }) + .fill(theme.bg_app) + .children(vec![header, body]) +} + +fn matilda_header(state: &State, theme: &Theme) -> View { + let label = format!( + "Matilda · {} · {} hosts · {} containers · {} vhosts", + state.source.label(), + state.desired.hosts().count(), + state.desired.containers().count(), + state.desired.vhosts().count(), + ); + View::new(Style { + size: Size { + width: percent(1.0_f32), + height: length(28.0_f32), + }, + padding: Rect { + left: length(14.0_f32), + right: length(14.0_f32), + top: length(0.0_f32), + bottom: length(0.0_f32), + }, + align_items: Some(AlignItems::Center), + ..Default::default() + }) + .fill(theme.bg_panel) + .text_aligned(label, 12.0, theme.fg_text, Alignment::Start) +} + +/// Panel izquierdo: el inventario en 3 secciones (hosts / containers / +/// vhosts). Las filas de contenedor son **clickeables**: seleccionan el +/// contenedor y abren la barra de acciones (start/stop/restart/logs/rm). +fn inventory_pane( + state: &State, + theme: &Theme, + lift: impl Fn(Msg) -> HostMsg + Clone + Send + Sync + 'static, +) -> View { + let mut children: Vec> = Vec::new(); + + // FLEET (M5) — los hosts declarados con su runtime por SSH. Cada host es + // clickeable: lo selecciona y expande sus contenedores/servicios. + children.push(section_label( + &format!("FLEET ({} hosts)", state.desired.hosts().count()), + theme, + )); + for h in state.desired.hosts() { + let entry = state.fleet.get(&h.name); + let sel = state.selected_host.as_deref() == Some(h.name.as_str()); + children.push(host_row(h, entry, sel, theme, lift.clone())); + // Expandido: el runtime del host (contenedores + servicios). Cada + // recurso es clickeable → abre su barra de acciones REMOTAS (M5): el + // chasis corre `docker`/`systemctl` por SSH contra ESTE host. + if sel { + if let Some(FleetEntry::Ready(rt)) = entry { + for c in &rt.containers { + let csel = state.selected_fleet_container.as_deref() == Some(c.name.as_str()); + children.push(fleet_resource_row( + c.state.glyph(), &c.name, &c.status, c.state.is_up(), csel, + Msg::SelectFleetContainer(c.name.clone()), theme, lift.clone(), + )); + if csel { + children.push(fleet_container_action_bar(&h.name, &c.name, theme, lift.clone())); + } + } + for svc in &rt.services { + use matilda_discover::ServiceState; + let ok = svc.state == ServiceState::Active; + let ssel = state.selected_fleet_service.as_deref() == Some(svc.name.as_str()); + children.push(fleet_resource_row( + svc.state.glyph(), &svc.name, &svc.sub, ok, ssel, + Msg::SelectFleetService(svc.name.clone()), theme, lift.clone(), + )); + if ssel { + children.push(fleet_service_action_bar(&h.name, &svc.name, theme, lift.clone())); + } + } + } + } + } + + // CONTAINERS — con estado runtime (●/○ + status) cuando hay discover. + let cont_label = match &state.runtime { + Some(rt) => format!( + "CONTAINERS ({}) · {} up · {} down", + state.desired.containers().count(), + rt.up_count(), + rt.down_count() + ), + None => format!( + "CONTAINERS ({}) · sin discover", + state.desired.containers().count() + ), + }; + children.push(section_label(&cont_label, theme)); + for c in state.desired.containers() { + let status = state.runtime.as_ref().and_then(|rt| rt.container(&c.name)); + // M6 — drift visible: el discover marca el contenedor desviado con + // imagen "(desviado)" en `current`. Lo mostramos como chip. + let drift = matches!( + state.current.as_ref().and_then(|inv| inv.container(&c.name)), + Some(cur) if cur.image == "(desviado)" + ); + children.push(container_row( + &c.name, + &c.image, + status, + drift, + state.selected_container.as_deref() == Some(c.name.as_str()), + theme, + lift.clone(), + )); + // Barra de acciones + sparkline CPU/mem bajo el seleccionado. + if state.selected_container.as_deref() == Some(c.name.as_str()) { + if let Some(spark) = cpu_mem_spark_row(state, &c.name, theme) { + children.push(spark); + } + children.push(container_action_bar(&c.name, theme, lift.clone())); + if let Some(card) = log_stream_card(state, &c.name, theme, lift.clone()) { + children.push(card); + } + } + } + // Huérfanos: contenedores que corren pero no están en el inventario + // deseado — el operador los ve y los opera sin ir a la terminal. + if let Some(rt) = &state.runtime { + for cs in &rt.containers { + if state.desired.container(&cs.name).is_none() { + let sel = state.selected_container.as_deref() == Some(cs.name.as_str()); + children.push(container_row( + &cs.name, &cs.image, Some(cs), false, sel, theme, lift.clone(), + )); + if sel { + if let Some(spark) = cpu_mem_spark_row(state, &cs.name, theme) { + children.push(spark); + } + children.push(container_action_bar(&cs.name, theme, lift.clone())); + if let Some(card) = log_stream_card(state, &cs.name, theme, lift.clone()) { + children.push(card); + } + } + } + } + } + + // SERVICES — systemd (running/failed), runtime puro + acciones. + if let Some(rt) = &state.runtime { + if !rt.services.is_empty() { + children.push(section_label( + &format!( + "SERVICES ({}) · {} activos · {} fallados", + rt.services.len(), + rt.services_active(), + rt.services_failed() + ), + theme, + )); + for svc in &rt.services { + let sel = state.selected_service.as_deref() == Some(svc.name.as_str()); + children.push(service_row(svc, sel, theme, lift.clone())); + if sel { + children.push(service_action_bar(&svc.name, theme, lift.clone())); + } + } + } + } + + // SERVICES declarados — los del inventario (deseados), con sus flags + // enable/active y si están corriendo ahora (cross-ref con el runtime). + // Es la paridad con contenedores/vhosts: el deseo se ve en el panel. + if state.desired.services().count() > 0 { + children.push(section_label( + &format!("SERVICES declarados ({})", state.desired.services().count()), + theme, + )); + for svc in state.desired.services() { + let corriendo = state + .runtime + .as_ref() + .map(|rt| rt.services.iter().any(|s| s.name == svc.unit && s.state.is_active())) + .unwrap_or(false); + let glyph = if corriendo { '●' } else { '◌' }; + let flags = match (svc.enabled, svc.active) { + (true, true) => "enable+start", + (true, false) => "enable", + (false, true) => "start", + (false, false) => "disable+stop", + }; + children.push(inv_row(&format!(" {glyph} {} [{flags}]", svc.unit), theme)); + } + } + + children.push(section_label( + &format!("VHOSTS ({})", state.desired.vhosts().count()), + theme, + )); + for v in state.desired.vhosts() { + children.push(inv_row( + &format!(" {} → {}", v.domain, describe_upstream(&v.upstream)), + theme, + )); + } + + View::new(Style { + flex_direction: FlexDirection::Column, + size: Size { + width: percent(1.0_f32), + height: percent(1.0_f32), + }, + padding: Rect { + left: length(10.0_f32), + right: length(10.0_f32), + top: length(8.0_f32), + bottom: length(8.0_f32), + }, + gap: Size { + width: length(0.0_f32), + height: length(2.0_f32), + }, + ..Default::default() + }) + .fill(theme.bg_panel) + .children(children) +} + +fn describe_upstream(u: &matilda_core::Upstream) -> String { + use matilda_core::Upstream::*; + match u { + Container { name, port } => format!("{name}:{port}"), + Address(addr) => addr.clone(), + } +} + +fn inv_row(text: &str, theme: &Theme) -> View { + text_row(text, theme.fg_text, theme) +} + +/// Fila de un host de la flota (M5): semáforo (● alcanzable / ◐ consultando +/// / ✖ error / ◌ sin consultar) + nombre + dirección + resumen up/down/svc +/// o el error. Clickeable → selecciona y expande su runtime. +fn host_row( + host: &Host, + entry: Option<&FleetEntry>, + selected: bool, + theme: &Theme, + lift: impl Fn(Msg) -> HostMsg + Clone + Send + Sync + 'static, +) -> View { + use llimphi_ui::llimphi_raster::peniko::Color; + let green = Color::from_rgba8(0x82, 0xCD, 0x8C, 0xFF); + let red = Color::from_rgba8(0xE0, 0x6C, 0x6C, 0xFF); + let (glyph, color, summary) = match entry { + None => ('◌', theme.fg_muted, "· sin consultar (pulsa «Fleet»)".to_string()), + Some(FleetEntry::Pending) => ('◐', theme.fg_muted, "· consultando…".to_string()), + Some(FleetEntry::Ready(rt)) => { + let c = if rt.down_count() == 0 && rt.services_failed() == 0 { green } else { red }; + ( + '●', + c, + format!( + "· {} up · {} down · {} svc", + rt.up_count(), + rt.down_count(), + rt.services.len() + ), + ) + } + Some(FleetEntry::Failed(e)) => { + let short: String = e.chars().take(40).collect(); + ('✖', red, format!("· ✘ {short}")) + } + }; + let prefix = if selected { "▸ " } else { " " }; + let mut row = View::new(Style { + size: Size { width: percent(1.0_f32), height: length(16.0_f32) }, + ..Default::default() + }) + .hover_fill(theme.bg_row_hover) + .on_click(lift(Msg::SelectHost(host.name.clone()))) + .text_aligned( + format!("{prefix}{glyph} {} {} {summary}", host.name, host.address), + 11.0, + color, + Alignment::Start, + ); + if selected { + row = row.fill(theme.bg_row_hover); + } + row +} + +/// Fila de un recurso dentro de un host expandido de la flota: glifo + +/// nombre + detalle, indentada. **Clickeable** (M5) → emite `select` para +/// abrir la barra de acciones remotas. El prefijo `▸` y el fondo marcan la +/// selección, igual que las filas del Source montado. +#[allow(clippy::too_many_arguments)] +fn fleet_resource_row( + glyph: char, + name: &str, + detail: &str, + ok: bool, + selected: bool, + select: Msg, + theme: &Theme, + lift: impl Fn(Msg) -> HostMsg + Clone + Send + Sync + 'static, +) -> View { + use llimphi_ui::llimphi_raster::peniko::Color; + let color = if ok { + Color::from_rgba8(0x82, 0xCD, 0x8C, 0xFF) + } else { + theme.fg_muted + }; + let tail = if detail.is_empty() { + String::new() + } else { + format!(" · {detail}") + }; + let prefix = if selected { " ▸ " } else { " " }; + let mut row = View::new(Style { + size: Size { width: percent(1.0_f32), height: length(16.0_f32) }, + ..Default::default() + }) + .hover_fill(theme.bg_row_hover) + .on_click(lift(select)) + .text_aligned( + format!("{prefix}{glyph} {name}{tail}"), + 11.0, + color, + Alignment::Start, + ); + if selected { + row = row.fill(theme.bg_row_hover); + } + row +} + +/// Barra de acciones remotas para un contenedor de un host de la flota (M5). +/// Idéntica a `container_action_bar` salvo que el click emite +/// `FleetContainerAction { host, … }` — el chasis corre el `docker` por SSH +/// contra `host` y re-observa su runtime. +fn fleet_container_action_bar( + host: &str, + name: &str, + theme: &Theme, + lift: impl Fn(Msg) -> HostMsg + Clone + Send + Sync + 'static, +) -> View { + use llimphi_ui::llimphi_raster::peniko::Color; + let mut buttons: Vec> = Vec::new(); + for action in ContainerAction::all() { + let color = if matches!(action, ContainerAction::Remove) { + Color::from_rgba8(0xE0, 0x6C, 0x6C, 0xFF) + } else { + theme.accent + }; + buttons.push( + View::new(Style { + size: Size { width: length(54.0_f32), height: length(18.0_f32) }, + align_items: Some(AlignItems::Center), + ..Default::default() + }) + .hover_fill(theme.bg_row_hover) + .on_click(lift(Msg::FleetContainerAction { + host: host.to_string(), + name: name.to_string(), + action, + })) + .text_aligned(action.label().to_string(), 11.0, color, Alignment::Start), + ); + } + fleet_action_bar_frame(buttons) +} + +/// Barra de acciones remotas para un servicio systemd de un host de la flota. +fn fleet_service_action_bar( + host: &str, + name: &str, + theme: &Theme, + lift: impl Fn(Msg) -> HostMsg + Clone + Send + Sync + 'static, +) -> View { + use llimphi_ui::llimphi_raster::peniko::Color; + let mut buttons: Vec> = Vec::new(); + for action in ServiceAction::all() { + let color = if matches!(action, ServiceAction::Stop | ServiceAction::Disable) { + Color::from_rgba8(0xE0, 0x6C, 0x6C, 0xFF) + } else { + theme.accent + }; + buttons.push( + View::new(Style { + size: Size { width: length(60.0_f32), height: length(18.0_f32) }, + align_items: Some(AlignItems::Center), + ..Default::default() + }) + .hover_fill(theme.bg_row_hover) + .on_click(lift(Msg::FleetServiceAction { + host: host.to_string(), + name: name.to_string(), + action, + })) + .text_aligned(action.label().to_string(), 11.0, color, Alignment::Start), + ); + } + fleet_action_bar_frame(buttons) +} + +/// Marco común de las barras de acción de la flota: fila con sangría extra +/// (los recursos de flota ya van indentados) y el mismo gap/padding que las +/// barras del Source montado. +fn fleet_action_bar_frame( + buttons: Vec>, +) -> View { + View::new(Style { + flex_direction: FlexDirection::Row, + size: Size { width: percent(1.0_f32), height: length(20.0_f32) }, + padding: Rect { + left: length(40.0_f32), + right: length(0.0_f32), + top: length(0.0_f32), + bottom: length(2.0_f32), + }, + gap: Size { width: length(4.0_f32), height: length(0.0_f32) }, + ..Default::default() + }) + .children(buttons) +} + +/// Fila de contenedor con semáforo runtime, clickeable. Sin estado +/// observado pinta `◌` tenue; con estado, el glifo coloreado (verde vivo / +/// tenue parado) + el `status` de Docker. `drift` agrega un chip ⚠; el +/// click selecciona el contenedor (abre la barra de acciones). +#[allow(clippy::too_many_arguments)] +fn container_row( + name: &str, + image: &str, + status: Option<&matilda_discover::ContainerStatus>, + drift: bool, + selected: bool, + theme: &Theme, + lift: impl Fn(Msg) -> HostMsg + Clone + Send + Sync + 'static, +) -> View { + use llimphi_ui::llimphi_raster::peniko::Color; + let (glyph, color, tail) = match status { + Some(cs) => { + let color = if cs.state.is_up() { + Color::from_rgba8(0x82, 0xCD, 0x8C, 0xFF) // verde vivo + } else { + theme.fg_muted + }; + (cs.state.glyph(), color, format!(" · {}", cs.status)) + } + None => ('◌', theme.fg_muted, String::new()), + }; + let prefix = if selected { "▸ " } else { " " }; + let drift_chip = if drift { " ⚠ drift" } else { "" }; + let mut row = View::new(Style { + size: Size { width: percent(1.0_f32), height: length(16.0_f32) }, + ..Default::default() + }) + .hover_fill(theme.bg_row_hover) + .on_click(lift(Msg::SelectContainer(name.to_string()))) + .text_aligned( + format!("{prefix}{glyph} {name} {image}{tail}{drift_chip}"), + 11.0, + color, + Alignment::Start, + ); + if selected { + row = row.fill(theme.bg_row_hover); + } + row +} + +/// Fila CPU/mem (M2) del contenedor seleccionado: lectura actual + sparkline +/// del histórico CPU. `None` si todavía no hay muestras (el polling no corrió +/// o el contenedor está parado y `docker stats` no lo lista). Tinte del accent +/// del tema; texto monoespaciado para que las barras se alineen. +fn cpu_mem_spark_row( + state: &State, + name: &str, + theme: &Theme, +) -> Option> { + let spark = state.cpu_sparkline(name)?; + let last = state.last_stats(name)?; + let text = format!( + " CPU {:>5.1}% {spark} MEM {:>5.1}%", + last.cpu_pct, last.mem_pct + ); + Some(text_row(&text, theme.accent, theme)) +} + +/// Barra de acciones para el contenedor seleccionado: un botón por +/// `ContainerAction` (Start/Stop/Restart/Logs/Remove). +fn container_action_bar( + name: &str, + theme: &Theme, + lift: impl Fn(Msg) -> HostMsg + Clone + Send + Sync + 'static, +) -> View { + use llimphi_ui::llimphi_raster::peniko::Color; + let mut buttons: Vec> = Vec::new(); + for action in ContainerAction::all() { + // Remove en rojo tenue (es destructivo); el resto en accent. + let color = if matches!(action, ContainerAction::Remove) { + Color::from_rgba8(0xE0, 0x6C, 0x6C, 0xFF) + } else { + theme.accent + }; + buttons.push( + View::new(Style { + size: Size { width: length(54.0_f32), height: length(18.0_f32) }, + align_items: Some(AlignItems::Center), + ..Default::default() + }) + .hover_fill(theme.bg_row_hover) + .on_click(lift(Msg::ContainerActionMsg { + name: name.to_string(), + action, + })) + .text_aligned(action.label().to_string(), 11.0, color, Alignment::Start), + ); + } + // M2 — "Tail ▶": live-tail (`docker logs -f`), distinto del `Logs` + // snapshot. Arranca el stream a una card bajo el contenedor. + buttons.push( + View::new(Style { + size: Size { width: length(54.0_f32), height: length(18.0_f32) }, + align_items: Some(AlignItems::Center), + ..Default::default() + }) + .hover_fill(theme.bg_row_hover) + .on_click(lift(Msg::StartLogStream(name.to_string()))) + .text_aligned("Tail ▶".to_string(), 11.0, theme.accent, Alignment::Start), + ); + View::new(Style { + flex_direction: FlexDirection::Row, + size: Size { width: percent(1.0_f32), height: length(20.0_f32) }, + padding: Rect { + left: length(16.0_f32), + right: length(0.0_f32), + top: length(0.0_f32), + bottom: length(2.0_f32), + }, + gap: Size { width: length(4.0_f32), height: length(0.0_f32) }, + ..Default::default() + }) + .children(buttons) +} + +/// M2 — card del live-tail bajo el contenedor `name`, si hay un stream activo +/// PARA ese contenedor. Header `▶ logs: name [Stop]` (Stop clickeable, alza la +/// bandera) + las últimas ~12 líneas en monospace tenue. `None` si no hay +/// stream o es de otro contenedor. +fn log_stream_card( + state: &State, + name: &str, + theme: &Theme, + lift: impl Fn(Msg) -> HostMsg + Clone + Send + Sync + 'static, +) -> Option> { + let ls = state.log_stream.as_ref()?; + if ls.container != name { + return None; + } + use llimphi_ui::llimphi_raster::peniko::Color; + let red = Color::from_rgba8(0xE0, 0x6C, 0x6C, 0xFF); + let estado = if ls.ended { "cerrado" } else { "en vivo" }; + // Header: título + botón Stop. + let header = View::new(Style { + flex_direction: FlexDirection::Row, + size: Size { width: percent(1.0_f32), height: length(18.0_f32) }, + padding: Rect { + left: length(16.0_f32), + right: length(8.0_f32), + top: length(2.0_f32), + bottom: length(0.0_f32), + }, + justify_content: Some(JustifyContent::SpaceBetween), + ..Default::default() + }) + .children(vec![ + View::new(Style::default()).text_aligned( + format!("▶ logs: {name} ({estado}, {} líneas)", ls.lines.len()), + 11.0, + theme.accent, + Alignment::Start, + ), + View::new(Style { + size: Size { width: length(48.0_f32), height: length(16.0_f32) }, + align_items: Some(AlignItems::Center), + ..Default::default() + }) + .hover_fill(theme.bg_row_hover) + .on_click(lift(Msg::StopLogStream)) + .text_aligned("Stop ⏹".to_string(), 11.0, red, Alignment::Start), + ]); + // Cuerpo: las últimas líneas (las viejas ya están capadas en el buffer). + const VISIBLE: usize = 12; + let mut rows: Vec> = vec![header]; + let start = ls.lines.len().saturating_sub(VISIBLE); + for line in ls.lines.iter().skip(start) { + rows.push( + View::new(Style { + size: Size { width: percent(1.0_f32), height: length(14.0_f32) }, + padding: Rect { + left: length(20.0_f32), + right: length(0.0_f32), + top: length(0.0_f32), + bottom: length(0.0_f32), + }, + ..Default::default() + }) + .text_aligned(line.clone(), 10.5, theme.fg_muted, Alignment::Start), + ); + } + Some( + View::new(Style { + flex_direction: FlexDirection::Column, + size: Size { width: percent(1.0_f32), height: auto() }, + padding: Rect { + left: length(0.0_f32), + right: length(0.0_f32), + top: length(0.0_f32), + bottom: length(4.0_f32), + }, + ..Default::default() + }) + .fill(theme.bg_panel) + .children(rows), + ) +} + +/// Fila de servicio systemd con semáforo (●/✖/○) + `sub` + descripción, +/// clickeable para abrir su barra de acciones. +fn service_row( + svc: &matilda_discover::ServiceStatus, + selected: bool, + theme: &Theme, + lift: impl Fn(Msg) -> HostMsg + Clone + Send + Sync + 'static, +) -> View { + use llimphi_ui::llimphi_raster::peniko::Color; + use matilda_discover::ServiceState; + let color = match svc.state { + ServiceState::Active | ServiceState::Activating => Color::from_rgba8(0x82, 0xCD, 0x8C, 0xFF), + ServiceState::Failed => Color::from_rgba8(0xE0, 0x6C, 0x6C, 0xFF), + _ => theme.fg_muted, + }; + let prefix = if selected { "▸ " } else { " " }; + let desc = if svc.description.is_empty() { + String::new() + } else { + format!(" · {}", svc.description) + }; + let mut row = View::new(Style { + size: Size { width: percent(1.0_f32), height: length(16.0_f32) }, + ..Default::default() + }) + .hover_fill(theme.bg_row_hover) + .on_click(lift(Msg::SelectService(svc.name.clone()))) + .text_aligned( + format!("{prefix}{} {} ({}){desc}", svc.state.glyph(), svc.name, svc.sub), + 11.0, + color, + Alignment::Start, + ); + if selected { + row = row.fill(theme.bg_row_hover); + } + row +} + +/// Barra de acciones del servicio seleccionado. +fn service_action_bar( + name: &str, + theme: &Theme, + lift: impl Fn(Msg) -> HostMsg + Clone + Send + Sync + 'static, +) -> View { + use llimphi_ui::llimphi_raster::peniko::Color; + let mut buttons: Vec> = Vec::new(); + for action in ServiceAction::all() { + let color = if matches!(action, ServiceAction::Stop | ServiceAction::Disable) { + Color::from_rgba8(0xE0, 0x6C, 0x6C, 0xFF) + } else { + theme.accent + }; + buttons.push( + View::new(Style { + size: Size { width: length(60.0_f32), height: length(18.0_f32) }, + align_items: Some(AlignItems::Center), + ..Default::default() + }) + .hover_fill(theme.bg_row_hover) + .on_click(lift(Msg::ServiceActionMsg { + name: name.to_string(), + action, + })) + .text_aligned(action.label().to_string(), 11.0, color, Alignment::Start), + ); + } + View::new(Style { + flex_direction: FlexDirection::Row, + size: Size { width: percent(1.0_f32), height: length(20.0_f32) }, + padding: Rect { + left: length(16.0_f32), + right: length(0.0_f32), + top: length(0.0_f32), + bottom: length(2.0_f32), + }, + gap: Size { width: length(4.0_f32), height: length(0.0_f32) }, + ..Default::default() + }) + .children(buttons) +} + +fn plan_and_log_pane(state: &State, theme: &Theme) -> View { + let plan_label = match &state.plan { + Some(p) if p.is_empty() => "Plan · sin cambios".to_string(), + Some(p) => format!("Plan · {} acciones", p.len()), + None => "Plan · sin calcular (pulsa «Plan» en la toolbar)".to_string(), + }; + + let plan_header = section_label(&plan_label, theme); + + let mut plan_children: Vec> = vec![plan_header]; + if let Some(p) = &state.plan { + for (i, action) in p.actions.iter().enumerate() { + plan_children.push(text_row( + &format!("{:>2}. {}", i + 1, action.describe()), + theme.fg_text, + theme, + )); + } + } + + plan_children.push(section_label("Log", theme)); + for line in state.log.iter().rev().take(40).rev() { + plan_children.push(text_row(line, theme.fg_muted, theme)); + } + + View::new(Style { + flex_direction: FlexDirection::Column, + size: Size { + width: percent(1.0_f32), + height: percent(1.0_f32), + }, + padding: Rect { + left: length(10.0_f32), + right: length(10.0_f32), + top: length(8.0_f32), + bottom: length(8.0_f32), + }, + gap: Size { + width: length(0.0_f32), + height: length(2.0_f32), + }, + ..Default::default() + }) + .fill(theme.bg_app) + .children(plan_children) +} + +fn section_label(text: &str, theme: &Theme) -> View { + View::new(Style { + size: Size { + width: percent(1.0_f32), + height: length(18.0_f32), + }, + margin: Rect { + left: length(0.0_f32), + right: length(0.0_f32), + top: length(6.0_f32), + bottom: length(2.0_f32), + }, + ..Default::default() + }) + .text_aligned(text.to_string(), 11.0, theme.accent, Alignment::Start) +} + +fn text_row( + text: &str, + color: llimphi_ui::llimphi_raster::peniko::Color, + _theme: &Theme, +) -> View { + View::new(Style { + size: Size { + width: percent(1.0_f32), + height: length(16.0_f32), + }, + ..Default::default() + }) + .text_aligned(text.to_string(), 11.0, color, Alignment::Start) +} diff --git a/02_ruway/shuma/sandbox/shuma-module-minga/src/lib.rs b/02_ruway/shuma/sandbox/shuma-module-minga/src/lib.rs index cf76084..2dacc15 100644 --- a/02_ruway/shuma/sandbox/shuma-module-minga/src/lib.rs +++ b/02_ruway/shuma/sandbox/shuma-module-minga/src/lib.rs @@ -355,7 +355,7 @@ pub fn view( } if snap.recent.is_empty() { children.push(text_row( - "(sin raíces — corré `minga ingest`)", + "(sin raíces — corre `minga ingest`)", theme.fg_muted, theme, )); diff --git a/02_ruway/shuma/sandbox/shuma-module-shell/Cargo.toml b/02_ruway/shuma/sandbox/shuma-module-shell/Cargo.toml index 38150c2..509e042 100644 --- a/02_ruway/shuma/sandbox/shuma-module-shell/Cargo.toml +++ b/02_ruway/shuma/sandbox/shuma-module-shell/Cargo.toml @@ -8,22 +8,61 @@ publish.workspace = true description = "shuma-module-shell — el shell interactivo (input + runs + historial) como un módulo enchufable a `shuma-shell-llimphi`. Vista placeholder por ahora; la migración del REPL GPUI llega aparte." [dependencies] +typed-arena = "2" shuma-module = { path = "../shuma-module" } +# Indicador de escucha por voz compartido (botón de mic + EstadoEscucha): el +# mismo widget que el panel de chat y la command-bar — un solo «llamado shuma». +shuma-voz-ui = { path = "../shuma-voz-ui" } shuma-exec = { path = "../shuma-exec" } shuma-remote-exec = { path = "../shuma-remote-exec" } shuma-protocol = { path = "../shuma-protocol" } shuma-link = { path = "../shuma-link" } +ulid = { workspace = true } shuma-line = { path = "../shuma-line" } shuma-history = { path = "../shuma-history" } +shuma-config = { path = "../shuma-config" } +# Config global del SO: la IA + semántica viven aquí (no per-app), editadas en +# wawa-panel. Los builtins (`:?`/`:buscar`) la leen en vivo. +wawa-config = { workspace = true } +# Catálogo de capacidades de control de la suite (mirada/sandokan/…): `:haz` +# lo incrusta en el prompt para que el LLM elija una acción real, no shell libre. +atipay = { workspace = true } shuma-intent = { path = "../shuma-intent" } shuma-infer = { path = "../shuma-infer" } +# Cotejo de pluma: comparar la salida de dos bloques al estilo pluma — +# alineación párrafo-a-párrafo por similitud léxica (Needleman–Wunsch), +# clasifica idéntica/similar/divergente/agregada/eliminada (no un diff exacto). +pluma-core = { workspace = true } +pluma-cuerpo = { workspace = true } +pluma-cotejo = { workspace = true } +uuid = { workspace = true } llimphi-ui = { workspace = true } llimphi-theme = { workspace = true } llimphi-icons = { workspace = true } +llimphi-widget-scroll = { workspace = true } +llimphi-widget-text-editor = { workspace = true } +# El motor de edición compartido: el input de la barra corre sobre él (ver +# `src/input_editor.rs`), no sobre una caja propia. llimphi-widget-text-input = { workspace = true } +llimphi-widget-terminal = { workspace = true } +llimphi-widget-context-menu = { workspace = true } +llimphi-image = { workspace = true } +# Íconos XDG de apps `.desktop` (resolución freedesktop + cache), para que el +# panel de completado pinte el ícono real de cada candidato-app (tier 0). Vivía +# en pata; bajó aquí para que TODOS los frontends del shell lo compartan. +llimphi-svg = { workspace = true } +# Decodificador kitty/sixel: extrae las imágenes del stream del PTY antes de +# que lleguen al vt100 (que las descartaría). +llimphi-term-graphics = { workspace = true } vt100 = { workspace = true } arboard = { workspace = true } +serde = { workspace = true } +serde_json = { workspace = true } +toml = { workspace = true } +similar = { workspace = true } [dev-dependencies] png = { workspace = true } pollster = { workspace = true } +tempfile = { workspace = true } +base64 = { workspace = true } diff --git a/02_ruway/shuma/sandbox/shuma-module-shell/examples/alias_chip.rs b/02_ruway/shuma/sandbox/shuma-module-shell/examples/alias_chip.rs new file mode 100644 index 0000000..31cbfd1 --- /dev/null +++ b/02_ruway/shuma/sandbox/shuma-module-shell/examples/alias_chip.rs @@ -0,0 +1,167 @@ +//! Verificación headless del **chip de alias (A2)**: cuando una *línea larga* +//! se repitió varias veces idéntica (separada por otros comandos), el shell +//! ofrece bautizarla con un nombre corto que aprende al shumarc. Es el gemelo +//! de A1 (coreografía), pero sobre una sola línea. Render del `view()` a PNG. +//! +//! `cargo run -p shuma-module-shell --example alias_chip -- [out.png]` + +use std::fs::File; +use std::io::BufWriter; +use std::sync::{Arc, Mutex}; + +use llimphi_ui::llimphi_compositor::{measure_text_node, mount, paint}; +use llimphi_ui::llimphi_hal::{wgpu, Hal}; +use llimphi_ui::llimphi_layout::taffy; +use llimphi_ui::llimphi_layout::LayoutTree; +use llimphi_ui::llimphi_raster::peniko::Color; +use llimphi_ui::llimphi_raster::{vello, Renderer}; +use llimphi_ui::llimphi_text::Typesetter; + +use shuma_module_shell::OutputLine; + +const W: u32 = 1000; +const H: u32 = 420; +const FMT: wgpu::TextureFormat = wgpu::TextureFormat::Rgba8Unorm; + +fn main() { + let out = std::env::args().nth(1).unwrap_or_else(|| "alias_chip.png".to_string()); + let theme = llimphi_theme::Theme::default(); + let mut state = shuma_module_shell::State::new(shuma_module::Source::Local); + + // Historial aislado (in-memory, sobre /dev/null) para que el chip sea + // determinista y no mezcle el historial real del disco. + let hist = shuma_history::History::open(std::path::PathBuf::from("/dev/null")) + .expect("/dev/null como history vacío"); + state.history = Arc::new(Mutex::new(hist)); + + // Sembrar la línea larga repetida 3× (separada por otro comando — el + // historial deduplica consecutivos, como en el uso real). + let larga = "git push origin feature/inteligencia-shuma --force-with-lease"; + { + let mut h = state.history.lock().unwrap(); + for i in 0..3u64 { + let _ = h.append(shuma_history::Entry::new(larga, "/repo", 2 * i)); + let _ = h.append(shuma_history::Entry::new("git status", "/repo", 2 * i + 1)); + } + } + + // Un par de bloques de fondo para que la sesión no se vea vacía. + let mut blk = 0u64; + let mut cmd = |state: &mut shuma_module_shell::State, blk: &mut u64, prompt: &str, body: &[OutputLine], close: &str| { + *blk += 1; + let b = *blk; + let mut p = OutputLine::prompt(prompt); + p.block = b; + state.output.push(p); + for l in body { + let mut l = l.clone(); + l.block = b; + state.output.push(l); + } + let mut n = OutputLine::notice(close); + n.block = b; + state.output.push(n); + }; + cmd(&mut state, &mut blk, &format!("$ {larga}"), &[OutputLine::stdout("Everything up-to-date")], "✔ exit 0"); + cmd(&mut state, &mut blk, "$ git status", &[OutputLine::stdout("nada para commitear")], "✔ exit 0"); + + state.block_seq = blk; + state.current_block = blk; + if let Ok(mut g) = state.out_viewport_h.lock() { + *g = 300.0; + } + state.scroll_px = 0.0; + + let v = shuma_module_shell::view::<()>(&state, &theme, |_m| ()); + let mut layout = LayoutTree::new(); + let mounted = mount(&mut layout, v); + let mut ts = Typesetter::new(); + let computed = { + let tmap = &mounted.text_measures; + layout + .compute_with_measure(mounted.root, (W as f32, H as f32), |nid, known, avail| { + match tmap.get(&nid) { + Some(tm) => measure_text_node(&mut ts, tm, known, avail), + None => taffy::Size::ZERO, + } + }) + .expect("layout") + }; + let mut scene = vello::Scene::new(); + paint(&mut scene, &mounted, &computed, &mut ts, None, None); + + let hal = pollster::block_on(Hal::new(None)).expect("hal"); + let mut renderer = Renderer::new(&hal).expect("renderer"); + let target = hal.device.create_texture(&wgpu::TextureDescriptor { + label: Some("alias-chip"), + size: wgpu::Extent3d { width: W, height: H, depth_or_array_layers: 1 }, + mip_level_count: 1, + sample_count: 1, + dimension: wgpu::TextureDimension::D2, + format: FMT, + usage: wgpu::TextureUsages::STORAGE_BINDING + | wgpu::TextureUsages::RENDER_ATTACHMENT + | wgpu::TextureUsages::COPY_SRC, + view_formats: &[], + }); + let view = target.create_view(&wgpu::TextureViewDescriptor::default()); + renderer + .render_to_view(&hal, &scene, &view, W, H, Color::from_rgba8(20, 20, 26, 255)) + .expect("render_to_view"); + write_png(&hal, &target, &out); + eprintln!("alias_chip: {out} ({W}x{H})"); +} + +fn write_png(hal: &Hal, target: &wgpu::Texture, path: &str) { + let unpadded = (W * 4) as usize; + let align = wgpu::COPY_BYTES_PER_ROW_ALIGNMENT as usize; + let padded = unpadded.div_ceil(align) * align; + let buf = hal.device.create_buffer(&wgpu::BufferDescriptor { + label: Some("readback"), + size: (padded * H as usize) as u64, + usage: wgpu::BufferUsages::MAP_READ | wgpu::BufferUsages::COPY_DST, + mapped_at_creation: false, + }); + let mut enc = hal + .device + .create_command_encoder(&wgpu::CommandEncoderDescriptor { label: None }); + enc.copy_texture_to_buffer( + wgpu::TexelCopyTextureInfo { + texture: target, + mip_level: 0, + origin: wgpu::Origin3d::ZERO, + aspect: wgpu::TextureAspect::All, + }, + wgpu::TexelCopyBufferInfo { + buffer: &buf, + layout: wgpu::TexelCopyBufferLayout { + offset: 0, + bytes_per_row: Some(padded as u32), + rows_per_image: Some(H), + }, + }, + wgpu::Extent3d { width: W, height: H, depth_or_array_layers: 1 }, + ); + hal.queue.submit(std::iter::once(enc.finish())); + let slice = buf.slice(..); + let (tx, rx) = std::sync::mpsc::channel(); + slice.map_async(wgpu::MapMode::Read, move |r| { + let _ = tx.send(r); + }); + hal.device.poll(wgpu::PollType::wait_indefinitely()); + rx.recv().unwrap().unwrap(); + let data = slice.get_mapped_range(); + let mut pixels = Vec::with_capacity((W * H * 4) as usize); + for row in 0..H as usize { + let s = row * padded; + pixels.extend_from_slice(&data[s..s + unpadded]); + } + drop(data); + buf.unmap(); + let file = File::create(path).expect("png"); + let mut enc = png::Encoder::new(BufWriter::new(file), W, H); + enc.set_color(png::ColorType::Rgba); + enc.set_depth(png::BitDepth::Eight); + let mut w = enc.write_header().unwrap(); + w.write_image_data(&pixels).unwrap(); +} diff --git a/02_ruway/shuma/sandbox/shuma-module-shell/examples/choreo_chip.rs b/02_ruway/shuma/sandbox/shuma-module-shell/examples/choreo_chip.rs new file mode 100644 index 0000000..8b3d0e6 --- /dev/null +++ b/02_ruway/shuma/sandbox/shuma-module-shell/examples/choreo_chip.rs @@ -0,0 +1,169 @@ +//! Verificación headless del **chip de coreografía (A1)**: cuando una +//! secuencia repetida (`git pull → cargo build → cargo test`) supera el +//! umbral, el shell ofrece guardarla como grupo ejecutable con un chip +//! discreto sobre el input. Render del `view()` completo a PNG. +//! +//! `cargo run -p shuma-module-shell --example choreo_chip -- [out.png]` + +use std::fs::File; +use std::io::BufWriter; + +use llimphi_ui::llimphi_compositor::{measure_text_node, mount, paint}; +use llimphi_ui::llimphi_hal::{wgpu, Hal}; +use llimphi_ui::llimphi_layout::taffy; +use llimphi_ui::llimphi_layout::LayoutTree; +use llimphi_ui::llimphi_raster::peniko::Color; +use llimphi_ui::llimphi_raster::{vello, Renderer}; +use llimphi_ui::llimphi_text::Typesetter; + +use shuma_module_shell::OutputLine; + +const W: u32 = 1000; +const H: u32 = 420; +const FMT: wgpu::TextureFormat = wgpu::TextureFormat::Rgba8Unorm; + +fn main() { + let out = std::env::args().nth(1).unwrap_or_else(|| "choreo_chip.png".to_string()); + let theme = llimphi_theme::Theme::default(); + let mut state = shuma_module_shell::State::new(shuma_module::Source::Local); + + // Sembrar la coreografía repetida 3× (separada por otros comandos, como en + // el uso real) directamente en `patterns` — lo que haría `refresh_patterns` + // tras cerrar cada comando. + let rec = |l: &str| shuma_infer::CommandRecord::parse(l, "/repo", true); + let records = vec![ + rec("git pull"), + rec("cargo build"), + rec("cargo test"), + rec("ls"), + rec("git pull"), + rec("cargo build"), + rec("cargo test"), + rec("cd /tmp"), + rec("git pull"), + rec("cargo build"), + rec("cargo test"), + ]; + state.patterns = + shuma_infer::detect_patterns(&records, &shuma_infer::InferConfig::default()); + + // Un par de bloques de fondo para que la sesión no se vea vacía. + let mut blk = 0u64; + let mut cmd = |state: &mut shuma_module_shell::State, blk: &mut u64, prompt: &str, body: &[OutputLine], close: &str| { + *blk += 1; + let b = *blk; + let mut p = OutputLine::prompt(prompt); + p.block = b; + state.output.push(p); + for l in body { + let mut l = l.clone(); + l.block = b; + state.output.push(l); + } + let mut n = OutputLine::notice(close); + n.block = b; + state.output.push(n); + }; + cmd(&mut state, &mut blk, "$ git pull", &[OutputLine::stdout("Already up to date.")], "✔ exit 0"); + cmd(&mut state, &mut blk, "$ cargo build", &[OutputLine::stdout(" Finished in 2.1s")], "✔ exit 0"); + + state.block_seq = blk; + state.current_block = blk; + if let Ok(mut g) = state.out_viewport_h.lock() { + *g = 300.0; + } + state.scroll_px = 0.0; + + let v = shuma_module_shell::view::<()>(&state, &theme, |_m| ()); + let mut layout = LayoutTree::new(); + let mounted = mount(&mut layout, v); + let mut ts = Typesetter::new(); + let computed = { + let tmap = &mounted.text_measures; + layout + .compute_with_measure(mounted.root, (W as f32, H as f32), |nid, known, avail| { + match tmap.get(&nid) { + Some(tm) => measure_text_node(&mut ts, tm, known, avail), + None => taffy::Size::ZERO, + } + }) + .expect("layout") + }; + let mut scene = vello::Scene::new(); + paint(&mut scene, &mounted, &computed, &mut ts, None, None); + + let hal = pollster::block_on(Hal::new(None)).expect("hal"); + let mut renderer = Renderer::new(&hal).expect("renderer"); + let target = hal.device.create_texture(&wgpu::TextureDescriptor { + label: Some("choreo-chip"), + size: wgpu::Extent3d { width: W, height: H, depth_or_array_layers: 1 }, + mip_level_count: 1, + sample_count: 1, + dimension: wgpu::TextureDimension::D2, + format: FMT, + usage: wgpu::TextureUsages::STORAGE_BINDING + | wgpu::TextureUsages::RENDER_ATTACHMENT + | wgpu::TextureUsages::COPY_SRC, + view_formats: &[], + }); + let view = target.create_view(&wgpu::TextureViewDescriptor::default()); + renderer + .render_to_view(&hal, &scene, &view, W, H, Color::from_rgba8(20, 20, 26, 255)) + .expect("render_to_view"); + write_png(&hal, &target, &out); + eprintln!("choreo_chip: {out} ({W}x{H})"); +} + +fn write_png(hal: &Hal, target: &wgpu::Texture, path: &str) { + let unpadded = (W * 4) as usize; + let align = wgpu::COPY_BYTES_PER_ROW_ALIGNMENT as usize; + let padded = unpadded.div_ceil(align) * align; + let buf = hal.device.create_buffer(&wgpu::BufferDescriptor { + label: Some("readback"), + size: (padded * H as usize) as u64, + usage: wgpu::BufferUsages::MAP_READ | wgpu::BufferUsages::COPY_DST, + mapped_at_creation: false, + }); + let mut enc = hal + .device + .create_command_encoder(&wgpu::CommandEncoderDescriptor { label: None }); + enc.copy_texture_to_buffer( + wgpu::TexelCopyTextureInfo { + texture: target, + mip_level: 0, + origin: wgpu::Origin3d::ZERO, + aspect: wgpu::TextureAspect::All, + }, + wgpu::TexelCopyBufferInfo { + buffer: &buf, + layout: wgpu::TexelCopyBufferLayout { + offset: 0, + bytes_per_row: Some(padded as u32), + rows_per_image: Some(H), + }, + }, + wgpu::Extent3d { width: W, height: H, depth_or_array_layers: 1 }, + ); + hal.queue.submit(std::iter::once(enc.finish())); + let slice = buf.slice(..); + let (tx, rx) = std::sync::mpsc::channel(); + slice.map_async(wgpu::MapMode::Read, move |r| { + let _ = tx.send(r); + }); + hal.device.poll(wgpu::PollType::wait_indefinitely()); + rx.recv().unwrap().unwrap(); + let data = slice.get_mapped_range(); + let mut pixels = Vec::with_capacity((W * H * 4) as usize); + for row in 0..H as usize { + let s = row * padded; + pixels.extend_from_slice(&data[s..s + unpadded]); + } + drop(data); + buf.unmap(); + let file = File::create(path).expect("png"); + let mut enc = png::Encoder::new(BufWriter::new(file), W, H); + enc.set_color(png::ColorType::Rgba); + enc.set_depth(png::BitDepth::Eight); + let mut w = enc.write_header().unwrap(); + w.write_image_data(&pixels).unwrap(); +} diff --git a/02_ruway/shuma/sandbox/shuma-module-shell/examples/consola_probe.rs b/02_ruway/shuma/sandbox/shuma-module-shell/examples/consola_probe.rs new file mode 100644 index 0000000..b97d7fc --- /dev/null +++ b/02_ruway/shuma/sandbox/shuma-module-shell/examples/consola_probe.rs @@ -0,0 +1,86 @@ +//! Sonda EN VIVO de la vista consola: adjunta a una sesión persistente REAL +//! del daemon (id por argv) por el mismo camino del chasis (`:attach` → +//! `mount_session_run`), la hace streamear tipeando en su stdin (repaints del +//! input box de claude, sin Enter — no ejecuta nada), y cada tick imprime qué +//! rama ESTRUCTURAL pintaría la vista y la radiografía (`diag_consola`). +//! +//! Uso: cargo run -p shuma-module-shell --example consola_probe -- +//! +//! OJO: adjuntarse REDIMENSIONA la sesión al tamaño del cliente — usarla sólo +//! contra sesiones zombis, nunca contra la que el usuario está mirando. + +use std::time::Duration; + +fn main() { + // Modo volcado (argv[2] = ruta): adjunta CRUDO (sin pasar por el módulo), + // guarda todos los bytes del replay+stream y reporta si un vt100 fresco + // queda en alt-screen tras comerlos — para auditar qué secuencia lo prende. + let id_arg: Option = + std::env::args().nth(1).and_then(|s| s.parse().ok()); + if let (Some(id), Some(path)) = (id_arg, std::env::args().nth(2)) { + let sock = shuma_protocol::default_socket_path(); + let mut h = shuma_remote_exec::attach_session(&sock, id, 44, 240) + .expect("attach crudo"); + let mut buf: Vec = Vec::new(); + let mut parser = vt100::Parser::new(44, 240, 10_000); + for _ in 0..30 { + std::thread::sleep(Duration::from_millis(100)); + for ev in h.try_events() { + if let shuma_exec::RunEvent::Bytes(b) = ev { + buf.extend_from_slice(&b); + parser.process(&b); + } + } + } + std::fs::write(&path, &buf).expect("escribir dump"); + println!( + "bytes={} altscreen={} dump={path}", + buf.len(), + parser.screen().alternate_screen(), + ); + return; + } + let arg = std::env::args() + .nth(1) + .expect("uso: consola_probe [dump.bin]"); + let mut s = shuma_module_shell::State::new(shuma_module::Source::Local); + if arg == "spawn" { + // Flujo REAL de tipear `claude` en la barra: start_run → + // spawn_persistente en el daemon del entorno (usar XDG_RUNTIME_DIR + // temporal + SHUMA_DAEMON_BIN para no ensuciar el daemon vivo). + let linea = std::env::args().nth(2).unwrap_or_else(|| "claude".into()); + s.input.set_text(&linea); + } else { + s.input.set_text(&format!(":attach {arg}")); + } + s = shuma_module_shell::update(s, shuma_module_shell::Msg::Submit); + if s.running.is_none() { + eprintln!("no montó — ¿sesión muerta o daemon caído?"); + std::process::exit(1); + } + + // "hola " tipeado de a un byte, y al final backspaces que lo borran: + // provoca un repaint del TUI por tecla y deja el input como estaba. + let teclas: Vec = b"hola ".iter().copied().chain([0x7f; 5]).collect(); + let mut teclas = teclas.into_iter(); + + for i in 0..60 { + std::thread::sleep(Duration::from_millis(250)); + s = shuma_module_shell::update(s, shuma_module_shell::Msg::Tick); + if i % 2 == 0 { + if let (Some(arc), Some(b)) = (s.running.as_ref(), teclas.next()) { + if let Ok(g) = arc.lock() { + g.handle.write_input(vec![b]); + } + } + } + let rama = if s.is_fullscreen_tui() { + "GRID" + } else if s.tui_skin_vivo.is_some() { + "CONSOLA" + } else { + "SURFACE" + }; + println!("t{i:02} rama={rama} {}", s.diag_consola()); + } +} diff --git a/02_ruway/shuma/sandbox/shuma-module-shell/examples/costo_consola.rs b/02_ruway/shuma/sandbox/shuma-module-shell/examples/costo_consola.rs new file mode 100644 index 0000000..21a7e42 --- /dev/null +++ b/02_ruway/shuma/sandbox/shuma-module-shell/examples/costo_consola.rs @@ -0,0 +1,88 @@ +//! ¿Cuánto cuesta armar la vista consola de claude, POR FRAME? +//! +//! La vista no guarda nada: cada frame vuelve a leer el scrollback del vt100 +//! celda por celda (una `String` por celda), lo aplana a texto y lo pasa +//! entero por `detect_claude`. Este probe reproduce ese trabajo sobre un +//! buffer sintético del mismo tamaño que el real y lo cronometra, para saber +//! si el congelamiento del panel durante una respuesta larga es esto. +//! +//! Uso: cargo run -p shuma-module-shell --example costo_consola --release + +use std::time::Instant; + +const FILAS_VIS: u16 = 44; +const COLS: u16 = 240; +/// Mismo tope que `capture_tui_completo`. +const MAX_SCROLLBACK: usize = 400; + +fn main() { + // Buffer sintético: un log de claude plausible (headers, viñetas, cuerpo). + let mut parser = vt100::Parser::new(FILAS_VIS, COLS, 10_000); + for i in 0..900u32 { + let linea = match i % 7 { + 0 => format!("⏺ Bash(cargo check --workspace) #{i}"), + 1 => " ⎿ Compiling llimphi-text v0.1.0".to_string(), + 2 => String::new(), + 3 => format!("● El texto multicolor perdía la familia mono, iteración {i}."), + 4 => " · un detalle indentado que el detector agrupa".to_string(), + 5 => format!("| campo{i:<4} | valor | {i:>6} |"), + _ => "y una línea de cuerpo corriente, con acentos: ñandú, ártico.".to_string(), + }; + parser.process(linea.as_bytes()); + parser.process(b"\r\n"); + } + + // --- 1. Captura: scrollback + pantalla viva, celda por celda. --- + let t0 = Instant::now(); + let mut celdas: usize = 0; + let mut lineas: Vec = Vec::new(); + { + let screen = parser.screen_mut(); + screen.set_scrollback(usize::MAX); + let total = screen.scrollback(); + let leer = total.min(MAX_SCROLLBACK); + let mut pendientes = leer; + while pendientes > 0 { + screen.set_scrollback(pendientes); + let tanda = pendientes.min(FILAS_VIS as usize); + for r in 0..tanda as u16 { + let mut s = String::with_capacity(COLS as usize); + for c in 0..COLS { + match screen.cell(r, c) { + Some(cell) if cell.has_contents() => s.push_str(cell.contents()), + _ => s.push(' '), + } + celdas += 1; + } + lineas.push(s.trim_end().to_string()); + } + pendientes -= tanda; + } + screen.set_scrollback(0); + for r in 0..FILAS_VIS { + let mut s = String::with_capacity(COLS as usize); + for c in 0..COLS { + match screen.cell(r, c) { + Some(cell) if cell.has_contents() => s.push_str(cell.contents()), + _ => s.push(' '), + } + celdas += 1; + } + lineas.push(s.trim_end().to_string()); + } + } + let t_captura = t0.elapsed(); + + // --- 2. Estructuración: el detector corre sobre TODO, cada frame. --- + let t1 = Instant::now(); + let secciones = shuma_module_shell::sections::detect_claude(&lineas); + let t_detect = t1.elapsed(); + + let total = t_captura + t_detect; + println!("filas={} celdas={celdas}", lineas.len()); + println!("captura {:>8.2} ms", t_captura.as_secs_f64() * 1000.0); + println!("detect {:>8.2} ms", t_detect.as_secs_f64() * 1000.0); + println!("POR FRAME {:>8.2} ms → techo {:.1} fps", total.as_secs_f64() * 1000.0, + 1.0 / total.as_secs_f64()); + println!("secciones={}", secciones.map(|v| v.len()).unwrap_or(0)); +} diff --git a/02_ruway/shuma/sandbox/shuma-module-shell/examples/desplanizador.rs b/02_ruway/shuma/sandbox/shuma-module-shell/examples/desplanizador.rs new file mode 100644 index 0000000..1d68156 --- /dev/null +++ b/02_ruway/shuma/sandbox/shuma-module-shell/examples/desplanizador.rs @@ -0,0 +1,197 @@ +//! Verificación headless de los **detectores de secciones** nuevos: `docker +//! ps` como tabla ordenable, `git status` partido por grupo, y `cargo` con +//! una sección colapsable por diagnóstico. El stream plano de la terminal se +//! «desplaniza» en estructura consultable sin que el comando coopere. +//! +//! `cargo run -p shuma-module-shell --example desplanizador -- [out.png]` + +use std::fs::File; +use std::io::BufWriter; + +use llimphi_ui::llimphi_compositor::{measure_text_node, mount, paint}; +use llimphi_ui::llimphi_hal::{wgpu, Hal}; +use llimphi_ui::llimphi_layout::taffy; +use llimphi_ui::llimphi_layout::LayoutTree; +use llimphi_ui::llimphi_raster::peniko::Color; +use llimphi_ui::llimphi_raster::{vello, Renderer}; +use llimphi_ui::llimphi_text::Typesetter; + +use shuma_module_shell::OutputLine; + +const W: u32 = 1040; +const H: u32 = 720; +const FMT: wgpu::TextureFormat = wgpu::TextureFormat::Rgba8Unorm; + +fn main() { + let out = std::env::args().nth(1).unwrap_or_else(|| "desplanizador.png".to_string()); + let theme = llimphi_theme::Theme::default(); + let mut state = shuma_module_shell::State::new(shuma_module::Source::Local); + + let mut blk = 0u64; + let mut cmd = |state: &mut shuma_module_shell::State, blk: &mut u64, prompt: &str, body: &[&str]| { + *blk += 1; + let b = *blk; + let mut p = OutputLine::prompt(prompt); + p.block = b; + state.output.push(p); + for line in body { + let mut l = OutputLine::stdout(*line); + l.block = b; + state.output.push(l); + } + state.block_command.insert(b, prompt.to_string()); + b + }; + + cmd( + &mut state, + &mut blk, + "$ docker ps -a", + &[ + "CONTAINER ID IMAGE COMMAND CREATED STATUS PORTS NAMES", + "abc123def456 nginx:1.27 \"/docker-entrypoint.…\" 2 hours ago Up 2 hours 0.0.0.0:80->80/tcp web", + "789aaa111bbb postgres:16 \"docker-entrypoint.s…\" 3 days ago Up 3 days 5432/tcp db", + "ccc222ddd333 redis:7 \"redis-server\" 5 days ago Exited (0) cache", + ], + ); + + cmd( + &mut state, + &mut blk, + "$ git status", + &[ + "On branch main", + "Your branch is up to date with 'origin/main'.", + "", + "Changes to be committed:", + " (use \"git restore --staged ...\" to unstage)", + "\tmodified: src/sections.rs", + "\tnew file: examples/desplanizador.rs", + "", + "Changes not staged for commit:", + " (use \"git add ...\" to update what will be committed)", + "\tmodified: src/update/builtins.rs", + "", + "Untracked files:", + " (use \"git add ...\" to include in what will be committed)", + "\tnohup.out", + ], + ); + + cmd( + &mut state, + &mut blk, + "$ cargo build", + &[ + " Compiling shuma-module-shell v0.1.0", + "error[E0308]: mismatched types", + " --> src/foo.rs:3:5", + " |", + "3 | let x: u32 = \"hola\";", + " | --- ^^^^^^ expected `u32`, found `&str`", + "warning: unused variable: `tmp`", + " --> src/bar.rs:9:9", + "error: could not compile `shuma-module-shell` (lib) due to 1 error", + ], + ); + + state.block_seq = blk; + state.current_block = blk; + if let Ok(mut g) = state.out_viewport_h.lock() { + *g = 660.0; + } + state.scroll_px = 0.0; + + let v = shuma_module_shell::view::<()>(&state, &theme, |_m| ()); + let mut layout = LayoutTree::new(); + let mounted = mount(&mut layout, v); + let mut ts = Typesetter::new(); + let computed = { + let tmap = &mounted.text_measures; + layout + .compute_with_measure(mounted.root, (W as f32, H as f32), |nid, known, avail| { + match tmap.get(&nid) { + Some(tm) => measure_text_node(&mut ts, tm, known, avail), + None => taffy::Size::ZERO, + } + }) + .expect("layout") + }; + let mut scene = vello::Scene::new(); + paint(&mut scene, &mounted, &computed, &mut ts, None, None); + + let hal = pollster::block_on(Hal::new(None)).expect("hal"); + let mut renderer = Renderer::new(&hal).expect("renderer"); + let target = hal.device.create_texture(&wgpu::TextureDescriptor { + label: Some("desplanizador"), + size: wgpu::Extent3d { width: W, height: H, depth_or_array_layers: 1 }, + mip_level_count: 1, + sample_count: 1, + dimension: wgpu::TextureDimension::D2, + format: FMT, + usage: wgpu::TextureUsages::STORAGE_BINDING + | wgpu::TextureUsages::RENDER_ATTACHMENT + | wgpu::TextureUsages::COPY_SRC, + view_formats: &[], + }); + let view = target.create_view(&wgpu::TextureViewDescriptor::default()); + renderer + .render_to_view(&hal, &scene, &view, W, H, Color::from_rgba8(20, 20, 26, 255)) + .expect("render_to_view"); + write_png(&hal, &target, &out); + eprintln!("desplanizador: {out} ({W}x{H})"); +} + +fn write_png(hal: &Hal, target: &wgpu::Texture, path: &str) { + let unpadded = (W * 4) as usize; + let align = wgpu::COPY_BYTES_PER_ROW_ALIGNMENT as usize; + let padded = unpadded.div_ceil(align) * align; + let buf = hal.device.create_buffer(&wgpu::BufferDescriptor { + label: Some("readback"), + size: (padded * H as usize) as u64, + usage: wgpu::BufferUsages::MAP_READ | wgpu::BufferUsages::COPY_DST, + mapped_at_creation: false, + }); + let mut enc = hal + .device + .create_command_encoder(&wgpu::CommandEncoderDescriptor { label: None }); + enc.copy_texture_to_buffer( + wgpu::TexelCopyTextureInfo { + texture: target, + mip_level: 0, + origin: wgpu::Origin3d::ZERO, + aspect: wgpu::TextureAspect::All, + }, + wgpu::TexelCopyBufferInfo { + buffer: &buf, + layout: wgpu::TexelCopyBufferLayout { + offset: 0, + bytes_per_row: Some(padded as u32), + rows_per_image: Some(H), + }, + }, + wgpu::Extent3d { width: W, height: H, depth_or_array_layers: 1 }, + ); + hal.queue.submit(std::iter::once(enc.finish())); + let slice = buf.slice(..); + let (tx, rx) = std::sync::mpsc::channel(); + slice.map_async(wgpu::MapMode::Read, move |r| { + let _ = tx.send(r); + }); + hal.device.poll(wgpu::PollType::wait_indefinitely()); + rx.recv().unwrap().unwrap(); + let data = slice.get_mapped_range(); + let mut pixels = Vec::with_capacity((W * H * 4) as usize); + for row in 0..H as usize { + let s = row * padded; + pixels.extend_from_slice(&data[s..s + unpadded]); + } + drop(data); + buf.unmap(); + let file = File::create(path).expect("png"); + let mut enc = png::Encoder::new(BufWriter::new(file), W, H); + enc.set_color(png::ColorType::Rgba); + enc.set_depth(png::BitDepth::Eight); + let mut w = enc.write_header().unwrap(); + w.write_image_data(&pixels).unwrap(); +} diff --git a/02_ruway/shuma/sandbox/shuma-module-shell/examples/did_you_mean.rs b/02_ruway/shuma/sandbox/shuma-module-shell/examples/did_you_mean.rs new file mode 100644 index 0000000..496aa98 --- /dev/null +++ b/02_ruway/shuma/sandbox/shuma-module-shell/examples/did_you_mean.rs @@ -0,0 +1,151 @@ +//! Verificación headless del **notice «¿quisiste decir…?» (A4)**: cuando un +//! comando falla por `command not found`, el shell ofrece bajo el bloque una +//! fila clickeable con el binario más cercano (Damerau-Levenshtein, priorizando +//! el historial). Render del `view()` completo a PNG. +//! +//! `cargo run -p shuma-module-shell --example did_you_mean -- [out.png]` + +use std::fs::File; +use std::io::BufWriter; + +use llimphi_ui::llimphi_compositor::{measure_text_node, mount, paint}; +use llimphi_ui::llimphi_hal::{wgpu, Hal}; +use llimphi_ui::llimphi_layout::taffy; +use llimphi_ui::llimphi_layout::LayoutTree; +use llimphi_ui::llimphi_raster::peniko::Color; +use llimphi_ui::llimphi_raster::{vello, Renderer}; +use llimphi_ui::llimphi_text::Typesetter; + +use shuma_module_shell::OutputLine; + +const W: u32 = 1000; +const H: u32 = 420; +const FMT: wgpu::TextureFormat = wgpu::TextureFormat::Rgba8Unorm; + +fn main() { + let out = std::env::args().nth(1).unwrap_or_else(|| "did_you_mean.png".to_string()); + let theme = llimphi_theme::Theme::default(); + let mut state = shuma_module_shell::State::new(shuma_module::Source::Local); + + // Un bloque que falló por `command not found`, con su corrección A4. + let mut blk = 0u64; + let mut cmd = |state: &mut shuma_module_shell::State, blk: &mut u64, prompt: &str, body: &[OutputLine], close: &str| { + *blk += 1; + let b = *blk; + let mut p = OutputLine::prompt(prompt); + p.block = b; + state.output.push(p); + for l in body { + let mut l = l.clone(); + l.block = b; + state.output.push(l); + } + let mut n = OutputLine::notice(close); + n.block = b; + state.output.push(n); + }; + cmd(&mut state, &mut blk, "$ git status", &[OutputLine::stdout("nothing to commit, working tree clean")], "\u{2714} exit 0"); + cmd(&mut state, &mut blk, "$ cagro build --release", &[OutputLine::stderr("zsh: command not found: cagro")], "\u{2718} exit 127"); + // La corrección que `detect_did_you_mean` habría calculado. + state.did_you_mean.insert(blk, "cargo build --release".to_string()); + + state.block_seq = blk; + state.current_block = blk; + if let Ok(mut g) = state.out_viewport_h.lock() { + *g = 300.0; + } + state.scroll_px = 0.0; + + let v = shuma_module_shell::view::<()>(&state, &theme, |_m| ()); + let mut layout = LayoutTree::new(); + let mounted = mount(&mut layout, v); + let mut ts = Typesetter::new(); + let computed = { + let tmap = &mounted.text_measures; + layout + .compute_with_measure(mounted.root, (W as f32, H as f32), |nid, known, avail| { + match tmap.get(&nid) { + Some(tm) => measure_text_node(&mut ts, tm, known, avail), + None => taffy::Size::ZERO, + } + }) + .expect("layout") + }; + let mut scene = vello::Scene::new(); + paint(&mut scene, &mounted, &computed, &mut ts, None, None); + + let hal = pollster::block_on(Hal::new(None)).expect("hal"); + let mut renderer = Renderer::new(&hal).expect("renderer"); + let target = hal.device.create_texture(&wgpu::TextureDescriptor { + label: Some("choreo-chip"), + size: wgpu::Extent3d { width: W, height: H, depth_or_array_layers: 1 }, + mip_level_count: 1, + sample_count: 1, + dimension: wgpu::TextureDimension::D2, + format: FMT, + usage: wgpu::TextureUsages::STORAGE_BINDING + | wgpu::TextureUsages::RENDER_ATTACHMENT + | wgpu::TextureUsages::COPY_SRC, + view_formats: &[], + }); + let view = target.create_view(&wgpu::TextureViewDescriptor::default()); + renderer + .render_to_view(&hal, &scene, &view, W, H, Color::from_rgba8(20, 20, 26, 255)) + .expect("render_to_view"); + write_png(&hal, &target, &out); + eprintln!("did_you_mean: {out} ({W}x{H})"); +} + +fn write_png(hal: &Hal, target: &wgpu::Texture, path: &str) { + let unpadded = (W * 4) as usize; + let align = wgpu::COPY_BYTES_PER_ROW_ALIGNMENT as usize; + let padded = unpadded.div_ceil(align) * align; + let buf = hal.device.create_buffer(&wgpu::BufferDescriptor { + label: Some("readback"), + size: (padded * H as usize) as u64, + usage: wgpu::BufferUsages::MAP_READ | wgpu::BufferUsages::COPY_DST, + mapped_at_creation: false, + }); + let mut enc = hal + .device + .create_command_encoder(&wgpu::CommandEncoderDescriptor { label: None }); + enc.copy_texture_to_buffer( + wgpu::TexelCopyTextureInfo { + texture: target, + mip_level: 0, + origin: wgpu::Origin3d::ZERO, + aspect: wgpu::TextureAspect::All, + }, + wgpu::TexelCopyBufferInfo { + buffer: &buf, + layout: wgpu::TexelCopyBufferLayout { + offset: 0, + bytes_per_row: Some(padded as u32), + rows_per_image: Some(H), + }, + }, + wgpu::Extent3d { width: W, height: H, depth_or_array_layers: 1 }, + ); + hal.queue.submit(std::iter::once(enc.finish())); + let slice = buf.slice(..); + let (tx, rx) = std::sync::mpsc::channel(); + slice.map_async(wgpu::MapMode::Read, move |r| { + let _ = tx.send(r); + }); + hal.device.poll(wgpu::PollType::wait_indefinitely()); + rx.recv().unwrap().unwrap(); + let data = slice.get_mapped_range(); + let mut pixels = Vec::with_capacity((W * H * 4) as usize); + for row in 0..H as usize { + let s = row * padded; + pixels.extend_from_slice(&data[s..s + unpadded]); + } + drop(data); + buf.unmap(); + let file = File::create(path).expect("png"); + let mut enc = png::Encoder::new(BufWriter::new(file), W, H); + enc.set_color(png::ColorType::Rgba); + enc.set_depth(png::BitDepth::Eight); + let mut w = enc.write_header().unwrap(); + w.write_image_data(&pixels).unwrap(); +} diff --git a/02_ruway/shuma/sandbox/shuma-module-shell/examples/dump_ls.rs b/02_ruway/shuma/sandbox/shuma-module-shell/examples/dump_ls.rs index a724d5e..709c318 100644 --- a/02_ruway/shuma/sandbox/shuma-module-shell/examples/dump_ls.rs +++ b/02_ruway/shuma/sandbox/shuma-module-shell/examples/dump_ls.rs @@ -30,10 +30,20 @@ fn main() { state.output.push(l); }; push(&mut state, OutputLine::prompt("$ ls -la")); - for i in 0..50 { + // Entradas REALES del cwd → `decorate_line` las reconoce y las colorea + // por tipo (carpeta/código/ejecutable…). Así el dump ejercita el coloreo. + let mut names: Vec = std::fs::read_dir(".") + .map(|rd| { + rd.flatten() + .filter_map(|e| e.file_name().into_string().ok()) + .collect() + }) + .unwrap_or_default(); + names.sort(); + for name in names.iter().take(40) { push( &mut state, - OutputLine::stdout(format!("-rw-r--r-- 1 sergio sergio {:>6} archivo_{:02}.rs", i * 137, i)), + OutputLine::stdout(format!("-rw-r--r-- 1 sergio sergio 1234 {name}")), ); } push(&mut state, OutputLine::notice("✔ exit 0")); @@ -119,7 +129,7 @@ fn write_png(hal: &Hal, target: &wgpu::Texture, path: &str) { slice.map_async(wgpu::MapMode::Read, move |r| { let _ = tx.send(r); }); - hal.device.poll(wgpu::Maintain::Wait); + hal.device.poll(wgpu::PollType::wait_indefinitely()); rx.recv().unwrap().unwrap(); let data = slice.get_mapped_range(); let mut pixels = Vec::with_capacity((W * H * 4) as usize); diff --git a/02_ruway/shuma/sandbox/shuma-module-shell/examples/dump_scroll.rs b/02_ruway/shuma/sandbox/shuma-module-shell/examples/dump_scroll.rs new file mode 100644 index 0000000..b263cbf --- /dev/null +++ b/02_ruway/shuma/sandbox/shuma-module-shell/examples/dump_scroll.rs @@ -0,0 +1,164 @@ +//! Volcado de scroll: llena el output con muchas líneas y renderiza a una +//! posición de scroll dada para verificar que el área scrollea bien. +//! `cargo run -p shuma-module-shell --example dump_scroll -- ` + +use std::fs::File; +use std::io::BufWriter; + +use llimphi_ui::llimphi_compositor::{measure_text_node, mount, paint}; +use llimphi_ui::llimphi_hal::{wgpu, Hal}; +use llimphi_ui::llimphi_layout::taffy; +use llimphi_ui::llimphi_layout::LayoutTree; +use llimphi_ui::llimphi_raster::peniko::Color; +use llimphi_ui::llimphi_raster::{vello, Renderer}; +use llimphi_ui::llimphi_text::Typesetter; + +const W: u32 = 1000; +const H: u32 = 480; +const FMT: wgpu::TextureFormat = wgpu::TextureFormat::Rgba8Unorm; + +fn main() { + let out = std::env::args().nth(1).unwrap_or_else(|| "scroll.png".to_string()); + let scroll: f32 = std::env::args() + .nth(2) + .and_then(|s| s.parse().ok()) + .unwrap_or(0.0); + + let theme = llimphi_theme::Theme::default(); + let mut state = shuma_module_shell::State::new(shuma_module::Source::Local); + + use shuma_module_shell::OutputLine; + // 8 comandos, cada uno con su salida → cards apiladas, mucho más alto + // que el viewport. + let push = |state: &mut shuma_module_shell::State, block: u64, mut l: OutputLine| { + l.block = block; + state.output.push(l); + }; + for c in 0..8u64 { + let block = c + 1; + push(&mut state, block, OutputLine::prompt(format!("$ comando-{c} --largo"))); + for l in 0..6 { + push( + &mut state, + block, + OutputLine::stdout(format!( + " línea {l} de la salida del comando {c} — texto de relleno" + )), + ); + } + push(&mut state, block, OutputLine::notice("✔ exit 0")); + } + + // Simulamos el estado estable: el painter ya midió el viewport en un + // frame anterior. El viewport del output es la ventana (H) menos header + // (~24+8) e input (~34+8) y banners — aproximamos a H-110. + let viewport = (H as f32) - 110.0; + *state.out_viewport_h.lock().unwrap() = viewport; + state.scroll_px = scroll; + + let v = shuma_module_shell::view::<()>(&state, &theme, |_m| ()); + + let mut layout = LayoutTree::new(); + let mounted = mount(&mut layout, v); + let mut ts = Typesetter::new(); + let computed = { + let tmap = &mounted.text_measures; + layout + .compute_with_measure(mounted.root, (W as f32, H as f32), |nid, known, avail| { + match tmap.get(&nid) { + Some(tm) => measure_text_node(&mut ts, tm, known, avail), + None => taffy::Size::ZERO, + } + }) + .expect("layout") + }; + let mut scene = vello::Scene::new(); + paint(&mut scene, &mounted, &computed, &mut ts, None, None); + + let overflow = state.out_overflow.lock().map(|g| *g).unwrap_or(-1.0); + eprintln!("dump_scroll: viewport={viewport} scroll_px={scroll} overflow={overflow}"); + + let hal = pollster::block_on(Hal::new(None)).expect("hal"); + let mut renderer = Renderer::new(&hal).expect("renderer"); + let target = hal.device.create_texture(&wgpu::TextureDescriptor { + label: Some("dump-scroll"), + size: wgpu::Extent3d { + width: W, + height: H, + depth_or_array_layers: 1, + }, + mip_level_count: 1, + sample_count: 1, + dimension: wgpu::TextureDimension::D2, + format: FMT, + usage: wgpu::TextureUsages::STORAGE_BINDING + | wgpu::TextureUsages::RENDER_ATTACHMENT + | wgpu::TextureUsages::COPY_SRC, + view_formats: &[], + }); + let view = target.create_view(&wgpu::TextureViewDescriptor::default()); + renderer + .render_to_view(&hal, &scene, &view, W, H, Color::from_rgba8(20, 20, 26, 255)) + .expect("render_to_view"); + + write_png(&hal, &target, &out); + eprintln!("dump_scroll: escrito {out} ({W}x{H})"); +} + +fn write_png(hal: &Hal, target: &wgpu::Texture, path: &str) { + let unpadded = (W * 4) as usize; + let align = wgpu::COPY_BYTES_PER_ROW_ALIGNMENT as usize; + let padded = unpadded.div_ceil(align) * align; + let buf = hal.device.create_buffer(&wgpu::BufferDescriptor { + label: Some("readback"), + size: (padded * H as usize) as u64, + usage: wgpu::BufferUsages::MAP_READ | wgpu::BufferUsages::COPY_DST, + mapped_at_creation: false, + }); + let mut enc = hal + .device + .create_command_encoder(&wgpu::CommandEncoderDescriptor { label: None }); + enc.copy_texture_to_buffer( + wgpu::TexelCopyTextureInfo { + texture: target, + mip_level: 0, + origin: wgpu::Origin3d::ZERO, + aspect: wgpu::TextureAspect::All, + }, + wgpu::TexelCopyBufferInfo { + buffer: &buf, + layout: wgpu::TexelCopyBufferLayout { + offset: 0, + bytes_per_row: Some(padded as u32), + rows_per_image: Some(H), + }, + }, + wgpu::Extent3d { + width: W, + height: H, + depth_or_array_layers: 1, + }, + ); + hal.queue.submit(std::iter::once(enc.finish())); + let slice = buf.slice(..); + let (tx, rx) = std::sync::mpsc::channel(); + slice.map_async(wgpu::MapMode::Read, move |r| { + let _ = tx.send(r); + }); + hal.device.poll(wgpu::PollType::wait_indefinitely()); + rx.recv().unwrap().unwrap(); + let data = slice.get_mapped_range(); + let mut pixels = Vec::with_capacity((W * H * 4) as usize); + for row in 0..H as usize { + let s = row * padded; + pixels.extend_from_slice(&data[s..s + unpadded]); + } + drop(data); + buf.unmap(); + let file = File::create(path).expect("png"); + let mut enc = png::Encoder::new(BufWriter::new(file), W, H); + enc.set_color(png::ColorType::Rgba); + enc.set_depth(png::BitDepth::Eight); + let mut w = enc.write_header().unwrap(); + w.write_image_data(&pixels).unwrap(); +} diff --git a/02_ruway/shuma/sandbox/shuma-module-shell/examples/dump_shell.rs b/02_ruway/shuma/sandbox/shuma-module-shell/examples/dump_shell.rs index 8ec79d0..0261547 100644 --- a/02_ruway/shuma/sandbox/shuma-module-shell/examples/dump_shell.rs +++ b/02_ruway/shuma/sandbox/shuma-module-shell/examples/dump_shell.rs @@ -162,7 +162,7 @@ fn write_png(hal: &Hal, target: &wgpu::Texture, path: &str) { slice.map_async(wgpu::MapMode::Read, move |r| { let _ = tx.send(r); }); - hal.device.poll(wgpu::Maintain::Wait); + hal.device.poll(wgpu::PollType::wait_indefinitely()); rx.recv().unwrap().unwrap(); let data = slice.get_mapped_range(); let mut pixels = Vec::with_capacity((W * H * 4) as usize); diff --git a/02_ruway/shuma/sandbox/shuma-module-shell/examples/dump_stress.rs b/02_ruway/shuma/sandbox/shuma-module-shell/examples/dump_stress.rs new file mode 100644 index 0000000..60901eb --- /dev/null +++ b/02_ruway/shuma/sandbox/shuma-module-shell/examples/dump_stress.rs @@ -0,0 +1,151 @@ +//! Dump de estrés temporal: reproduce (1) espacio final en el input, +//! (2) output largo que podría pisar el input, (3) línea muy larga que +//! wrappea y se pisa con la de abajo. Igual pipeline que dump_shell. + +use std::fs::File; +use std::io::BufWriter; + +use llimphi_ui::llimphi_compositor::{measure_text_node, mount, paint}; +use llimphi_ui::llimphi_hal::{wgpu, Hal}; +use llimphi_ui::llimphi_layout::taffy; +use llimphi_ui::llimphi_layout::LayoutTree; +use llimphi_ui::llimphi_raster::peniko::Color; +use llimphi_ui::llimphi_raster::{vello, Renderer}; +use llimphi_ui::llimphi_text::Typesetter; + +const W: u32 = 1000; +const H: u32 = 640; +const FMT: wgpu::TextureFormat = wgpu::TextureFormat::Rgba8Unorm; + +fn main() { + let out = std::env::args().nth(1).unwrap_or_else(|| "stress.png".to_string()); + let theme = llimphi_theme::Theme::default(); + let mut state = shuma_module_shell::State::new(shuma_module::Source::Local); + use shuma_module_shell::OutputLine; + + // (3) Una línea muy larga que debería wrappear y pisar lo de abajo. + let block = 1u64; + let push = |state: &mut shuma_module_shell::State, mut l: OutputLine, b: u64| { + l.block = b; + state.output.push(l); + }; + let larga = "esta es una linea de salida deliberadamente muy larga que supera el ancho del panel para forzar el wrap y ver si se pisa con la siguiente linea de abajo del output del shell"; + // 3a. Línea suelta larga (block 0, sin Prompt) → render_output_line (altura + // fija 16px + text_aligned) → wrappea y pisa la de abajo. + push(&mut state, OutputLine::stdout(larga), 0); + push(&mut state, OutputLine::stdout("SUELTA-DE-ABAJO-NO-DEBERIA-PISARSE"), 0); + // 3b. Etapa capturada larga (stage row de altura fija) → mismo wrap. + push(&mut state, OutputLine::prompt("$ cat archivo | grep x"), block); + push(&mut state, OutputLine::stage_stdout(0, larga), block); + push(&mut state, OutputLine::stage_stdout(0, "ETAPA-DE-ABAJO-NO-DEBERIA-PISARSE"), block); + push(&mut state, OutputLine::stdout("resultado final corto"), block); + push(&mut state, OutputLine::notice("✔ exit 0"), block); + state.expanded_stages.insert((block, 0)); + + // (2) Output largo: muchos comandos cortos para llenar y empujar al input. + for b in 2u64..=14 { + push(&mut state, OutputLine::prompt(&format!("$ cmd-{b}")), b); + push(&mut state, OutputLine::stdout(&format!("salida del comando {b}")), b); + push(&mut state, OutputLine::notice("✔ exit 0"), b); + state.block_started.insert(b, 0); + } + state.block_seq = 14; + state.current_block = 14; + state.block_started.insert(block, 0); + + // (1) Input que termina en espacio — ¿se ve el espacio / avanza el caret? + state.input.set_text("echo hola "); + + let v = shuma_module_shell::view::<()>(&state, &theme, |_m| ()); + let mut layout = LayoutTree::new(); + let mounted = mount(&mut layout, v); + let mut ts = Typesetter::new(); + let computed = { + let tmap = &mounted.text_measures; + layout + .compute_with_measure(mounted.root, (W as f32, H as f32), |nid, known, avail| { + match tmap.get(&nid) { + Some(tm) => measure_text_node(&mut ts, tm, known, avail), + None => taffy::Size::ZERO, + } + }) + .expect("layout") + }; + let mut scene = vello::Scene::new(); + paint(&mut scene, &mounted, &computed, &mut ts, None, None); + + let hal = pollster::block_on(Hal::new(None)).expect("hal"); + let mut renderer = Renderer::new(&hal).expect("renderer"); + let target = hal.device.create_texture(&wgpu::TextureDescriptor { + label: Some("dump-stress"), + size: wgpu::Extent3d { width: W, height: H, depth_or_array_layers: 1 }, + mip_level_count: 1, + sample_count: 1, + dimension: wgpu::TextureDimension::D2, + format: FMT, + usage: wgpu::TextureUsages::STORAGE_BINDING + | wgpu::TextureUsages::RENDER_ATTACHMENT + | wgpu::TextureUsages::COPY_SRC, + view_formats: &[], + }); + let view = target.create_view(&wgpu::TextureViewDescriptor::default()); + renderer + .render_to_view(&hal, &scene, &view, W, H, Color::from_rgba8(20, 20, 26, 255)) + .expect("render_to_view"); + write_png(&hal, &target, &out); + eprintln!("dump_stress: escrito {out} ({W}x{H})"); +} + +fn write_png(hal: &Hal, target: &wgpu::Texture, path: &str) { + let unpadded = (W * 4) as usize; + let align = wgpu::COPY_BYTES_PER_ROW_ALIGNMENT as usize; + let padded = unpadded.div_ceil(align) * align; + let buf = hal.device.create_buffer(&wgpu::BufferDescriptor { + label: Some("readback"), + size: (padded * H as usize) as u64, + usage: wgpu::BufferUsages::MAP_READ | wgpu::BufferUsages::COPY_DST, + mapped_at_creation: false, + }); + let mut enc = hal + .device + .create_command_encoder(&wgpu::CommandEncoderDescriptor { label: None }); + enc.copy_texture_to_buffer( + wgpu::TexelCopyTextureInfo { + texture: target, + mip_level: 0, + origin: wgpu::Origin3d::ZERO, + aspect: wgpu::TextureAspect::All, + }, + wgpu::TexelCopyBufferInfo { + buffer: &buf, + layout: wgpu::TexelCopyBufferLayout { + offset: 0, + bytes_per_row: Some(padded as u32), + rows_per_image: Some(H), + }, + }, + wgpu::Extent3d { width: W, height: H, depth_or_array_layers: 1 }, + ); + hal.queue.submit(std::iter::once(enc.finish())); + let slice = buf.slice(..); + let (tx, rx) = std::sync::mpsc::channel(); + slice.map_async(wgpu::MapMode::Read, move |r| { + let _ = tx.send(r); + }); + hal.device.poll(wgpu::PollType::wait_indefinitely()); + rx.recv().unwrap().unwrap(); + let data = slice.get_mapped_range(); + let mut pixels = Vec::with_capacity((W * H * 4) as usize); + for row in 0..H as usize { + let s = row * padded; + pixels.extend_from_slice(&data[s..s + unpadded]); + } + drop(data); + buf.unmap(); + let file = File::create(path).expect("png"); + let mut enc = png::Encoder::new(BufWriter::new(file), W, H); + enc.set_color(png::ColorType::Rgba); + enc.set_depth(png::BitDepth::Eight); + let mut w = enc.write_header().unwrap(); + w.write_image_data(&pixels).unwrap(); +} diff --git a/02_ruway/shuma/sandbox/shuma-module-shell/examples/dump_surface.rs b/02_ruway/shuma/sandbox/shuma-module-shell/examples/dump_surface.rs new file mode 100644 index 0000000..d91ee3d --- /dev/null +++ b/02_ruway/shuma/sandbox/shuma-module-shell/examples/dump_surface.rs @@ -0,0 +1,238 @@ +//! Volcado headless del shell con la **superficie de terminal virtualizada** +//! activa (`SHUMA_TERMINAL_SURFACE`). Verificación obligatoria del SDD: simula +//! el **viewport medido** (`out_viewport_h` sembrado) + **scroll al fondo** +//! (`scroll_px = 0`), con un flood de miles de líneas + un bloque colapsado + +//! stderr, para que cualquier bug de scroll/anchor/negro salga a la luz. +//! +//! `cargo run -p shuma-module-shell --example dump_surface -- [out.png]` + +use std::fs::File; +use std::io::BufWriter; + +use llimphi_ui::llimphi_compositor::{measure_text_node, mount, paint}; +use llimphi_ui::llimphi_hal::{wgpu, Hal}; +use llimphi_ui::llimphi_layout::taffy; +use llimphi_ui::llimphi_layout::LayoutTree; +use llimphi_ui::llimphi_raster::peniko::Color; +use llimphi_ui::llimphi_raster::{vello, Renderer}; +use llimphi_ui::llimphi_text::Typesetter; + +use shuma_module_shell::OutputLine; + +const W: u32 = 1000; +const H: u32 = 640; +const FMT: wgpu::TextureFormat = wgpu::TextureFormat::Rgba8Unorm; + +fn main() { + // La superficie virtualizada es la única vía de output desde la Fase 5 + // (ya no hay flag ni `output_pane` legacy que activar). + + let out = std::env::args().nth(1).unwrap_or_else(|| "surface.png".to_string()); + let theme = llimphi_theme::Theme::default(); + let mut state = shuma_module_shell::State::new(shuma_module::Source::Local); + + let mut blk = 0u64; + let mut cmd = |state: &mut shuma_module_shell::State, + blk: &mut u64, + prompt: &str, + body: &[OutputLine], + close: &str| { + *blk += 1; + let b = *blk; + let mut p = OutputLine::prompt(prompt); + p.block = b; + state.output.push(p); + for l in body { + let mut l = l.clone(); + l.block = b; + state.output.push(l); + } + let mut n = OutputLine::notice(close); + n.block = b; + state.output.push(n); + b + }; + + cmd( + &mut state, + &mut blk, + "$ ls -la ~/tawasuyu", + &[ + OutputLine::stdout("total 248"), + OutputLine::stdout("drwxr-xr-x 12 sergio sergio 4096 00_unanchay"), + OutputLine::stdout("drwxr-xr-x 8 sergio sergio 4096 02_ruway"), + OutputLine::stdout("-rw-r--r-- 1 sergio sergio 11k CLAUDE.md"), + ], + "✔ exit 0", + ); + cmd( + &mut state, + &mut blk, + "$ cargo build -p llimphi-widget-terminal", + &[ + OutputLine::stdout(" Compiling llimphi-widget-terminal v0.1.0"), + OutputLine::stderr("warning: unused variable `x`"), + OutputLine::stdout(" Finished `dev` profile in 1.89s"), + ], + "✔ exit 0", + ); + + // FLOOD: un find con miles de líneas en un solo bloque. + blk += 1; + let flood = blk; + { + let mut p = OutputLine::prompt("$ find / -name '*.rs'"); + p.block = flood; + state.output.push(p); + for i in 0..3000 { + let mut l = OutputLine::stdout(&format!( + "/home/sergio/tawasuyu/02_ruway/llimphi/widgets/terminal/src/archivo_{i:05}.rs" + )); + l.block = flood; + state.output.push(l); + } + let mut n = OutputLine::notice("✔ exit 0"); + n.block = flood; + state.output.push(n); + } + + let git = cmd( + &mut state, + &mut blk, + "$ git status", + &[ + OutputLine::stdout("On branch main"), + OutputLine::stdout("Changes not staged for commit:"), + OutputLine::stdout(" modified: src/blocks.rs"), + ], + "✔ exit 0", + ); + state.collapsed.insert(git); // colapsado: sólo header + + cmd( + &mut state, + &mut blk, + "$ cat noexiste.txt", + &[OutputLine::stderr("cat: noexiste.txt: No such file or directory")], + "✘ exit 1", + ); + cmd( + &mut state, + &mut blk, + "$ echo listo", + &[OutputLine::stdout("listo")], + "✔ exit 0", + ); + + state.block_seq = blk; + state.current_block = blk; + + // Simular el viewport medido (el painter lo pondría el frame anterior) + + // anclado al fondo. ~520px es el alto del panel de output con este chrome. + if let Ok(mut g) = state.out_viewport_h.lock() { + *g = 520.0; + } + state.scroll_px = 0.0; + + let v = shuma_module_shell::view::<()>(&state, &theme, |_m| ()); + + let mut layout = LayoutTree::new(); + let mounted = mount(&mut layout, v); + let mut ts = Typesetter::new(); + let computed = { + let tmap = &mounted.text_measures; + layout + .compute_with_measure(mounted.root, (W as f32, H as f32), |nid, known, avail| { + match tmap.get(&nid) { + Some(tm) => measure_text_node(&mut ts, tm, known, avail), + None => taffy::Size::ZERO, + } + }) + .expect("layout") + }; + let mut scene = vello::Scene::new(); + paint(&mut scene, &mounted, &computed, &mut ts, None, None); + + let hal = pollster::block_on(Hal::new(None)).expect("hal"); + let mut renderer = Renderer::new(&hal).expect("renderer"); + let target = hal.device.create_texture(&wgpu::TextureDescriptor { + label: Some("dump-surface"), + size: wgpu::Extent3d { + width: W, + height: H, + depth_or_array_layers: 1, + }, + mip_level_count: 1, + sample_count: 1, + dimension: wgpu::TextureDimension::D2, + format: FMT, + usage: wgpu::TextureUsages::STORAGE_BINDING + | wgpu::TextureUsages::RENDER_ATTACHMENT + | wgpu::TextureUsages::COPY_SRC, + view_formats: &[], + }); + let view = target.create_view(&wgpu::TextureViewDescriptor::default()); + renderer + .render_to_view(&hal, &scene, &view, W, H, Color::from_rgba8(20, 20, 26, 255)) + .expect("render_to_view"); + write_png(&hal, &target, &out); + eprintln!("dump_surface: {out} ({W}x{H}) — {} líneas en {blk} comandos", state.output.len()); +} + +fn write_png(hal: &Hal, target: &wgpu::Texture, path: &str) { + let unpadded = (W * 4) as usize; + let align = wgpu::COPY_BYTES_PER_ROW_ALIGNMENT as usize; + let padded = unpadded.div_ceil(align) * align; + let buf = hal.device.create_buffer(&wgpu::BufferDescriptor { + label: Some("readback"), + size: (padded * H as usize) as u64, + usage: wgpu::BufferUsages::MAP_READ | wgpu::BufferUsages::COPY_DST, + mapped_at_creation: false, + }); + let mut enc = hal + .device + .create_command_encoder(&wgpu::CommandEncoderDescriptor { label: None }); + enc.copy_texture_to_buffer( + wgpu::TexelCopyTextureInfo { + texture: target, + mip_level: 0, + origin: wgpu::Origin3d::ZERO, + aspect: wgpu::TextureAspect::All, + }, + wgpu::TexelCopyBufferInfo { + buffer: &buf, + layout: wgpu::TexelCopyBufferLayout { + offset: 0, + bytes_per_row: Some(padded as u32), + rows_per_image: Some(H), + }, + }, + wgpu::Extent3d { + width: W, + height: H, + depth_or_array_layers: 1, + }, + ); + hal.queue.submit(std::iter::once(enc.finish())); + let slice = buf.slice(..); + let (tx, rx) = std::sync::mpsc::channel(); + slice.map_async(wgpu::MapMode::Read, move |r| { + let _ = tx.send(r); + }); + hal.device.poll(wgpu::PollType::wait_indefinitely()); + rx.recv().unwrap().unwrap(); + let data = slice.get_mapped_range(); + let mut pixels = Vec::with_capacity((W * H * 4) as usize); + for row in 0..H as usize { + let s = row * padded; + pixels.extend_from_slice(&data[s..s + unpadded]); + } + drop(data); + buf.unmap(); + let file = File::create(path).expect("png"); + let mut enc = png::Encoder::new(BufWriter::new(file), W, H); + enc.set_color(png::ColorType::Rgba); + enc.set_depth(png::BitDepth::Eight); + let mut w = enc.write_header().unwrap(); + w.write_image_data(&pixels).unwrap(); +} diff --git a/02_ruway/shuma/sandbox/shuma-module-shell/examples/pantallazo_compara.rs b/02_ruway/shuma/sandbox/shuma-module-shell/examples/pantallazo_compara.rs new file mode 100644 index 0000000..c016791 --- /dev/null +++ b/02_ruway/shuma/sandbox/shuma-module-shell/examples/pantallazo_compara.rs @@ -0,0 +1,174 @@ +//! Pantallazo headless del `:compara` (cotejo de pluma) en shuma: siembra dos +//! bloques con salidas parecidas, corre `:compara %c1 %c2` de verdad (vía +//! `Msg::RunLine`) y renderiza el bloque de cotejo side-by-side. +//! +//! `cargo run -p shuma-module-shell --example pantallazo_compara --release -- [out.png] [W] [H]` + +use std::fs::File; +use std::io::BufWriter; + +use llimphi_ui::llimphi_compositor::{measure_text_node, mount, paint}; +use llimphi_ui::llimphi_hal::{wgpu, Hal}; +use llimphi_ui::llimphi_layout::taffy; +use llimphi_ui::llimphi_layout::LayoutTree; +use llimphi_ui::llimphi_raster::peniko::Color; +use llimphi_ui::llimphi_raster::{vello, Renderer}; +use llimphi_ui::llimphi_text::Typesetter; + +use shuma_module_shell::{Msg, OutputLine}; + +const FMT: wgpu::TextureFormat = wgpu::TextureFormat::Rgba8Unorm; + +fn main() { + let out = std::env::args() + .nth(1) + .unwrap_or_else(|| "/tmp/shots/shuma_compara.png".to_string()); + let w: u32 = std::env::args().nth(2).and_then(|s| s.parse().ok()).unwrap_or(1100); + let h: u32 = std::env::args().nth(3).and_then(|s| s.parse().ok()).unwrap_or(420); + if let Some(dir) = std::path::Path::new(&out).parent() { + let _ = std::fs::create_dir_all(dir); + } + + let theme = llimphi_theme::Theme::default(); + let mut state = shuma_module_shell::State::new(shuma_module::Source::Local); + state.cwd = std::path::PathBuf::from("/home/sergio/proyectos"); + + // Dos corridas del mismo comando, con diferencias: una línea editada, una + // agregada y una eliminada (ancla idéntica desplazada → huecos reales). + let seed = |state: &mut shuma_module_shell::State, b: u64, prompt: &str, body: &[&str]| { + let mut p = OutputLine::prompt(prompt); + p.block = b; + state.output.push(p); + for t in body { + let mut l = OutputLine::stdout(*t); + l.block = b; + state.output.push(l); + } + let mut n = OutputLine::notice("✔ exit 0"); + n.block = b; + state.output.push(n); + state.block_command.insert(b, prompt.trim_start_matches("$ ").to_string()); + }; + seed( + &mut state, + 1, + "$ ./deploy.sh staging", + &["build: ok en 12.3s", "subiendo imagen v4.2", "migraciones: 3 aplicadas", "health: OK"], + ); + seed( + &mut state, + 2, + "$ ./deploy.sh prod", + &["build: ok en 11.8s", "warm cache", "subiendo imagen v4.2", "health: OK"], + ); + state.block_seq = 2; + state.current_block = 2; + + // SHOT_ANCHOR=1: mostrar el estado de "un clic" (bloque 1 marcado → su chip + // dice «⇄ elegido», el del bloque 2 «⇄ vs %c1»), sin disparar el cotejo. + // Por defecto: corre el cotejo de verdad y muestra su bloque. + if std::env::var("SHOT_ANCHOR").is_ok() { + state = shuma_module_shell::update(state, Msg::CompareWith(1)); + } else { + state = shuma_module_shell::update(state, Msg::RunLine(":compara %c1 %c2".to_string())); + } + state.input.set_text(""); + + if let Ok(mut g) = state.out_viewport_h.lock() { + *g = (h as f32 - 94.0).max(50.0); + } + state.scroll_px = 0.0; + + let v = shuma_module_shell::view::<()>(&state, &theme, |_m| ()); + let mut layout = LayoutTree::new(); + let mounted = mount(&mut layout, v); + let mut ts = Typesetter::new(); + let computed = { + let tmap = &mounted.text_measures; + layout + .compute_with_measure(mounted.root, (w as f32, h as f32), |nid, known, avail| { + match tmap.get(&nid) { + Some(tm) => measure_text_node(&mut ts, tm, known, avail), + None => taffy::Size::ZERO, + } + }) + .expect("layout") + }; + let mut scene = vello::Scene::new(); + paint(&mut scene, &mounted, &computed, &mut ts, None, None); + + let hal = pollster::block_on(Hal::new(None)).expect("hal"); + let mut renderer = Renderer::new(&hal).expect("renderer"); + let target = hal.device.create_texture(&wgpu::TextureDescriptor { + label: Some("pantallazo-compara"), + size: wgpu::Extent3d { width: w, height: h, depth_or_array_layers: 1 }, + mip_level_count: 1, + sample_count: 1, + dimension: wgpu::TextureDimension::D2, + format: FMT, + usage: wgpu::TextureUsages::STORAGE_BINDING + | wgpu::TextureUsages::RENDER_ATTACHMENT + | wgpu::TextureUsages::COPY_SRC, + view_formats: &[], + }); + let view = target.create_view(&wgpu::TextureViewDescriptor::default()); + renderer + .render_to_view(&hal, &scene, &view, w, h, Color::from_rgba8(20, 20, 26, 255)) + .expect("render_to_view"); + write_png(&hal, &target, &out, w, h); + eprintln!("pantallazo_compara: {out} ({w}x{h})"); +} + +fn write_png(hal: &Hal, target: &wgpu::Texture, path: &str, w: u32, h: u32) { + let unpadded = (w * 4) as usize; + let align = wgpu::COPY_BYTES_PER_ROW_ALIGNMENT as usize; + let padded = unpadded.div_ceil(align) * align; + let buf = hal.device.create_buffer(&wgpu::BufferDescriptor { + label: Some("readback"), + size: (padded * h as usize) as u64, + usage: wgpu::BufferUsages::MAP_READ | wgpu::BufferUsages::COPY_DST, + mapped_at_creation: false, + }); + let mut enc = hal + .device + .create_command_encoder(&wgpu::CommandEncoderDescriptor { label: None }); + enc.copy_texture_to_buffer( + wgpu::TexelCopyTextureInfo { + texture: target, + mip_level: 0, + origin: wgpu::Origin3d::ZERO, + aspect: wgpu::TextureAspect::All, + }, + wgpu::TexelCopyBufferInfo { + buffer: &buf, + layout: wgpu::TexelCopyBufferLayout { + offset: 0, + bytes_per_row: Some(padded as u32), + rows_per_image: Some(h), + }, + }, + wgpu::Extent3d { width: w, height: h, depth_or_array_layers: 1 }, + ); + hal.queue.submit(std::iter::once(enc.finish())); + let slice = buf.slice(..); + let (tx, rx) = std::sync::mpsc::channel(); + slice.map_async(wgpu::MapMode::Read, move |r| { + let _ = tx.send(r); + }); + hal.device.poll(wgpu::PollType::wait_indefinitely()); + rx.recv().unwrap().unwrap(); + let data = slice.get_mapped_range(); + let mut pixels = Vec::with_capacity((w * h * 4) as usize); + for row in 0..h as usize { + let s = row * padded; + pixels.extend_from_slice(&data[s..s + unpadded]); + } + drop(data); + buf.unmap(); + let file = File::create(path).expect("png"); + let mut enc = png::Encoder::new(BufWriter::new(file), w, h); + enc.set_color(png::ColorType::Rgba); + enc.set_depth(png::BitDepth::Eight); + let mut w = enc.write_header().unwrap(); + w.write_image_data(&pixels).unwrap(); +} diff --git a/02_ruway/shuma/sandbox/shuma-module-shell/examples/pantallazo_shell.rs b/02_ruway/shuma/sandbox/shuma-module-shell/examples/pantallazo_shell.rs new file mode 100644 index 0000000..9f567a6 --- /dev/null +++ b/02_ruway/shuma/sandbox/shuma-module-shell/examples/pantallazo_shell.rs @@ -0,0 +1,340 @@ +//! Pantallazo headless de shuma para material público: una sesión sembrada +//! creíble con los tres pilares de la superficie de bloques — +//! +//! 1. `ls -l` reconocido como **tabla ordenable** (headers clickeables, orden +//! activo por tamaño desc), +//! 2. `ls -R` partido en **sub-bloques colapsables** por directorio, +//! 3. un comando **corriendo en vivo** (streaming, badge ▶) sobre un proceso +//! real, además de un bloque colapsado entero y el prompt con texto. +//! +//! Mismo pipeline que `dump_surface`: view → mount → layout → vello → +//! readback → PNG. Corre sin display (llvmpipe). +//! +//! `cargo run -p shuma-module-shell --example pantallazo_shell --release -- [out.png]` + +use std::fs::File; +use std::io::BufWriter; +use std::sync::{Arc, Mutex}; + +use llimphi_ui::llimphi_compositor::{measure_text_node, mount, paint}; +use llimphi_ui::llimphi_hal::{wgpu, Hal}; +use llimphi_ui::llimphi_layout::taffy; +use llimphi_ui::llimphi_layout::LayoutTree; +use llimphi_ui::llimphi_raster::peniko::Color; +use llimphi_ui::llimphi_raster::{vello, Renderer}; +use llimphi_ui::llimphi_text::Typesetter; + +use shuma_module_shell::{ActiveRun, BackendHandle, OutputLine}; + +/// Tamaño del lienzo: `--width`/`--height` por args 2 y 3 (default 1280×800). +/// Permite reproducir layouts con ventana chica (p. ej. media pantalla). +fn shot_size() -> (u32, u32) { + let w = std::env::args() + .nth(2) + .and_then(|s| s.parse().ok()) + .unwrap_or(1280); + let h = std::env::args() + .nth(3) + .and_then(|s| s.parse().ok()) + .unwrap_or(800); + (w, h) +} + +const FMT: wgpu::TextureFormat = wgpu::TextureFormat::Rgba8Unorm; + +fn now_secs() -> u64 { + std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .map(|d| d.as_secs()) + .unwrap_or(0) +} + +fn main() { + // La superficie virtualizada es la única vía de output desde la Fase 5 + // (ya no hay flag ni `output_pane` legacy que activar). + let out = std::env::args() + .nth(1) + .unwrap_or_else(|| "/tmp/shots/shuma.png".to_string()); + let (w, h) = shot_size(); + if let Some(dir) = std::path::Path::new(&out).parent() { + let _ = std::fs::create_dir_all(dir); + } + + let theme = llimphi_theme::Theme::default(); + let mut state = shuma_module_shell::State::new(shuma_module::Source::Local); + // cwd presentable para material público (el header lo muestra). + state.cwd = std::path::PathBuf::from("/home/sergio/proyectos"); + let now = now_secs(); + + // Helper: abre un bloque con prompt + body + notice de cierre opcional. + let mut blk = 0u64; + let mut cmd = |state: &mut shuma_module_shell::State, + blk: &mut u64, + started_ago: u64, + prompt: &str, + body: &[OutputLine], + close: Option<&str>| { + *blk += 1; + let b = *blk; + let mut p = OutputLine::prompt(prompt); + p.block = b; + state.output.push(p); + for l in body { + let mut l = l.clone(); + l.block = b; + state.output.push(l); + } + if let Some(close) = close { + let mut n = OutputLine::notice(close); + n.block = b; + state.output.push(n); + } + state.block_started.insert(b, now.saturating_sub(started_ago)); + state + .block_command + .insert(b, prompt.trim_start_matches("$ ").to_string()); + b + }; + + // ── Bloque 1: `ls -l` → tabla ordenable (orden activo: size desc) ── + let tabla = cmd( + &mut state, + &mut blk, + 9 * 60, + "$ ls -l ~/proyectos", + &[ + OutputLine::stdout("total 248"), + OutputLine::stdout("drwxr-xr-x 4 sergio sergio 4096 jun 9 10:12 assets"), + OutputLine::stdout("-rw-r--r-- 1 sergio sergio 18342 jun 9 11:47 informe.md"), + OutputLine::stdout("-rw-r--r-- 1 sergio sergio 104214 jun 8 19:03 datos.csv"), + OutputLine::stdout("-rwxr-xr-x 1 sergio sergio 61288 jun 9 09:30 servidor"), + OutputLine::stdout("drwxr-xr-x 12 sergio sergio 4096 jun 7 16:55 src"), + OutputLine::stdout("-rw-r--r-- 1 sergio sergio 2931 jun 9 11:02 config.toml"), + OutputLine::stdout("-rw-r--r-- 1 sergio sergio 44102 jun 6 14:21 fotos.zip"), + OutputLine::stdout("drwxr-xr-x 2 sergio sergio 4096 jun 9 08:14 respaldos"), + OutputLine::stdout("-rw-r--r-- 1 sergio sergio 8210 jun 5 09:48 notas.txt"), + ], + Some("✔ exit 0"), + ); + // Orden activo por tamaño (col 4) descendente → flecha ▼ en el header. + state.section_sort.insert((tabla, 0), (4, false)); + + // ── Bloque 2: `ls -R` → sub-bloques colapsables por directorio ── + // `src` y `src/api` quedan expandidos; `src/api/v2` (profundidad ≥2) + // arranca colapsado por la heurística — se ve el chevron cerrado. + cmd( + &mut state, + &mut blk, + 3 * 60, + "$ ls -R src", + &[ + OutputLine::stdout("src:"), + OutputLine::stdout("main.rs lib.rs api modelos.rs"), + OutputLine::stdout("util.rs errores.rs config.rs"), + OutputLine::stdout(""), + OutputLine::stdout("src/api:"), + OutputLine::stdout("mod.rs rutas.rs sesiones.rs v2"), + OutputLine::stdout(""), + OutputLine::stdout("src/api/v2:"), + OutputLine::stdout("mod.rs handlers.rs esquema.rs"), + ], + Some("✔ exit 0"), + ); + + // ── Bloque extra: `git status` con cuerpo corto (coloreo semántico) ── + cmd( + &mut state, + &mut blk, + 2 * 60, + "$ git status", + &[ + OutputLine::stdout("On branch main"), + OutputLine::stdout("Changes not staged for commit:"), + OutputLine::stdout(" modified: src/api/rutas.rs"), + OutputLine::stdout(" modified: config.toml"), + ], + Some("✔ exit 0"), + ); + + // ── Bloque 3: comando entero colapsado (sólo header + badge) ── + let plegado = cmd( + &mut state, + &mut blk, + 60, + "$ git log --oneline -20", + &[ + OutputLine::stdout("a31f02c feat: tabla ordenable en bloques"), + OutputLine::stdout("99d7e10 fix: scroll anclado al fondo"), + ], + Some("✔ exit 0"), + ); + state.collapsed.insert(plegado); + + // ── Bloque 4: comando corriendo AHORA (streaming, sin notice de cierre) ── + let vivo = cmd( + &mut state, + &mut blk, + 4, + "$ cargo build --release", + &[ + OutputLine::stdout(" Compiling serde v1.0.219"), + OutputLine::stdout(" Compiling tokio v1.45.0"), + OutputLine::stdout(" Compiling rayon v1.10.0"), + OutputLine::stdout(" Compiling image v0.25.6"), + OutputLine::stdout(" Compiling wgpu v27.0.1"), + OutputLine::stdout(" Compiling vello v0.7.0"), + OutputLine::stdout(" Compiling parley v0.6.0"), + OutputLine::stdout(" Compiling taffy v0.7.7"), + OutputLine::stdout(" Compiling llimphi-ui v0.1.0"), + OutputLine::stdout(" Compiling llimphi-widget-terminal v0.1.0"), + OutputLine::stdout(" Compiling shuma-exec v0.1.0"), + OutputLine::stdout(" Compiling servidor v0.4.2 (/home/sergio/proyectos)"), + ], + None, // sin cierre: el run sigue vivo + ); + // Bytes ya streameados por el run vivo → badge "▶ 24 KB" en el header. + state.current_run_bytes = 24_576; + + state.block_seq = blk; + state.current_block = vivo; + + // Proceso REAL detrás del bloque vivo: `is_running()` debe dar true para + // que el header pinte el badge ▶. Un sleep largo que matamos al final. + // cwd REAL para el spawn (el cwd presentable del state puede no existir). + let spec = shuma_exec::CommandSpec::shell("sleep 60", "/tmp".to_string()); + let handle = shuma_exec::run(&spec); + let killer = handle.killer(); + state.running = Some(Arc::new(Mutex::new(ActiveRun { + handle: BackendHandle::Local(handle), + killer: Some(killer.clone()), + command: "cargo build --release".to_string(), + tui: None, + block: vivo, + session: None, + }))); + + // Prompt con el próximo comando a medio tipear (resaltado de sintaxis). + match std::env::var("SHOT_INPUT_LINES").ok().and_then(|v| v.parse::().ok()) { + Some(n) if n > 1 => { + let lines: Vec = (1..=n).map(|i| format!("echo linea {i} de un script pegado")).collect(); + state.input.set_text(lines.join("\n")); + } + _ => state.input.set_text("cargo test -p servidor"), + } + + // Viewport medido (lo pondría el painter el frame anterior) + pinned al + // fondo. ~680px = alto del panel de output con este chrome a 800px. + if let Ok(mut g) = state.out_viewport_h.lock() { + *g = (h as f32 - 94.0).max(50.0); + } + state.scroll_px = 0.0; + + let v = shuma_module_shell::view::<()>(&state, &theme, |_m| ()); + + // view → layout → scene → textura → PNG (misma secuencia que el eventloop). + let mut layout = LayoutTree::new(); + let mounted = mount(&mut layout, v); + let mut ts = Typesetter::new(); + let computed = { + let tmap = &mounted.text_measures; + layout + .compute_with_measure(mounted.root, (w as f32, h as f32), |nid, known, avail| { + match tmap.get(&nid) { + Some(tm) => measure_text_node(&mut ts, tm, known, avail), + None => taffy::Size::ZERO, + } + }) + .expect("layout") + }; + let mut scene = vello::Scene::new(); + paint(&mut scene, &mounted, &computed, &mut ts, None, None); + + let hal = pollster::block_on(Hal::new(None)).expect("hal"); + let mut renderer = Renderer::new(&hal).expect("renderer"); + let target = hal.device.create_texture(&wgpu::TextureDescriptor { + label: Some("pantallazo-shell"), + size: wgpu::Extent3d { + width: w, + height: h, + depth_or_array_layers: 1, + }, + mip_level_count: 1, + sample_count: 1, + dimension: wgpu::TextureDimension::D2, + format: FMT, + usage: wgpu::TextureUsages::STORAGE_BINDING + | wgpu::TextureUsages::RENDER_ATTACHMENT + | wgpu::TextureUsages::COPY_SRC, + view_formats: &[], + }); + let view = target.create_view(&wgpu::TextureViewDescriptor::default()); + renderer + .render_to_view(&hal, &scene, &view, w, h, Color::from_rgba8(20, 20, 26, 255)) + .expect("render_to_view"); + write_png(&hal, &target, &out, w, h); + + // Bajar el sleep antes de salir (no dejar huérfanos). + killer.kill(); + eprintln!( + "pantallazo_shell: {out} ({w}x{h}) — {} líneas en {blk} bloques", + state.output.len() + ); +} + +fn write_png(hal: &Hal, target: &wgpu::Texture, path: &str, w: u32, h: u32) { + let unpadded = (w * 4) as usize; + let align = wgpu::COPY_BYTES_PER_ROW_ALIGNMENT as usize; + let padded = unpadded.div_ceil(align) * align; + let buf = hal.device.create_buffer(&wgpu::BufferDescriptor { + label: Some("readback"), + size: (padded * h as usize) as u64, + usage: wgpu::BufferUsages::MAP_READ | wgpu::BufferUsages::COPY_DST, + mapped_at_creation: false, + }); + let mut enc = hal + .device + .create_command_encoder(&wgpu::CommandEncoderDescriptor { label: None }); + enc.copy_texture_to_buffer( + wgpu::TexelCopyTextureInfo { + texture: target, + mip_level: 0, + origin: wgpu::Origin3d::ZERO, + aspect: wgpu::TextureAspect::All, + }, + wgpu::TexelCopyBufferInfo { + buffer: &buf, + layout: wgpu::TexelCopyBufferLayout { + offset: 0, + bytes_per_row: Some(padded as u32), + rows_per_image: Some(h), + }, + }, + wgpu::Extent3d { + width: w, + height: h, + depth_or_array_layers: 1, + }, + ); + hal.queue.submit(std::iter::once(enc.finish())); + let slice = buf.slice(..); + let (tx, rx) = std::sync::mpsc::channel(); + slice.map_async(wgpu::MapMode::Read, move |r| { + let _ = tx.send(r); + }); + hal.device.poll(wgpu::PollType::wait_indefinitely()); + rx.recv().unwrap().unwrap(); + let data = slice.get_mapped_range(); + let mut pixels = Vec::with_capacity((w * h * 4) as usize); + for row in 0..h as usize { + let s = row * padded; + pixels.extend_from_slice(&data[s..s + unpadded]); + } + drop(data); + buf.unmap(); + let file = File::create(path).expect("png"); + let mut enc = png::Encoder::new(BufWriter::new(file), w, h); + enc.set_color(png::ColorType::Rgba); + enc.set_depth(png::BitDepth::Eight); + let mut w = enc.write_header().unwrap(); + w.write_image_data(&pixels).unwrap(); +} diff --git a/02_ruway/shuma/sandbox/shuma-module-shell/examples/pantallazo_tee.rs b/02_ruway/shuma/sandbox/shuma-module-shell/examples/pantallazo_tee.rs new file mode 100644 index 0000000..b37dff8 --- /dev/null +++ b/02_ruway/shuma/sandbox/shuma-module-shell/examples/pantallazo_tee.rs @@ -0,0 +1,201 @@ +//! Pantallazo headless de las etapas del tee **accionables** + la salida de IA +//! como bloque propio. Siembra: +//! +//! 1. Un pipe `cat | grep | sort` con captura por etapa: chips rotulados con su +//! índice `K`, la etapa 1 desplegada con sus líneas + la **fila de acciones** +//! (filtrar IA / copiar / guardar / explicar) que direcciona `%cN.K`. +//! 2. Un bloque de **respuesta de IA** (`:filtra`) teñido con el acento. +//! +//! `cargo run -p shuma-module-shell --example pantallazo_tee --release -- [out.png] [W] [H]` + +use std::fs::File; +use std::io::BufWriter; + +use llimphi_ui::llimphi_compositor::{measure_text_node, mount, paint}; +use llimphi_ui::llimphi_hal::{wgpu, Hal}; +use llimphi_ui::llimphi_layout::taffy; +use llimphi_ui::llimphi_layout::LayoutTree; +use llimphi_ui::llimphi_raster::peniko::Color; +use llimphi_ui::llimphi_raster::{vello, Renderer}; +use llimphi_ui::llimphi_text::Typesetter; + +use shuma_module_shell::OutputLine; + +const FMT: wgpu::TextureFormat = wgpu::TextureFormat::Rgba8Unorm; + +fn now_secs() -> u64 { + std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .map(|d| d.as_secs()) + .unwrap_or(0) +} + +fn main() { + let out = std::env::args() + .nth(1) + .unwrap_or_else(|| "/tmp/shots/shuma_tee.png".to_string()); + let w: u32 = std::env::args().nth(2).and_then(|s| s.parse().ok()).unwrap_or(1100); + let h: u32 = std::env::args().nth(3).and_then(|s| s.parse().ok()).unwrap_or(760); + if let Some(dir) = std::path::Path::new(&out).parent() { + let _ = std::fs::create_dir_all(dir); + } + + let theme = llimphi_theme::Theme::default(); + let mut state = shuma_module_shell::State::new(shuma_module::Source::Local); + state.cwd = std::path::PathBuf::from("/home/sergio/proyectos"); + let now = now_secs(); + + // ── Bloque 1: pipe con captura por etapa (tee) ────────────────────────── + let pipe_blk = 1u64; + let mut p = OutputLine::prompt("$ cat acceso.log | grep error | sort"); + p.block = pipe_blk; + state.output.push(p); + // Etapa 0 (cat): muchas líneas capturadas. + for t in [ + "10:01 GET / 200", + "10:02 GET /api error 500", + "10:03 GET /ok 200", + "10:04 POST /x error 503", + ] { + let mut l = OutputLine::stage_stdout(0, t); + l.block = pipe_blk; + state.output.push(l); + } + // Etapa 1 (grep): sólo las que matchean. + for t in ["10:02 GET /api error 500", "10:04 POST /x error 503"] { + let mut l = OutputLine::stage_stdout(1, t); + l.block = pipe_blk; + state.output.push(l); + } + // Cuerpo = salida final (sort), stdout normal. + for t in ["10:02 GET /api error 500", "10:04 POST /x error 503"] { + let mut l = OutputLine::stdout(t); + l.block = pipe_blk; + state.output.push(l); + } + let mut n = OutputLine::notice("✔ exit 0"); + n.block = pipe_blk; + state.output.push(n); + state.block_started.insert(pipe_blk, now.saturating_sub(120)); + state.block_command.insert(pipe_blk, "cat acceso.log | grep error | sort".to_string()); + // Etapa 1 desplegada → se ven sus líneas + la fila de acciones. + state.expanded_stages.insert((pipe_blk, 1)); + + // ── Bloque 2: respuesta de IA (:filtra) como bloque propio (Ai) ───────── + let ai_blk = 2u64; + let mut ph = OutputLine::prompt("🜲 :filtra «cuenta los errores por ruta» ← %c1.1"); + ph.block = ai_blk; + state.output.push(ph); + for t in ["/api → 1 error (500)", "/x → 1 error (503)", "total: 2 errores"] { + let mut l = OutputLine::ai(t); + l.block = ai_blk; + state.output.push(l); + } + state.block_started.insert(ai_blk, now.saturating_sub(20)); + state + .block_command + .insert(ai_blk, "🜲 :filtra «cuenta los errores por ruta» ← %c1.1".to_string()); + + state.block_seq = ai_blk; + state.current_block = ai_blk; + state.input.set_text(":predice"); + + if let Ok(mut g) = state.out_viewport_h.lock() { + *g = (h as f32 - 94.0).max(50.0); + } + state.scroll_px = 0.0; + + let v = shuma_module_shell::view::<()>(&state, &theme, |_m| ()); + + let mut layout = LayoutTree::new(); + let mounted = mount(&mut layout, v); + let mut ts = Typesetter::new(); + let computed = { + let tmap = &mounted.text_measures; + layout + .compute_with_measure(mounted.root, (w as f32, h as f32), |nid, known, avail| { + match tmap.get(&nid) { + Some(tm) => measure_text_node(&mut ts, tm, known, avail), + None => taffy::Size::ZERO, + } + }) + .expect("layout") + }; + let mut scene = vello::Scene::new(); + paint(&mut scene, &mounted, &computed, &mut ts, None, None); + + let hal = pollster::block_on(Hal::new(None)).expect("hal"); + let mut renderer = Renderer::new(&hal).expect("renderer"); + let target = hal.device.create_texture(&wgpu::TextureDescriptor { + label: Some("pantallazo-tee"), + size: wgpu::Extent3d { width: w, height: h, depth_or_array_layers: 1 }, + mip_level_count: 1, + sample_count: 1, + dimension: wgpu::TextureDimension::D2, + format: FMT, + usage: wgpu::TextureUsages::STORAGE_BINDING + | wgpu::TextureUsages::RENDER_ATTACHMENT + | wgpu::TextureUsages::COPY_SRC, + view_formats: &[], + }); + let view = target.create_view(&wgpu::TextureViewDescriptor::default()); + renderer + .render_to_view(&hal, &scene, &view, w, h, Color::from_rgba8(20, 20, 26, 255)) + .expect("render_to_view"); + write_png(&hal, &target, &out, w, h); + eprintln!("pantallazo_tee: {out} ({w}x{h}) — {} líneas", state.output.len()); +} + +fn write_png(hal: &Hal, target: &wgpu::Texture, path: &str, w: u32, h: u32) { + let unpadded = (w * 4) as usize; + let align = wgpu::COPY_BYTES_PER_ROW_ALIGNMENT as usize; + let padded = unpadded.div_ceil(align) * align; + let buf = hal.device.create_buffer(&wgpu::BufferDescriptor { + label: Some("readback"), + size: (padded * h as usize) as u64, + usage: wgpu::BufferUsages::MAP_READ | wgpu::BufferUsages::COPY_DST, + mapped_at_creation: false, + }); + let mut enc = hal + .device + .create_command_encoder(&wgpu::CommandEncoderDescriptor { label: None }); + enc.copy_texture_to_buffer( + wgpu::TexelCopyTextureInfo { + texture: target, + mip_level: 0, + origin: wgpu::Origin3d::ZERO, + aspect: wgpu::TextureAspect::All, + }, + wgpu::TexelCopyBufferInfo { + buffer: &buf, + layout: wgpu::TexelCopyBufferLayout { + offset: 0, + bytes_per_row: Some(padded as u32), + rows_per_image: Some(h), + }, + }, + wgpu::Extent3d { width: w, height: h, depth_or_array_layers: 1 }, + ); + hal.queue.submit(std::iter::once(enc.finish())); + let slice = buf.slice(..); + let (tx, rx) = std::sync::mpsc::channel(); + slice.map_async(wgpu::MapMode::Read, move |r| { + let _ = tx.send(r); + }); + hal.device.poll(wgpu::PollType::wait_indefinitely()); + rx.recv().unwrap().unwrap(); + let data = slice.get_mapped_range(); + let mut pixels = Vec::with_capacity((w * h * 4) as usize); + for row in 0..h as usize { + let s = row * padded; + pixels.extend_from_slice(&data[s..s + unpadded]); + } + drop(data); + buf.unmap(); + let file = File::create(path).expect("png"); + let mut enc = png::Encoder::new(BufWriter::new(file), w, h); + enc.set_color(png::ColorType::Rgba); + enc.set_depth(png::BitDepth::Eight); + let mut w = enc.write_header().unwrap(); + w.write_image_data(&pixels).unwrap(); +} diff --git a/02_ruway/shuma/sandbox/shuma-module-shell/examples/replay_desplanizado.rs b/02_ruway/shuma/sandbox/shuma-module-shell/examples/replay_desplanizado.rs new file mode 100644 index 0000000..1077663 --- /dev/null +++ b/02_ruway/shuma/sandbox/shuma-module-shell/examples/replay_desplanizado.rs @@ -0,0 +1,107 @@ +//! Sonda headless: reproduce un volcado crudo de PTY (`replay.bin`) por el +//! vt100 a las dimensiones reales del drawer y aplica LAS MISMAS heurísticas +//! de la vista viva (`input_box_top` + `es_turno` de `segmentar_grid_claude`) +//! para diagnosticar por qué el drawer pinta "TUI puro" sin paneles. +//! +//! `cargo run -p shuma-module-shell --example replay_desplanizado -- replay.bin 57 240` + +fn main() { + let mut args = std::env::args().skip(1); + let path = args.next().expect("uso: replay_desplanizado [rows] [cols]"); + let rows: u16 = args.next().and_then(|s| s.parse().ok()).unwrap_or(57); + let cols: u16 = args.next().and_then(|s| s.parse().ok()).unwrap_or(240); + let bytes = std::fs::read(&path).expect("leer replay"); + let mut parser = vt100::Parser::new(rows, cols, 10_000); + parser.process(&bytes); + let screen = parser.screen(); + + let fila_txt = |r: u16| -> String { + (0..cols) + .map(|c| match screen.cell(r, c) { + Some(cell) if !cell.contents().is_empty() => cell.contents(), + _ => " ".into(), + }) + .collect::() + }; + + // ── réplica de input_box_top (view/tui.rs) ── + let es_regla = |t: &str| { + let t = t.trim(); + t.chars().count() >= 3 && t.chars().all(|c| "─━═╌ ".contains(c)) + }; + let es_chrome_input = |t: &str| { + let t = t.trim(); + es_regla(t) + || t == "❯" + || t == ">" + || t.starts_with('⏸') + || t.contains("manual mode") + || t.contains("? for shortcuts") + || t.contains("esc to interrupt") + || t.is_empty() + }; + let mut ultima = None; + for r in (0..rows).rev() { + if !fila_txt(r).trim().is_empty() { + ultima = Some(r); + break; + } + } + let ultima = ultima.unwrap_or(0); + let mut top: Option = None; + let mut r = ultima as i32; + let limite = (ultima as i32 - 8).max(0); + while r >= limite { + let t = fila_txt(r as u16); + if es_chrome_input(&t) { + if es_regla(&t) { + top = Some(r as u16); + } + r -= 1; + } else { + break; + } + } + println!("== ultima fila con contenido: {ultima} · input_box_top: {top:?} =="); + + // ── réplica de es_turno (segmentar_grid_claude) ── + let es_turno = |r: u16| -> bool { + let t = fila_txt(r); + let ts = t.trim_start(); + if ts.starts_with('❯') || ts.starts_with('>') { + return true; + } + let mut con_texto = 0usize; + let mut resaltadas = 0usize; + for c in 0..cols { + if let Some(cell) = screen.cell(r, c) { + if !cell.contents().trim().is_empty() { + con_texto += 1; + if !matches!(cell.bgcolor(), vt100::Color::Default) { + resaltadas += 1; + } + } + } + } + con_texto >= 2 && resaltadas * 2 >= con_texto + }; + + let fin = top.unwrap_or(rows).min(rows); + let mut turnos = 0; + for r in 0..fin { + let t = fila_txt(r); + let tt = t.trim_end(); + if tt.trim().is_empty() { + continue; + } + let turno = es_turno(r); + if turno { + turnos += 1; + } + let marca = if turno { "TURNO" } else { " " }; + let corto: String = tt.chars().take(110).collect(); + println!("r{r:02} {marca} |{corto}"); + } + println!("== filas visibles: {fin} · turnos detectados: {turnos} =="); + println!("== alt_screen: {} ==", screen.alternate_screen()); +} diff --git a/02_ruway/shuma/sandbox/shuma-module-shell/examples/scrollback_db.rs b/02_ruway/shuma/sandbox/shuma-module-shell/examples/scrollback_db.rs new file mode 100644 index 0000000..998e7bc --- /dev/null +++ b/02_ruway/shuma/sandbox/shuma-module-shell/examples/scrollback_db.rs @@ -0,0 +1,158 @@ +//! Verificación headless del **scrollback como base de datos (E2)**: cuando un +//! comando falla por `command not found`, el shell ofrece bajo el bloque una +//! fila clickeable con el binario más cercano (Damerau-Levenshtein, priorizando +//! el historial). Render del `view()` completo a PNG. +//! +//! `cargo run -p shuma-module-shell --example scrollback_db -- [out.png]` + +use std::fs::File; +use std::io::BufWriter; + +use llimphi_ui::llimphi_compositor::{measure_text_node, mount, paint}; +use llimphi_ui::llimphi_hal::{wgpu, Hal}; +use llimphi_ui::llimphi_layout::taffy; +use llimphi_ui::llimphi_layout::LayoutTree; +use llimphi_ui::llimphi_raster::peniko::Color; +use llimphi_ui::llimphi_raster::{vello, Renderer}; +use llimphi_ui::llimphi_text::Typesetter; + +use shuma_module_shell::OutputLine; + +const W: u32 = 1000; +const H: u32 = 420; +const FMT: wgpu::TextureFormat = wgpu::TextureFormat::Rgba8Unorm; + +fn main() { + let out = std::env::args().nth(1).unwrap_or_else(|| "scrollback_db.png".to_string()); + let theme = llimphi_theme::Theme::default(); + let mut state = shuma_module_shell::State::new(shuma_module::Source::Local); + + // Bloques con stdout (referenciables por %cN) + una consulta viva. + let mut blk = 0u64; + let mut cmd = |state: &mut shuma_module_shell::State, blk: &mut u64, prompt: &str, body: &[OutputLine], close: &str| { + *blk += 1; + let b = *blk; + let mut p = OutputLine::prompt(prompt); + p.block = b; + state.output.push(p); + for l in body { + let mut l = l.clone(); + l.block = b; + state.output.push(l); + } + let mut n = OutputLine::notice(close); + n.block = b; + state.output.push(n); + }; + cmd(&mut state, &mut blk, "$ ls -l", &[ + OutputLine::stdout("-rw-r--r-- 1 sergio 4096 README.md"), + OutputLine::stdout("-rw-r--r-- 1 sergio 11240 CLAUDE.md"), + ], "\u{2714} exit 0"); + cmd(&mut state, &mut blk, "$ cargo build", &[ + OutputLine::stdout(" Compiling shuma v0.1.0"), + OutputLine::stderr("warning: unused variable `x`"), + OutputLine::stdout(" Finished in 3.2s"), + ], "\u{2714} exit 0"); + // El usuario está escribiendo una consulta sobre el bloque 2. + state.input.set_text("%c2 | grep warning"); + + state.block_seq = blk; + state.current_block = blk; + if let Ok(mut g) = state.out_viewport_h.lock() { + *g = 300.0; + } + state.scroll_px = 0.0; + + let v = shuma_module_shell::view::<()>(&state, &theme, |_m| ()); + let mut layout = LayoutTree::new(); + let mounted = mount(&mut layout, v); + let mut ts = Typesetter::new(); + let computed = { + let tmap = &mounted.text_measures; + layout + .compute_with_measure(mounted.root, (W as f32, H as f32), |nid, known, avail| { + match tmap.get(&nid) { + Some(tm) => measure_text_node(&mut ts, tm, known, avail), + None => taffy::Size::ZERO, + } + }) + .expect("layout") + }; + let mut scene = vello::Scene::new(); + paint(&mut scene, &mounted, &computed, &mut ts, None, None); + + let hal = pollster::block_on(Hal::new(None)).expect("hal"); + let mut renderer = Renderer::new(&hal).expect("renderer"); + let target = hal.device.create_texture(&wgpu::TextureDescriptor { + label: Some("choreo-chip"), + size: wgpu::Extent3d { width: W, height: H, depth_or_array_layers: 1 }, + mip_level_count: 1, + sample_count: 1, + dimension: wgpu::TextureDimension::D2, + format: FMT, + usage: wgpu::TextureUsages::STORAGE_BINDING + | wgpu::TextureUsages::RENDER_ATTACHMENT + | wgpu::TextureUsages::COPY_SRC, + view_formats: &[], + }); + let view = target.create_view(&wgpu::TextureViewDescriptor::default()); + renderer + .render_to_view(&hal, &scene, &view, W, H, Color::from_rgba8(20, 20, 26, 255)) + .expect("render_to_view"); + write_png(&hal, &target, &out); + eprintln!("scrollback_db: {out} ({W}x{H})"); +} + +fn write_png(hal: &Hal, target: &wgpu::Texture, path: &str) { + let unpadded = (W * 4) as usize; + let align = wgpu::COPY_BYTES_PER_ROW_ALIGNMENT as usize; + let padded = unpadded.div_ceil(align) * align; + let buf = hal.device.create_buffer(&wgpu::BufferDescriptor { + label: Some("readback"), + size: (padded * H as usize) as u64, + usage: wgpu::BufferUsages::MAP_READ | wgpu::BufferUsages::COPY_DST, + mapped_at_creation: false, + }); + let mut enc = hal + .device + .create_command_encoder(&wgpu::CommandEncoderDescriptor { label: None }); + enc.copy_texture_to_buffer( + wgpu::TexelCopyTextureInfo { + texture: target, + mip_level: 0, + origin: wgpu::Origin3d::ZERO, + aspect: wgpu::TextureAspect::All, + }, + wgpu::TexelCopyBufferInfo { + buffer: &buf, + layout: wgpu::TexelCopyBufferLayout { + offset: 0, + bytes_per_row: Some(padded as u32), + rows_per_image: Some(H), + }, + }, + wgpu::Extent3d { width: W, height: H, depth_or_array_layers: 1 }, + ); + hal.queue.submit(std::iter::once(enc.finish())); + let slice = buf.slice(..); + let (tx, rx) = std::sync::mpsc::channel(); + slice.map_async(wgpu::MapMode::Read, move |r| { + let _ = tx.send(r); + }); + hal.device.poll(wgpu::PollType::wait_indefinitely()); + rx.recv().unwrap().unwrap(); + let data = slice.get_mapped_range(); + let mut pixels = Vec::with_capacity((W * H * 4) as usize); + for row in 0..H as usize { + let s = row * padded; + pixels.extend_from_slice(&data[s..s + unpadded]); + } + drop(data); + buf.unmap(); + let file = File::create(path).expect("png"); + let mut enc = png::Encoder::new(BufWriter::new(file), W, H); + enc.set_color(png::ColorType::Rgba); + enc.set_depth(png::BitDepth::Eight); + let mut w = enc.write_header().unwrap(); + w.write_image_data(&pixels).unwrap(); +} diff --git a/02_ruway/shuma/sandbox/shuma-module-shell/examples/showreel.rs b/02_ruway/shuma/sandbox/shuma-module-shell/examples/showreel.rs new file mode 100644 index 0000000..d86b74f --- /dev/null +++ b/02_ruway/shuma/sandbox/shuma-module-shell/examples/showreel.rs @@ -0,0 +1,674 @@ +//! **Showreel** de shuma — para el README del repo standalone. NO es eye-candy +//! abstracto: es una vitrina de la **superficie de bloques REAL** del shell, en +//! acción. Cada frame reconstruye el `State` de shuma y lo pinta con la MISMA +//! función pública (`shuma_module_shell::view`) que usa el shell en producción: +//! header con cwd, bloques de comando (prompt + body), la tabla ordenable de +//! `ls -l` (headers clickeables + flecha de orden), los sub-bloques colapsables +//! de `ls -R`, el coloreo semántico de `git status`, un bloque entero plegado y +//! un comando **corriendo en vivo** (badge ▶ + bytes). El **estado** se deriva +//! del tiempo normalizado `t∈[0,1]`: los bloques aparecen con stagger, el +//! comando vivo va streameando líneas, el prompt se va tipeando, el output se +//! desplaza. No se dibuja una terminal falsa: si existe el render, se usa. +//! +//! Beats (timeline): +//! 1. cold-open: prompt sobrio + caret + trazo bezier draw-on (firma). +//! 2. los bloques aparecen con stagger (ls -l tabla, ls -R, git status…). +//! 3. el comando vivo `cargo build` streamea (badge ▶), el output scrollea. +//! 4. el próximo comando se tipea en el prompt. +//! 5. cierre: wordmark «shuma» + subtítulo, frame limpio para screenshot. +//! +//! Render headless y determinista (sin reloj, sin runtime, sin winit): frame +//! `i` de `N` → `t = i/(N-1)` → View → layout (taffy + parley) → vello::Scene → +//! wgpu → PNG. Idéntico al eventloop. +//! +//! ```text +//! cargo run -p shuma-module-shell --example showreel --release -- \ +//! [out_dir] [n_frames] [W] [H] +//! ``` +//! Defaults: `out_dir=showreel_frames_shuma`, `n_frames=300`, `W=1600`, `H=900`. + +use std::fs::{create_dir_all, File}; +use std::io::BufWriter; + +use llimphi_ui::llimphi_compositor::{measure_text_node, mount, paint, PaintRect}; +use llimphi_ui::llimphi_hal::{wgpu, Hal}; +use llimphi_ui::llimphi_layout::taffy; +use llimphi_ui::llimphi_layout::taffy::prelude::{length, percent, Position, Size, Style}; +use llimphi_ui::llimphi_layout::taffy::Rect as TaffyRect; +use llimphi_ui::llimphi_layout::LayoutTree; +use llimphi_ui::llimphi_raster::peniko::{self, Color}; +use llimphi_ui::llimphi_raster::vello::kurbo::{Affine, BezPath, Circle, Point, Stroke}; +use llimphi_ui::llimphi_raster::{vello, Renderer}; +use llimphi_ui::llimphi_text::{draw_layout_brush_xf, measurement, Alignment, Typesetter}; +use llimphi_ui::View; + +use llimphi_theme::{motion, Theme}; + +use shuma_module_shell::{OutputLine, State}; + +const FMT: wgpu::TextureFormat = wgpu::TextureFormat::Rgba8Unorm; + +// ───────────────────────── utilidades ───────────────────────── + +fn with_alpha(c: Color, a: f32) -> Color { + let [r, g, b, _] = c.components; + Color::new([r, g, b, a.clamp(0.0, 1.0)]) +} + +fn lerp(a: f64, b: f64, t: f64) -> f64 { + a + (b - a) * t +} + +/// Reescala `t` desde el subintervalo `[lo,hi]` a `[0,1]`, clampado. +fn seg(t: f32, lo: f32, hi: f32) -> f32 { + ((t - lo) / (hi - lo)).clamp(0.0, 1.0) +} + +// ───────────────────────── tema / skin ───────────────────────── + +#[derive(Clone)] +struct Skin { + accent: Color, + bg: Color, + fg: Color, + fg_muted: Color, +} + +// ───────────────────────── el guion de bloques ───────────────────────── + +/// Un bloque del guion: prompt + cuerpo + (opcional) notice de cierre. El +/// `started_ago` alimenta el "hace N min" del header; `collapsed`/`sort` son +/// los estados de la superficie. `live` marca el bloque que sigue corriendo. +struct Block { + started_ago: u64, + prompt: &'static str, + body: &'static [(&'static str, OutKind)], + close: Option<&'static str>, + collapsed: bool, + /// orden activo en su sección 0: `(col, asc)` → flecha en el header. + sort: Option<(usize, bool)>, + /// si el cuerpo se revela de a poco (streaming) en su beat. + live: bool, +} + +#[derive(Clone, Copy)] +enum OutKind { + Out, + Err, +} + +/// El guion completo del reel (mismo contenido creíble que `pantallazo_shell`). +fn script() -> Vec { + use OutKind::*; + vec![ + // ── 1: `ls -l` → tabla ordenable (orden activo: size desc) ── + Block { + started_ago: 9 * 60, + prompt: "$ ls -l ~/proyectos", + body: &[ + ("total 248", Out), + ("drwxr-xr-x 4 sergio sergio 4096 jun 9 10:12 assets", Out), + ("-rw-r--r-- 1 sergio sergio 18342 jun 9 11:47 informe.md", Out), + ("-rw-r--r-- 1 sergio sergio 104214 jun 8 19:03 datos.csv", Out), + ("-rwxr-xr-x 1 sergio sergio 61288 jun 9 09:30 servidor", Out), + ("drwxr-xr-x 12 sergio sergio 4096 jun 7 16:55 src", Out), + ("-rw-r--r-- 1 sergio sergio 2931 jun 9 11:02 config.toml", Out), + ("-rw-r--r-- 1 sergio sergio 44102 jun 6 14:21 fotos.zip", Out), + ("drwxr-xr-x 2 sergio sergio 4096 jun 9 08:14 respaldos", Out), + ("-rw-r--r-- 1 sergio sergio 8210 jun 5 09:48 notas.txt", Out), + ], + close: Some("✔ exit 0"), + collapsed: false, + sort: Some((4, false)), + live: false, + }, + // ── 2: `ls -R` → sub-bloques colapsables por directorio ── + Block { + started_ago: 3 * 60, + prompt: "$ ls -R src", + body: &[ + ("src:", Out), + ("main.rs lib.rs api modelos.rs", Out), + ("util.rs errores.rs config.rs", Out), + ("", Out), + ("src/api:", Out), + ("mod.rs rutas.rs sesiones.rs v2", Out), + ("", Out), + ("src/api/v2:", Out), + ("mod.rs handlers.rs esquema.rs", Out), + ], + close: Some("✔ exit 0"), + collapsed: false, + sort: None, + live: false, + }, + // ── 3: `git status` con coloreo semántico ── + Block { + started_ago: 2 * 60, + prompt: "$ git status", + body: &[ + ("On branch main", Out), + ("Changes not staged for commit:", Out), + (" modified: src/api/rutas.rs", Err), + (" modified: config.toml", Err), + ], + close: Some("✔ exit 0"), + collapsed: false, + sort: None, + live: false, + }, + // ── 4: comando entero colapsado (sólo header + badge) ── + Block { + started_ago: 60, + prompt: "$ git log --oneline -20", + body: &[ + ("a31f02c feat: tabla ordenable en bloques", Out), + ("99d7e10 fix: scroll anclado al fondo", Out), + ], + close: Some("✔ exit 0"), + collapsed: true, + sort: None, + live: false, + }, + // ── 5: comando corriendo AHORA (streaming, sin cierre) ── + Block { + started_ago: 4, + prompt: "$ cargo build --release", + body: &[ + (" Compiling serde v1.0.219", Out), + (" Compiling tokio v1.45.0", Out), + (" Compiling rayon v1.10.0", Out), + (" Compiling image v0.25.6", Out), + (" Compiling wgpu v27.0.1", Out), + (" Compiling vello v0.7.0", Out), + (" Compiling parley v0.6.0", Out), + (" Compiling taffy v0.7.7", Out), + (" Compiling llimphi-ui v0.1.0", Out), + (" Compiling shuma-exec v0.1.0", Out), + (" Compiling servidor v0.4.2 (/home/sergio/proyectos)", Out), + ], + close: None, + collapsed: false, + sort: None, + live: true, + }, + ] +} + +/// Construye el `State` para el tiempo `t`: los bloques aparecen con stagger +/// (cada uno tras el anterior), el cuerpo del bloque vivo se revela de a poco +/// (streaming), el prompt se va tipeando al final y el scroll sigue al fondo. +fn build_state(t: f32, vp_h: f32) -> State { + let mut state = State::new(shuma_module::Source::Local); + state.cwd = std::path::PathBuf::from("/home/sergio/proyectos"); + let now: u64 = 1_700_000_000; + + let blocks = script(); + let n = blocks.len(); + + // Ventana de bloques (12%–70%): los bloques entran con stagger. `reveal` + // ∈[0,n] como float; la parte entera = bloques completos, la fracción = + // progreso de revelado del bloque en curso. + let appear = motion::ease_out_cubic(seg(t, 0.12, 0.70)); + let reveal = appear * n as f32; + + let mut blk_id = 0u64; + for (idx, b) in blocks.iter().enumerate() { + let block_progress = (reveal - idx as f32).clamp(0.0, 1.0); + if block_progress <= 0.0 { + break; // este bloque y los siguientes aún no entraron + } + blk_id += 1; + let id = blk_id; + + // Prompt siempre presente apenas el bloque entra. + let mut p = OutputLine::prompt(b.prompt); + p.block = id; + state.output.push(p); + + // Cuántas líneas del cuerpo mostrar: el bloque vivo (el último) revela + // su cuerpo gradualmente con block_progress (streaming); los previos + // entran completos de una. + let total = b.body.len(); + let shown = if b.live { + ((block_progress * total as f32).ceil() as usize).min(total) + } else { + total + }; + for (text, kind) in b.body.iter().take(shown) { + let mut l = match kind { + OutKind::Out => OutputLine::stdout(*text), + OutKind::Err => OutputLine::stderr(*text), + }; + l.block = id; + state.output.push(l); + } + + // Notice de cierre sólo cuando el bloque ya entró del todo. + if block_progress >= 1.0 { + if let Some(close) = b.close { + let mut nce = OutputLine::notice(close); + nce.block = id; + state.output.push(nce); + } + } + + state.block_started.insert(id, now.saturating_sub(b.started_ago)); + state + .block_command + .insert(id, b.prompt.trim_start_matches("$ ").to_string()); + if let Some(close) = b.close { + if block_progress >= 1.0 { + let _ = close; + state.block_ended.insert(id, now); + } + } + if b.collapsed && block_progress >= 1.0 { + state.collapsed.insert(id); + } + if let Some(sort) = b.sort { + state.section_sort.insert((id, 0), sort); + } + if b.live { + state.current_block = id; + // badge ▶ con bytes que crecen con el streaming. + state.current_run_bytes = (block_progress * 26_624.0) as u64; + } + } + state.block_seq = blk_id; + + // Prompt: el próximo comando se va tipeando (beat 72%–88%). + let target = "cargo test -p servidor"; + let typed = seg(t, 0.72, 0.88); + let chars = (typed * target.chars().count() as f32).round() as usize; + let shown: String = target.chars().take(chars).collect(); + state.input.set_text(shown); + + // Viewport medido (lo pondría el painter del frame anterior). + if let Ok(mut g) = state.out_viewport_h.lock() { + *g = vp_h.max(50.0); + } + // Scroll dirigido por `t`: mientras entran los bloques (≤66%) anclamos + // ARRIBA (scroll_px grande → clampa al tope) para lucir la tabla ordenable + // de `ls -l`, el feature titular; después (66%–82%) bajamos suave al fondo + // (0.0 = pinned) para mostrar el comando vivo streameando. `scroll_px` es + // px desde el fondo: la superficie lo clampa al overflow real. + let to_bottom = motion::ease_in_out_cubic(seg(t, 0.66, 0.82)); + state.scroll_px = lerp(4000.0, 0.0, to_bottom as f64) as f32; + // Ancla del scroll (la superficie interpreta `scroll_px` relativo a ella); + // un valor alto la pone arriba del todo cuando `scroll_px` es grande. + state.surf_scroll_anchor = 4000.0; + + state +} + +// ───────────────────────── overlays vector (cold-open + wordmark) ───────────────────────── + +fn signature_path(cw: f64, ch: f64) -> BezPath { + let cx = cw / 2.0; + let cy = ch / 2.0; + let mut p = BezPath::new(); + p.move_to((cx - 360.0, cy + 40.0)); + p.curve_to( + (cx - 150.0, cy - 220.0), + (cx + 150.0, cy + 220.0), + (cx + 360.0, cy - 40.0), + ); + p +} + +fn trim_path(full: &BezPath, prog: f64) -> (BezPath, Point) { + use vello::kurbo::ParamCurve; + let prog = prog.clamp(0.0, 1.0); + let mut cubic = None; + let mut start = Point::ZERO; + for el in full.elements() { + match el { + vello::kurbo::PathEl::MoveTo(p) => start = *p, + vello::kurbo::PathEl::CurveTo(c1, c2, p) => { + cubic = Some(vello::kurbo::CubicBez::new(start, *c1, *c2, *p)); + } + _ => {} + } + } + let mut out = BezPath::new(); + let mut head = start; + if let Some(cb) = cubic { + out.move_to(cb.p0); + let steps = 96; + for i in 1..=steps { + let u = (i as f64 / steps as f64) * prog; + let pt = cb.eval(u); + out.line_to(pt); + head = pt; + } + } + (out, head) +} + +fn draw_overlays(scene: &mut vello::Scene, ts: &mut Typesetter, t: f32, cw: f64, ch: f64, s: &Skin) { + // ── COLD OPEN (0–12%) ────────────────────────────────────────── + let b1 = seg(t, 0.0, 0.12); + let line_vis = 1.0 - seg(t, 0.12, 0.19); + if line_vis > 0.001 { + let path = signature_path(cw, ch); + let draw_on = motion::ease_out_cubic(seg(t, 0.01, 0.13)) as f64; + let (trimmed, head) = trim_path(&path, draw_on); + let line_col = with_alpha(s.accent, 0.9 * line_vis); + scene.stroke(&Stroke::new(2.0), Affine::IDENTITY, line_col, None, &trimmed); + let pop = motion::ease_out_back(b1); + let r = (4.0 + 7.0 * pop as f64).max(0.0); + let dot_a = (b1 * line_vis).clamp(0.0, 1.0); + scene.fill( + peniko::Fill::NonZero, + Affine::IDENTITY, + with_alpha(s.accent, 0.18 * dot_a), + None, + &Circle::new(head, r * 3.2), + ); + scene.fill( + peniko::Fill::NonZero, + Affine::IDENTITY, + with_alpha(s.accent, dot_a), + None, + &Circle::new(head, r), + ); + } + + // Una pista textual durante el cold-open: prompt sobrio centrado, se + // desvanece cuando entran los bloques. + let prompt_a = seg(t, 0.03, 0.10) * (1.0 - seg(t, 0.12, 0.18)); + if prompt_a > 0.001 { + let psz = 30.0_f32; + let layout = ts.layout( + "shuma ❯ _", psz, None, Alignment::Start, 1.0, false, None, 500.0, false, false, 0.0, 0.0, + ); + let m = measurement(&layout); + let ox = (cw - m.width as f64) / 2.0; + let oy = ch / 2.0 + 70.0; + let brush = peniko::Brush::Solid(with_alpha(s.fg_muted, prompt_a)); + draw_layout_brush_xf(scene, &layout, &brush, Affine::translate((ox, oy))); + } + + // ── WORDMARK (84–100%) ───────────────────────────────────────── + let word_in = seg(t, 0.86, 0.96); + let word_a = motion::ease_out_cubic(word_in); + if word_a > 0.001 { + let size = 150.0_f32; + let layout = ts.layout( + "shuma", size, None, Alignment::Start, 1.0, false, None, 800.0, false, false, 0.0, 0.0, + ); + let m = measurement(&layout); + let rise = lerp(24.0, 0.0, word_a as f64); + let ox = (cw - m.width as f64) / 2.0; + let oy = (ch - m.height as f64) / 2.0 - 18.0 + rise; + let brush = peniko::Brush::Solid(with_alpha(s.fg, word_a)); + draw_layout_brush_xf(scene, &layout, &brush, Affine::translate((ox, oy))); + + let sub_a = motion::ease_out_cubic(seg(t, 0.90, 1.0)); + if sub_a > 0.001 { + let ssz = 26.0_f32; + let sub = ts.layout( + "a block-based terminal, in Rust", ssz, None, Alignment::Start, 1.0, false, None, + 500.0, false, false, 0.0, 0.0, + ); + let sm = measurement(&sub); + let dot_r = 6.0; + let block_w = sm.width as f64 + dot_r * 2.0 + 14.0; + let sx = (cw - block_w) / 2.0; + let sy = oy + m.height as f64 + 18.0; + scene.fill( + peniko::Fill::NonZero, + Affine::IDENTITY, + with_alpha(s.accent, sub_a), + None, + &Circle::new(Point::new(sx + dot_r, sy + ssz as f64 * 0.42), dot_r as f64), + ); + let sbrush = peniko::Brush::Solid(with_alpha(s.fg_muted, sub_a)); + draw_layout_brush_xf( + scene, + &sub, + &sbrush, + Affine::translate((sx + dot_r * 2.0 + 14.0, sy)), + ); + } + } + + // ── punto teal de firma (esquina inf-der) ─────── + let corner_a = seg(t, 0.04, 0.12) * (1.0 - seg(t, 0.82, 0.88)); + if corner_a > 0.001 { + let cx = cw - 54.0; + let cy = ch - 54.0; + scene.fill( + peniko::Fill::NonZero, + Affine::IDENTITY, + with_alpha(s.accent, 0.16 * corner_a), + None, + &Circle::new(Point::new(cx, cy), 18.0), + ); + scene.fill( + peniko::Fill::NonZero, + Affine::IDENTITY, + with_alpha(s.accent, 0.9 * corner_a), + None, + &Circle::new(Point::new(cx, cy), 6.0), + ); + } +} + +// ───────────────────────── la escena por frame ───────────────────────── + +fn build_view(t: f32, cw: f64, ch: f64, theme: &Theme, s: &Skin) -> View<()> { + // Slide/fade del shell: entra (10–20%), se desvanece antes del wordmark + // (82–88%). + let slide = motion::ease_out_cubic(seg(t, 0.10, 0.22)); + let shell_alpha = (slide * (1.0 - seg(t, 0.82, 0.88))).clamp(0.0, 1.0); + let shell_dy = lerp(14.0, 0.0, slide as f64); + + let mut children: Vec> = Vec::new(); + + if shell_alpha > 0.001 { + // Alto de viewport aproximado del panel de output con este chrome. + let vp_h = ch as f32 - 110.0; + let state = build_state(t, vp_h); + let shell = shuma_module_shell::view::<()>(&state, theme, |_m| ()); + let wrap = View::new(Style { + position: Position::Absolute, + inset: TaffyRect { + left: length(0.0), + top: length(0.0), + right: length(0.0), + bottom: length(0.0), + }, + size: Size { + width: percent(1.0_f32), + height: percent(1.0_f32), + }, + ..Default::default() + }) + .alpha(shell_alpha) + .transform(Affine::translate((0.0, shell_dy))) + .children(vec![shell]); + children.push(wrap); + } + + // Overlay full-screen del vector (cold-open + wordmark). + let overlay = View::new(Style { + position: Position::Absolute, + inset: TaffyRect { + left: length(0.0), + top: length(0.0), + right: length(0.0), + bottom: length(0.0), + }, + size: Size { + width: percent(1.0_f32), + height: percent(1.0_f32), + }, + ..Default::default() + }) + .paint_with({ + let s = s.clone(); + move |scene, ts, _rect: PaintRect| { + draw_overlays(scene, ts, t, cw, ch, &s); + } + }); + children.push(overlay); + + View::new(Style { + size: Size { + width: percent(1.0_f32), + height: percent(1.0_f32), + }, + position: Position::Relative, + ..Default::default() + }) + .fill(s.bg) + .children(children) +} + +fn main() { + let mut args = std::env::args().skip(1); + let out_dir = args + .next() + .unwrap_or_else(|| "showreel_frames_shuma".to_string()); + let n: usize = args.next().and_then(|v| v.parse().ok()).unwrap_or(300); + let w: u32 = args.next().and_then(|v| v.parse().ok()).unwrap_or(1600); + let h: u32 = args.next().and_then(|v| v.parse().ok()).unwrap_or(900); + create_dir_all(&out_dir).expect("mkdir out_dir"); + + // Aislar de la config/historial REAL del usuario: `State::new` carga el + // shumarc y el history desde XDG. Apuntamos XDG a un sandbox vacío para que + // el reel sea determinista y NO filtre comandos personales (la sugerencia de + // alias salía del historial real de la máquina). + let sandbox = std::env::temp_dir().join(format!("shuma-showreel-{}", std::process::id())); + let _ = std::fs::create_dir_all(&sandbox); + std::env::set_var("HOME", &sandbox); + std::env::set_var("XDG_CONFIG_HOME", sandbox.join("config")); + std::env::set_var("XDG_DATA_HOME", sandbox.join("data")); + std::env::set_var("XDG_STATE_HOME", sandbox.join("state")); + + let theme = Theme::by_name("Tawa").unwrap_or_default(); + let accent = theme.accent; + let skin = Skin { + accent, + bg: theme.bg_app, + fg: theme.fg_text, + fg_muted: theme.fg_muted, + }; + let [br, bg, bb, _] = skin.bg.components; + let base = Color::from_rgba8((br * 255.0) as u8, (bg * 255.0) as u8, (bb * 255.0) as u8, 255); + + let hal = pollster::block_on(Hal::new(None)).expect("hal"); + let mut renderer = Renderer::new(&hal).expect("renderer"); + let target = hal.device.create_texture(&wgpu::TextureDescriptor { + label: Some("showreel-shuma"), + size: wgpu::Extent3d { + width: w, + height: h, + depth_or_array_layers: 1, + }, + mip_level_count: 1, + sample_count: 1, + dimension: wgpu::TextureDimension::D2, + format: FMT, + usage: wgpu::TextureUsages::STORAGE_BINDING + | wgpu::TextureUsages::RENDER_ATTACHMENT + | wgpu::TextureUsages::COPY_SRC, + view_formats: &[], + }); + let view = target.create_view(&wgpu::TextureViewDescriptor::default()); + + let mut ts = Typesetter::new(); + let cw = w as f64; + let ch = h as f64; + + for i in 0..n { + let t = if n <= 1 { 0.0 } else { i as f32 / (n as f32 - 1.0) }; + let root = build_view(t, cw, ch, &theme, &skin); + + let mut layout = LayoutTree::new(); + let mounted = mount(&mut layout, root); + let computed = { + let tmap = &mounted.text_measures; + layout + .compute_with_measure(mounted.root, (w as f32, h as f32), |nid, known, avail| { + match tmap.get(&nid) { + Some(tm) => measure_text_node(&mut ts, tm, known, avail), + None => taffy::Size::ZERO, + } + }) + .expect("layout") + }; + let mut scene = vello::Scene::new(); + paint(&mut scene, &mounted, &computed, &mut ts, None, None); + + renderer + .render_to_view(&hal, &scene, &view, w, h, base) + .expect("render_to_view"); + let path = format!("{out_dir}/frame_{i:04}.png"); + write_png(&hal, &target, &path, w, h); + if i % 30 == 0 || i == n - 1 { + eprintln!("showreel-shuma: frame {}/{} (t={:.3})", i + 1, n, t); + } + } + eprintln!("showreel-shuma: {n} frames en {out_dir}/ ({w}x{h})"); +} + +fn write_png(hal: &Hal, target: &wgpu::Texture, path: &str, w: u32, h: u32) { + let unpadded = (w * 4) as usize; + let align = wgpu::COPY_BYTES_PER_ROW_ALIGNMENT as usize; + let padded = unpadded.div_ceil(align) * align; + let buf = hal.device.create_buffer(&wgpu::BufferDescriptor { + label: Some("readback"), + size: (padded * h as usize) as u64, + usage: wgpu::BufferUsages::MAP_READ | wgpu::BufferUsages::COPY_DST, + mapped_at_creation: false, + }); + let mut enc = hal + .device + .create_command_encoder(&wgpu::CommandEncoderDescriptor { label: None }); + enc.copy_texture_to_buffer( + wgpu::TexelCopyTextureInfo { + texture: target, + mip_level: 0, + origin: wgpu::Origin3d::ZERO, + aspect: wgpu::TextureAspect::All, + }, + wgpu::TexelCopyBufferInfo { + buffer: &buf, + layout: wgpu::TexelCopyBufferLayout { + offset: 0, + bytes_per_row: Some(padded as u32), + rows_per_image: Some(h), + }, + }, + wgpu::Extent3d { + width: w, + height: h, + depth_or_array_layers: 1, + }, + ); + hal.queue.submit(std::iter::once(enc.finish())); + let slice = buf.slice(..); + let (tx, rx) = std::sync::mpsc::channel(); + slice.map_async(wgpu::MapMode::Read, move |r| { + let _ = tx.send(r); + }); + let _ = hal.device.poll(wgpu::PollType::wait_indefinitely()); + rx.recv().unwrap().unwrap(); + let data = slice.get_mapped_range(); + let mut pixels = Vec::with_capacity((w * h * 4) as usize); + for r in 0..h as usize { + let sidx = r * padded; + pixels.extend_from_slice(&data[sidx..sidx + unpadded]); + } + drop(data); + buf.unmap(); + let file = File::create(path).expect("png"); + let mut enc = png::Encoder::new(BufWriter::new(file), w, h); + enc.set_color(png::ColorType::Rgba); + enc.set_depth(png::BitDepth::Eight); + let mut wr = enc.write_header().unwrap(); + wr.write_image_data(&pixels).unwrap(); +} diff --git a/02_ruway/shuma/sandbox/shuma-module-shell/examples/stats_e6.rs b/02_ruway/shuma/sandbox/shuma-module-shell/examples/stats_e6.rs new file mode 100644 index 0000000..dc56fe7 --- /dev/null +++ b/02_ruway/shuma/sandbox/shuma-module-shell/examples/stats_e6.rs @@ -0,0 +1,157 @@ +//! Verificación headless de **`:stats` (E6)**: la telemetría local del +//! historial renderizada como sección «resumen» + tabla ordenable «por +//! comando» (el mismo widget que `ls -l`). El detector de `sections.rs` +//! reconoce el comando `:stats` y parsea las filas tab-separadas que emite +//! `apply_stats`. Aquí sembramos esas líneas a mano (mismo formato que el +//! productor) para confirmar que el render llega a la tabla. +//! +//! `cargo run -p shuma-module-shell --example stats_e6 -- [out.png]` + +use std::fs::File; +use std::io::BufWriter; + +use llimphi_ui::llimphi_compositor::{measure_text_node, mount, paint}; +use llimphi_ui::llimphi_hal::{wgpu, Hal}; +use llimphi_ui::llimphi_layout::taffy; +use llimphi_ui::llimphi_layout::LayoutTree; +use llimphi_ui::llimphi_raster::peniko::Color; +use llimphi_ui::llimphi_raster::{vello, Renderer}; +use llimphi_ui::llimphi_text::Typesetter; + +use shuma_module_shell::OutputLine; + +const W: u32 = 1000; +const H: u32 = 460; +const FMT: wgpu::TextureFormat = wgpu::TextureFormat::Rgba8Unorm; + +fn main() { + let out = std::env::args().nth(1).unwrap_or_else(|| "stats_e6.png".to_string()); + let theme = llimphi_theme::Theme::default(); + let mut state = shuma_module_shell::State::new(shuma_module::Source::Local); + + // Bloque que emula la salida de `:stats` (formato exacto de apply_stats: + // 1 línea de resumen sin tab + header + filas tab-separadas). + let b = 1u64; + let mut p = OutputLine::prompt("$ :stats"); + p.block = b; + state.output.push(p); + let rows = [ + "412 comandos en historial · 9 binarios distintos · 380 con código de salida · pico 14–15h UTC", + "comando\tveces\tfallos\t%fallo\tp50ms\tp95ms\túltimo", + "cargo\t142\t11\t7\t1840\t9200\t3m", + "git\t98\t2\t2\t60\t180\t1m", + "ls\t54\t0\t0\t12\t40\tahora", + "rg\t31\t1\t3\t25\t90\t22m", + "shuma\t18\t0\t0\t450\t1200\t2h", + "ssh\t12\t3\t25\t820\t4100\t1d", + "podman\t9\t1\t11\t300\t2600\t4h", + ]; + for r in rows { + let mut l = OutputLine::stdout(r); + l.block = b; + state.output.push(l); + } + state + .block_command + .insert(b, "$ :stats".to_string()); + + state.block_seq = b; + state.current_block = b; + if let Ok(mut g) = state.out_viewport_h.lock() { + *g = 400.0; + } + state.scroll_px = 0.0; + + let v = shuma_module_shell::view::<()>(&state, &theme, |_m| ()); + let mut layout = LayoutTree::new(); + let mounted = mount(&mut layout, v); + let mut ts = Typesetter::new(); + let computed = { + let tmap = &mounted.text_measures; + layout + .compute_with_measure(mounted.root, (W as f32, H as f32), |nid, known, avail| { + match tmap.get(&nid) { + Some(tm) => measure_text_node(&mut ts, tm, known, avail), + None => taffy::Size::ZERO, + } + }) + .expect("layout") + }; + let mut scene = vello::Scene::new(); + paint(&mut scene, &mounted, &computed, &mut ts, None, None); + + let hal = pollster::block_on(Hal::new(None)).expect("hal"); + let mut renderer = Renderer::new(&hal).expect("renderer"); + let target = hal.device.create_texture(&wgpu::TextureDescriptor { + label: Some("stats-e6"), + size: wgpu::Extent3d { width: W, height: H, depth_or_array_layers: 1 }, + mip_level_count: 1, + sample_count: 1, + dimension: wgpu::TextureDimension::D2, + format: FMT, + usage: wgpu::TextureUsages::STORAGE_BINDING + | wgpu::TextureUsages::RENDER_ATTACHMENT + | wgpu::TextureUsages::COPY_SRC, + view_formats: &[], + }); + let view = target.create_view(&wgpu::TextureViewDescriptor::default()); + renderer + .render_to_view(&hal, &scene, &view, W, H, Color::from_rgba8(20, 20, 26, 255)) + .expect("render_to_view"); + write_png(&hal, &target, &out); + eprintln!("stats_e6: {out} ({W}x{H})"); +} + +fn write_png(hal: &Hal, target: &wgpu::Texture, path: &str) { + let unpadded = (W * 4) as usize; + let align = wgpu::COPY_BYTES_PER_ROW_ALIGNMENT as usize; + let padded = unpadded.div_ceil(align) * align; + let buf = hal.device.create_buffer(&wgpu::BufferDescriptor { + label: Some("readback"), + size: (padded * H as usize) as u64, + usage: wgpu::BufferUsages::MAP_READ | wgpu::BufferUsages::COPY_DST, + mapped_at_creation: false, + }); + let mut enc = hal + .device + .create_command_encoder(&wgpu::CommandEncoderDescriptor { label: None }); + enc.copy_texture_to_buffer( + wgpu::TexelCopyTextureInfo { + texture: target, + mip_level: 0, + origin: wgpu::Origin3d::ZERO, + aspect: wgpu::TextureAspect::All, + }, + wgpu::TexelCopyBufferInfo { + buffer: &buf, + layout: wgpu::TexelCopyBufferLayout { + offset: 0, + bytes_per_row: Some(padded as u32), + rows_per_image: Some(H), + }, + }, + wgpu::Extent3d { width: W, height: H, depth_or_array_layers: 1 }, + ); + hal.queue.submit(std::iter::once(enc.finish())); + let slice = buf.slice(..); + let (tx, rx) = std::sync::mpsc::channel(); + slice.map_async(wgpu::MapMode::Read, move |r| { + let _ = tx.send(r); + }); + hal.device.poll(wgpu::PollType::wait_indefinitely()); + rx.recv().unwrap().unwrap(); + let data = slice.get_mapped_range(); + let mut pixels = Vec::with_capacity((W * H * 4) as usize); + for row in 0..H as usize { + let s = row * padded; + pixels.extend_from_slice(&data[s..s + unpadded]); + } + drop(data); + buf.unmap(); + let file = File::create(path).expect("png"); + let mut enc = png::Encoder::new(BufWriter::new(file), W, H); + enc.set_color(png::ColorType::Rgba); + enc.set_depth(png::BitDepth::Eight); + let mut w = enc.write_header().unwrap(); + w.write_image_data(&pixels).unwrap(); +} diff --git a/02_ruway/shuma/sandbox/shuma-module-shell/examples/stats_input.rs b/02_ruway/shuma/sandbox/shuma-module-shell/examples/stats_input.rs new file mode 100644 index 0000000..f51cb19 --- /dev/null +++ b/02_ruway/shuma/sandbox/shuma-module-shell/examples/stats_input.rs @@ -0,0 +1,168 @@ +//! Sonda **numérica** del input del shell tras la mudanza al widget compartido. +//! +//! Renderiza headless sólo la línea de entrada y **cuenta píxeles por color**, +//! sin escribir ni mirar ninguna imagen: cada cosa que hay que certificar +//! (¿se pintó el texto?, ¿el comando salió en acento y el flag en amarillo?, +//! ¿hay caret?, ¿la caja creció al envolver?) es una cuenta que se lee como +//! texto. Mirar un PNG cuesta muchísimos más tokens y no diría más que esto. +//! +//! `cargo run -p shuma-module-shell --example stats_input --release` + +use llimphi_ui::llimphi_compositor::{measure_text_node, mount, paint}; +use llimphi_ui::llimphi_hal::{wgpu, Hal}; +use llimphi_ui::llimphi_layout::taffy; +use llimphi_ui::llimphi_layout::LayoutTree; +use llimphi_ui::llimphi_raster::peniko::Color; +use llimphi_ui::llimphi_raster::{vello, Renderer}; +use llimphi_ui::llimphi_text::Typesetter; + +const FMT: wgpu::TextureFormat = wgpu::TextureFormat::Rgba8Unorm; +const FONDO: Color = Color::from_rgba8(20, 20, 26, 255); + +/// Un caso: qué se tipea, con qué ancho, y qué esperamos ver. +struct Caso { + nombre: &'static str, + texto: &'static str, + w: u32, +} + +fn main() { + let theme = llimphi_theme::Theme::default(); + let hal = pollster::block_on(Hal::new(None)).expect("hal"); + let mut renderer = Renderer::new(&hal).expect("renderer"); + + let casos = [ + Caso { nombre: "vacio", texto: "", w: 900 }, + Caso { nombre: "comando con flag", texto: "cargo build --release", w: 900 }, + Caso { nombre: "envuelto (angosto)", texto: "cargo build --release --workspace --all-targets", w: 320 }, + ]; + + for caso in &casos { + let (w, h) = (caso.w, 120u32); + let mut state = shuma_module_shell::State::new(shuma_module::Source::Local); + state.focused = true; + state.input.set_text(caso.texto); + + // Dos pasadas: la primera mide la caja (el ajuste blando necesita saber + // su ancho), la segunda ya envuelve. Es el mismo camino de un cliente + // real, donde el segundo cuadro llega enseguida. + let mut pixels = Vec::new(); + for _ in 0..2 { + let v = shuma_module_shell::input_view::<()>(&state, &theme, |_m| ()); + let mut layout = LayoutTree::new(); + let mounted = mount(&mut layout, v); + let mut ts = Typesetter::new(); + let computed = { + let tmap = &mounted.text_measures; + layout + .compute_with_measure(mounted.root, (w as f32, h as f32), |nid, known, avail| { + match tmap.get(&nid) { + Some(tm) => measure_text_node(&mut ts, tm, known, avail), + None => taffy::Size::ZERO, + } + }) + .expect("layout") + }; + let mut scene = vello::Scene::new(); + paint(&mut scene, &mounted, &computed, &mut ts, None, None); + + let target = hal.device.create_texture(&wgpu::TextureDescriptor { + label: Some("stats-input"), + size: wgpu::Extent3d { width: w, height: h, depth_or_array_layers: 1 }, + mip_level_count: 1, + sample_count: 1, + dimension: wgpu::TextureDimension::D2, + format: FMT, + usage: wgpu::TextureUsages::STORAGE_BINDING + | wgpu::TextureUsages::RENDER_ATTACHMENT + | wgpu::TextureUsages::COPY_SRC, + view_formats: &[], + }); + let view = target.create_view(&wgpu::TextureViewDescriptor::default()); + renderer + .render_to_view(&hal, &scene, &view, w, h, FONDO) + .expect("render_to_view"); + pixels = leer(&hal, &target, w, h); + } + + let filas = shuma_module_shell::input_filas_visuales(&state); + let alto = shuma_module_shell::input_alto_px(&state); + let (avance, cols) = shuma_module_shell::input_avance_en_fila(&state); + let char_w = shuma_module_shell::input_char_w_px(&state); + + // Cuentas sobre el bitmap: cuánto se pintó y de qué colores. + let pintados = pixels + .chunks_exact(4) + .filter(|p| !casi_igual(p, FONDO)) + .count(); + let acento = cuenta(&pixels, theme.accent); + let amarillo = cuenta(&pixels, Color::from_rgba8(220, 200, 120, 255)); + + println!( + "{:<20} filas={filas} alto={alto:.1}px avance={avance}/{cols} char_w={char_w:.2}px \ + pintados={pintados} acento={acento} amarillo={amarillo}", + caso.nombre + ); + } +} + +/// ¿El píxel es (casi) ese color? Tolerancia amplia: el antialiasing de vello +/// mezcla con el fondo en los bordes de cada glifo. +fn casi_igual(p: &[u8], c: Color) -> bool { + let [r, g, b, _] = c.components; + let (cr, cg, cb) = ((r * 255.0) as i32, (g * 255.0) as i32, (b * 255.0) as i32); + (p[0] as i32 - cr).abs() <= 6 && (p[1] as i32 - cg).abs() <= 6 && (p[2] as i32 - cb).abs() <= 6 +} + +fn cuenta(pixels: &[u8], c: Color) -> usize { + pixels.chunks_exact(4).filter(|p| casi_igual(p, c)).count() +} + +fn leer(hal: &Hal, target: &wgpu::Texture, w: u32, h: u32) -> Vec { + let unpadded = (w * 4) as usize; + let align = wgpu::COPY_BYTES_PER_ROW_ALIGNMENT as usize; + let padded = unpadded.div_ceil(align) * align; + let buf = hal.device.create_buffer(&wgpu::BufferDescriptor { + label: Some("readback"), + size: (padded * h as usize) as u64, + usage: wgpu::BufferUsages::MAP_READ | wgpu::BufferUsages::COPY_DST, + mapped_at_creation: false, + }); + let mut enc = hal + .device + .create_command_encoder(&wgpu::CommandEncoderDescriptor { label: None }); + enc.copy_texture_to_buffer( + wgpu::TexelCopyTextureInfo { + texture: target, + mip_level: 0, + origin: wgpu::Origin3d::ZERO, + aspect: wgpu::TextureAspect::All, + }, + wgpu::TexelCopyBufferInfo { + buffer: &buf, + layout: wgpu::TexelCopyBufferLayout { + offset: 0, + bytes_per_row: Some(padded as u32), + rows_per_image: Some(h), + }, + }, + wgpu::Extent3d { width: w, height: h, depth_or_array_layers: 1 }, + ); + hal.queue.submit(std::iter::once(enc.finish())); + let slice = buf.slice(..); + let (tx, rx) = std::sync::mpsc::channel(); + slice.map_async(wgpu::MapMode::Read, move |r| { + let _ = tx.send(r); + }); + let _ = hal.device.poll(wgpu::PollType::wait_indefinitely()); + rx.recv().unwrap().unwrap(); + let data = slice.get_mapped_range(); + let mut pixels = Vec::with_capacity((w * h * 4) as usize); + for row in 0..h as usize { + let s = row * padded; + pixels.extend_from_slice(&data[s..s + unpadded]); + } + drop(data); + buf.unmap(); + pixels +} diff --git a/02_ruway/shuma/sandbox/shuma-module-shell/examples/titular_a5.rs b/02_ruway/shuma/sandbox/shuma-module-shell/examples/titular_a5.rs new file mode 100644 index 0000000..ce627e5 --- /dev/null +++ b/02_ruway/shuma/sandbox/shuma-module-shell/examples/titular_a5.rs @@ -0,0 +1,211 @@ +//! Verificación headless del **titular semáforo (A5)**: bloques colapsados +//! cuyo header muestra el resumen contado de las decoraciones `Severity` +//! del cuerpo — errores/avisos/líneas/duración — coloreado como semáforo. +//! +//! `cargo run -p shuma-module-shell --example titular_a5 -- [out.png]` + +use std::fs::File; +use std::io::BufWriter; + +use llimphi_ui::llimphi_compositor::{measure_text_node, mount, paint}; +use llimphi_ui::llimphi_hal::{wgpu, Hal}; +use llimphi_ui::llimphi_layout::taffy; +use llimphi_ui::llimphi_layout::LayoutTree; +use llimphi_ui::llimphi_raster::peniko::Color; +use llimphi_ui::llimphi_raster::{vello, Renderer}; +use llimphi_ui::llimphi_text::Typesetter; + +use shuma_module_shell::OutputLine; + +const W: u32 = 1000; +const H: u32 = 420; +const FMT: wgpu::TextureFormat = wgpu::TextureFormat::Rgba8Unorm; + +fn main() { + let out = std::env::args().nth(1).unwrap_or_else(|| "titular_a5.png".to_string()); + let theme = llimphi_theme::Theme::default(); + let mut state = shuma_module_shell::State::new(shuma_module::Source::Local); + + let mut blk = 0u64; + let mut cmd = |state: &mut shuma_module_shell::State, + blk: &mut u64, + prompt: &str, + body: &[OutputLine], + close: &str, + dur: u64| { + *blk += 1; + let b = *blk; + let mut p = OutputLine::prompt(prompt); + p.block = b; + state.output.push(p); + for l in body { + let mut l = l.clone(); + l.block = b; + state.output.push(l); + } + let mut n = OutputLine::notice(close); + n.block = b; + state.output.push(n); + // Sembrar start/end para la duración del titular. + state.block_started.insert(b, 1000); + state.block_ended.insert(b, 1000 + dur); + state.collapsed.insert(b); // colapsado: sólo header + titular + b + }; + + // 1) Build con errores + avisos → titular ROJO. + cmd( + &mut state, + &mut blk, + "$ cargo build -p shuma", + &[ + OutputLine::stdout(" Compiling shuma v0.1.0"), + OutputLine::stderr("error[E0308]: mismatched types"), + OutputLine::stderr("error[E0599]: no method named `foo`"), + OutputLine::stderr("warning: unused variable `x`"), + OutputLine::stderr("warning: unused import `bar`"), + OutputLine::stderr("warning: deprecated function"), + OutputLine::stderr("error: could not compile `shuma`"), + ], + "✘ exit 101", + 4, + ); + + // 2) Build con sólo avisos → titular ÁMBAR. + cmd( + &mut state, + &mut blk, + "$ cargo check -p llimphi-ui", + &[ + OutputLine::stdout(" Checking llimphi-ui v0.1.0"), + OutputLine::stderr("warning: unused variable `tmp`"), + OutputLine::stderr("warning: field is never read"), + OutputLine::stdout(" Finished in 8.12s"), + ], + "✔ exit 0", + 9, + ); + + // 3) Comando limpio → titular TENUE. + cmd( + &mut state, + &mut blk, + "$ ls -la ~/tawasuyu", + &[ + OutputLine::stdout("total 248"), + OutputLine::stdout("drwxr-xr-x 12 sergio sergio 4096 00_unanchay"), + OutputLine::stdout("drwxr-xr-x 8 sergio sergio 4096 02_ruway"), + OutputLine::stdout("-rw-r--r-- 1 sergio sergio 11k CLAUDE.md"), + ], + "✔ exit 0", + 0, + ); + + state.block_seq = blk; + state.current_block = blk; + if let Ok(mut g) = state.out_viewport_h.lock() { + *g = 360.0; + } + state.scroll_px = 0.0; + + let v = shuma_module_shell::view::<()>(&state, &theme, |_m| ()); + let mut layout = LayoutTree::new(); + let mounted = mount(&mut layout, v); + let mut ts = Typesetter::new(); + let computed = { + let tmap = &mounted.text_measures; + layout + .compute_with_measure(mounted.root, (W as f32, H as f32), |nid, known, avail| { + match tmap.get(&nid) { + Some(tm) => measure_text_node(&mut ts, tm, known, avail), + None => taffy::Size::ZERO, + } + }) + .expect("layout") + }; + let mut scene = vello::Scene::new(); + paint(&mut scene, &mounted, &computed, &mut ts, None, None); + + let hal = pollster::block_on(Hal::new(None)).expect("hal"); + let mut renderer = Renderer::new(&hal).expect("renderer"); + let target = hal.device.create_texture(&wgpu::TextureDescriptor { + label: Some("titular-a5"), + size: wgpu::Extent3d { + width: W, + height: H, + depth_or_array_layers: 1, + }, + mip_level_count: 1, + sample_count: 1, + dimension: wgpu::TextureDimension::D2, + format: FMT, + usage: wgpu::TextureUsages::STORAGE_BINDING + | wgpu::TextureUsages::RENDER_ATTACHMENT + | wgpu::TextureUsages::COPY_SRC, + view_formats: &[], + }); + let view = target.create_view(&wgpu::TextureViewDescriptor::default()); + renderer + .render_to_view(&hal, &scene, &view, W, H, Color::from_rgba8(20, 20, 26, 255)) + .expect("render_to_view"); + write_png(&hal, &target, &out); + eprintln!("titular_a5: {out} ({W}x{H})"); +} + +fn write_png(hal: &Hal, target: &wgpu::Texture, path: &str) { + let unpadded = (W * 4) as usize; + let align = wgpu::COPY_BYTES_PER_ROW_ALIGNMENT as usize; + let padded = unpadded.div_ceil(align) * align; + let buf = hal.device.create_buffer(&wgpu::BufferDescriptor { + label: Some("readback"), + size: (padded * H as usize) as u64, + usage: wgpu::BufferUsages::MAP_READ | wgpu::BufferUsages::COPY_DST, + mapped_at_creation: false, + }); + let mut enc = hal + .device + .create_command_encoder(&wgpu::CommandEncoderDescriptor { label: None }); + enc.copy_texture_to_buffer( + wgpu::TexelCopyTextureInfo { + texture: target, + mip_level: 0, + origin: wgpu::Origin3d::ZERO, + aspect: wgpu::TextureAspect::All, + }, + wgpu::TexelCopyBufferInfo { + buffer: &buf, + layout: wgpu::TexelCopyBufferLayout { + offset: 0, + bytes_per_row: Some(padded as u32), + rows_per_image: Some(H), + }, + }, + wgpu::Extent3d { + width: W, + height: H, + depth_or_array_layers: 1, + }, + ); + hal.queue.submit(std::iter::once(enc.finish())); + let slice = buf.slice(..); + let (tx, rx) = std::sync::mpsc::channel(); + slice.map_async(wgpu::MapMode::Read, move |r| { + let _ = tx.send(r); + }); + hal.device.poll(wgpu::PollType::wait_indefinitely()); + rx.recv().unwrap().unwrap(); + let data = slice.get_mapped_range(); + let mut pixels = Vec::with_capacity((W * H * 4) as usize); + for row in 0..H as usize { + let s = row * padded; + pixels.extend_from_slice(&data[s..s + unpadded]); + } + drop(data); + buf.unmap(); + let file = File::create(path).expect("png"); + let mut enc = png::Encoder::new(BufWriter::new(file), W, H); + enc.set_color(png::ColorType::Rgba); + enc.set_depth(png::BitDepth::Eight); + let mut w = enc.write_header().unwrap(); + w.write_image_data(&pixels).unwrap(); +} diff --git a/02_ruway/shuma/sandbox/shuma-module-shell/src/app_icons.rs b/02_ruway/shuma/sandbox/shuma-module-shell/src/app_icons.rs new file mode 100644 index 0000000..300afe8 --- /dev/null +++ b/02_ruway/shuma/sandbox/shuma-module-shell/src/app_icons.rs @@ -0,0 +1,213 @@ +//! Resolución de íconos para entradas del menú de inicio / dock / spotlight. +//! +//! Las apps tawasuyu declaran su ícono como un glyph corto (`✶`, `▶`, etc.). +//! Las apps `.desktop` externas declaran un **nombre freedesktop** (`firefox`, +//! `google-chrome`, `org.gnome.Files`) que no es renderizable por sí solo: hay +//! que ubicarlo en algún tema XDG y cargar el archivo. La mayoría del software +//! del sistema trae el ícono como **PNG** (no SVG), así que resolvemos ambos. +//! Sin eso, pata caía al glyph genérico `▸` para casi toda `.desktop` — feo y +//! poco distintivo. +//! +//! Este módulo hace dos cosas: +//! +//! 1. **Resolución XDG mínima** ([`resolve_icon_path`]): busca un `.svg` o +//! `.png` en los paths canónicos del freedesktop icon theme spec, en orden +//! de prioridad (escalable primero, luego tamaños grandes a chicos). No +//! parseamos `index.theme`; tomamos atajos pragmáticos (Adwaita/Papirus/ +//! hicolor cubren el ~95% del software del sistema). +//! +//! 2. **Cache de assets parseados** ([`get_or_load`]): parsear SVG / decodificar +//! PNG no es gratis. Una lista de 80 apps reparseando en cada frame mata el +//! thread de UI; el cache convierte ese costo en "parsea una vez, stampea N". +//! Cacheamos también los nombres que fallaron (`None`) para no re-walkear el +//! filesystem 60 veces por segundo buscando un ícono que no existe. + +use std::collections::HashMap; +use std::path::{Path, PathBuf}; +use std::sync::Mutex; + +use llimphi_image::Image; +use llimphi_svg::SvgAsset; +use llimphi_ui::llimphi_layout::taffy::prelude::{percent, Size, Style}; +use llimphi_ui::{ImageFit, View}; + +/// Un ícono de app ya resuelto y listo para pintar: vector (SVG) o raster +/// (PNG decodificado). Ambos son baratos de clonar (`Arc` internamente). +#[derive(Clone)] +pub enum AppIcon { + Svg(SvgAsset), + Raster(Image), +} + +impl AppIcon { + /// Una `View` que pinta el ícono, ajustado al contenedor (`Contain` para + /// el raster — preserva el aspect ratio sin recortar). + pub fn view(&self) -> View { + match self { + AppIcon::Svg(a) => a.view::(), + AppIcon::Raster(img) => View::new(Style { + size: Size { width: percent(1.0_f32), height: percent(1.0_f32) }, + ..Default::default() + }) + .image(img.clone()) + .image_fit(ImageFit::Contain), + } + } +} + +/// Cache singleton de íconos resueltos. Key = nombre freedesktop crudo (lo que +/// dice `.desktop`'s `Icon=…`). Value: +/// - `Some(icon)` si lo encontramos y parseó/decodificó. +/// - `None` si no pudimos resolverlo o falló el load — cacheado para no +/// repetir el trabajo cada frame. +fn cache() -> &'static Mutex>> { + use std::sync::OnceLock; + static CACHE: OnceLock>>> = OnceLock::new(); + CACHE.get_or_init(|| Mutex::new(HashMap::new())) +} + +/// Tope de tamaño en disco de un ícono raster (defensa contra un PNG gigante +/// que reviente la RAM del thread de UI). 4 MiB sobra para cualquier ícono. +const MAX_ICON_BYTES: u64 = 4 * 1024 * 1024; + +/// Devuelve el [`AppIcon`] para el nombre freedesktop dado, parseando/ +/// decodificando una vez y cacheando para el resto del proceso. `None` si el +/// nombre no resuelve a un archivo válido. **NO bloquea** sobre el filesystem +/// en frames siguientes: el resultado (positivo o negativo) queda fijo hasta +/// que la app reinicie. +pub fn get_or_load(name: &str) -> Option { + if name.is_empty() { + return None; + } + { + let guard = cache().lock().ok()?; + if let Some(slot) = guard.get(name) { + return slot.clone(); + } + } + // Si el nombre ya es un path absoluto a un ícono válido, lo usamos directo — + // algunos `.desktop` ponen `Icon=/usr/share/foo/icon.png`. + let resolved = if name.starts_with('/') { + let p = PathBuf::from(name); + p.is_file().then_some(p) + } else { + resolve_icon_path(name) + }; + let icon = resolved.and_then(|p| load_icon_file(&p)); + if let Ok(mut guard) = cache().lock() { + guard.insert(name.to_string(), icon.clone()); + } + icon +} + +/// Carga un archivo de ícono según su extensión: SVG → [`SvgAsset`]; cualquier +/// otra cosa (png/jpg/…) → decode raster a `peniko::Image`. +fn load_icon_file(p: &Path) -> Option { + let ext = p.extension().and_then(|e| e.to_str()).unwrap_or("").to_ascii_lowercase(); + if ext == "svg" || ext == "svgz" { + let s = std::fs::read_to_string(p).ok()?; + SvgAsset::from_str(&s).ok().map(AppIcon::Svg) + } else { + llimphi_image::load_path(p, MAX_ICON_BYTES).ok().map(AppIcon::Raster) + } +} + +/// Busca un ícono (`.svg` o `.png`) para `name` en los paths canónicos XDG. No +/// parsea `index.theme`; chequea themes en orden de preferencia × subdirs por +/// tamaño (escalable/grande→chico), probando `.svg` antes que `.png` dentro de +/// cada carpeta. Pública para tests. `None` si nada encaja. +pub fn resolve_icon_path(name: &str) -> Option { + // Themes en orden de preferencia: Adwaita (GNOME), Papirus (popular), + // breeze (KDE) y hicolor (el fallback obligatorio del spec). + const THEMES: &[&str] = &[ + "Adwaita", + // Adwaita moderno dejó de traer muchos íconos fullcolor de app/categoría; + // AdwaitaLegacy (instalado junto con Adwaita) los conserva en su contexto + // `legacy/`. Lo buscamos para que resuelvan `applications-*` y similares. + "AdwaitaLegacy", + "Papirus", + "Papirus-Dark", + "breeze", + "breeze-dark", + "hicolor", + ]; + // Subdirs por contexto × tamaño. `apps` primero (la mayoría de los íconos de + // app); luego `categories` y `legacy` (íconos de categoría freedesktop como + // `applications-multimedia`, que viven ahí, no en `apps`). SVG antes que + // raster, y raster de grande a chico (mejor nitidez al escalar). + const SUBDIRS: &[&str] = &[ + "scalable/apps", + "512x512/apps", + "256x256/apps", + "128x128/apps", + "96x96/apps", + "64x64/apps", + "48x48/apps", + "symbolic/apps", + "scalable/categories", + "64x64/categories", + "48x48/categories", + "32x32/categories", + "24x24/categories", + "22x22/categories", + "scalable/legacy", + "48x48/legacy", + "32x32/legacy", + "24x24/legacy", + "22x22/legacy", + ]; + // Extensiones, en orden: vector primero (escala sin perder), luego raster. + const EXTS: &[&str] = &["svg", "png"]; + + let home = std::env::var_os("HOME").map(PathBuf::from); + let mut roots: Vec = Vec::new(); + if let Some(h) = home { + roots.push(h.join(".local/share/icons")); + roots.push(h.join(".icons")); + } + roots.push(PathBuf::from("/usr/share/icons")); + roots.push(PathBuf::from("/usr/local/share/icons")); + + for root in &roots { + for theme in THEMES { + for sub in SUBDIRS { + for ext in EXTS { + let p = root.join(theme).join(sub).join(format!("{name}.{ext}")); + if p.is_file() { + return Some(p); + } + } + } + } + } + + // Pixmaps fallback (no por theme): svg o png sueltos. + for ext in EXTS { + let pix = PathBuf::from("/usr/share/pixmaps").join(format!("{name}.{ext}")); + if pix.is_file() { + return Some(pix); + } + } + + None +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn nombre_vacío_no_resuelve() { + assert!(get_or_load("").is_none()); + } + + #[test] + fn nombre_inexistente_se_cachea_negativo() { + // Un nombre que casi seguro no existe en el sistema; el cache lo guarda + // como `None` para no re-walkear. + let n = "icono-que-no-existe-xyz123-tawasuyu-test"; + assert!(get_or_load(n).is_none()); + // Segunda llamada usa el cache (sigue dando None y no panic). + assert!(get_or_load(n).is_none()); + } +} diff --git a/02_ruway/shuma/sandbox/shuma-module-shell/src/campana.rs b/02_ruway/shuma/sandbox/shuma-module-shell/src/campana.rs new file mode 100644 index 0000000..9616473 --- /dev/null +++ b/02_ruway/shuma/sandbox/shuma-module-shell/src/campana.rs @@ -0,0 +1,313 @@ +//! `campana` — el **protocolo de avisos** de un terminal: lo que un programa +//! bajo el PTY emite para llamar la atención del humano sin escribir nada en +//! pantalla. +//! +//! Un terminal de verdad no se entera de lo que pasa sólo leyendo el texto: los +//! programas *avisan* por el canal de control. Hasta ahora armábamos el +//! `vt100::Parser` con los callbacks vacíos (`()`), así que todo esto se tiraba +//! a la basura. Lo que se rescata acá: +//! +//! | secuencia | quién la emite | qué es | +//! |------------------------------------|---------------------------|--------| +//! | `^G` (BEL, `0x07`) | `echo -e '\a'`, bash, zsh | campana clásica | +//! | `ESC g` | vt100 visual bell | campana visual | +//! | `OSC 0 ; texto BEL` | casi todo | título **e** icono | +//! | `OSC 2 ; texto BEL` | vim, ssh, tmux, PS1 | título de ventana | +//! | `OSC 9 ; texto BEL` | iTerm2 / ConEmu | notificación de escritorio | +//! | `OSC 777 ; notify ; tit ; cuerpo` | urxvt / wezterm / kitty | notificación con cuerpo | +//! | `OSC 99 ; meta ; cuerpo` | kitty | notificación (protocolo nuevo) | +//! +//! El título OSC es **la** fuente de contexto de una pestaña: es exactamente lo +//! que cualquier terminal muestra en su tab, y el programa lo mantiene al día +//! (vim pone el archivo, ssh el host, un PS1 decente el cwd). Por eso gana sobre +//! cualquier cosa que podamos deducir nosotros desde afuera. +//! +//! El [`Buzon`] se comparte por `Arc>` porque el `vt100::Parser` se +//! queda con los callbacks por valor y no los presta de vuelta. + +use std::sync::{Arc, Mutex}; + +/// Tope de notificaciones acumuladas sin cosechar. Un programa en un lazo puede +/// escupir miles; nos quedamos con las últimas y el resto se descarta. +const BUZON_MAX: usize = 32; + +/// Tope de caracteres de un título/notificación. Un título de 4 KB (los hay) +/// no aporta nada a una pestaña de 180 px. +const TEXTO_MAX: usize = 160; + +/// Lo que el programa bajo el PTY dejó dicho por el canal de control. +#[derive(Debug, Default, Clone)] +pub struct Buzon { + /// Campanadas acumuladas (audibles + visuales). **Monótono**: el consumidor + /// guarda el valor que ya acusó y el delta le dice si sonó algo nuevo. + pub campanadas: u64, + /// Título de ventana vigente (OSC 0/2), ya saneado. `None` = el programa + /// nunca puso uno. + pub titulo: Option, + /// Notificaciones de escritorio sin cosechar, en orden de llegada. + pub notificaciones: Vec, +} + +/// Una notificación de escritorio pedida por el programa (OSC 9/777/99). +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct Notificacion { + /// Título; vacío cuando el protocolo no lo lleva (OSC 9 manda sólo cuerpo). + pub titulo: String, + pub cuerpo: String, +} + +/// Callbacks del `vt100::Parser` que llenan un [`Buzon`] compartido. +#[derive(Debug, Clone, Default)] +pub struct Campanario { + buzon: Arc>, +} + +impl Campanario { + pub fn new() -> Self { + Self::default() + } + + /// Handle al buzón — el `TuiSession` se lo queda para leerlo, porque el + /// parser no devuelve sus callbacks. + pub fn buzon(&self) -> Arc> { + self.buzon.clone() + } + + fn con(&self, f: impl FnOnce(&mut Buzon) -> R) -> R { + // Un panic ajeno dentro del lock no debe silenciar los avisos. + let mut g = self.buzon.lock().unwrap_or_else(|e| e.into_inner()); + f(&mut g) + } +} + +impl vt100::Callbacks for Campanario { + fn audible_bell(&mut self, _: &mut vt100::Screen) { + self.con(|b| b.campanadas = b.campanadas.saturating_add(1)); + } + + fn visual_bell(&mut self, _: &mut vt100::Screen) { + self.con(|b| b.campanadas = b.campanadas.saturating_add(1)); + } + + fn set_window_title(&mut self, _: &mut vt100::Screen, titulo: &[u8]) { + let t = sanear(titulo); + self.con(|b| b.titulo = if t.is_empty() { None } else { Some(t) }); + } + + /// OSC que el `vt100` no implementa: acá viven los protocolos de + /// notificación de escritorio (9, 777, 99). + fn unhandled_osc(&mut self, _: &mut vt100::Screen, params: &[&[u8]]) { + let Some(n) = notificacion_de(params) else { + return; + }; + self.con(|b| { + if b.notificaciones.len() >= BUZON_MAX { + b.notificaciones.remove(0); + } + b.notificaciones.push(n); + }); + } +} + +/// Traduce los parámetros de un OSC a una [`Notificacion`], si es alguno de los +/// protocolos conocidos. Pura y testeable — el grueso de la lógica del módulo. +fn notificacion_de(params: &[&[u8]]) -> Option { + match params { + // OSC 9 ; — iTerm2 / ConEmu. Sin título. + [b"9", cuerpo] => Some(Notificacion { + titulo: String::new(), + cuerpo: sanear(cuerpo), + }), + // OSC 777 ; notify ; [; ] — urxvt y compatibles. + [b"777", tipo, titulo, resto @ ..] if tipo.eq_ignore_ascii_case(b"notify") => { + Some(Notificacion { + titulo: sanear(titulo), + cuerpo: resto.first().map(|c| sanear(c)).unwrap_or_default(), + }) + } + // OSC 99 ; ; — kitty. Los metadatos + // (id, tipo, si es parcial) no nos aportan: el cuerpo es el aviso. + [b"99", _meta, cuerpo] => Some(Notificacion { + titulo: String::new(), + cuerpo: sanear(cuerpo), + }), + _ => None, + } +} + +/// Tope de caracteres de un título de pestaña. +/// +/// **No es el ancho de la pestaña** — quien decide cuánto entra es la vista, que +/// es la única que sabe cuántas pestañas hay y cuánto lugar queda, y que corta +/// con puntos suspensivos. Esto es sólo la guarda contra un título absurdo: un +/// programa puede poner 4 KB por OSC y no tiene sentido arrastrar eso por el +/// layout. Estaba en 26 y era **el** recorte real —la pestaña salía cortada +/// aunque sobrara lugar en la barra—, que es el caso que hay que evitar. +pub const TITULO_MAX: usize = 48; + +/// El programa de una línea de comando: primera palabra, sin path ni `sudo` +/// adelante (que taparía al programa real, que es lo informativo). +/// +/// `None` para una línea vacía o que es puro entorno (`FOO=bar`). +pub fn programa_de(linea: &str) -> Option { + let mut palabras = linea + .split_whitespace() + // `env`/`sudo`/`command` y las asignaciones `FOO=bar` de prefijo son + // andamiaje: el programa está detrás. + .skip_while(|p| { + matches!(*p, "sudo" | "env" | "command" | "exec" | "time" | "nohup") + || (p.contains('=') && !p.starts_with('-')) + }); + let prog = palabras.next()?; + let base = prog.rsplit('/').next().unwrap_or(prog); + if base.is_empty() { + return None; + } + Some(base.to_string()) +} + +/// Nombre corto de un directorio para rotular una pestaña en reposo: `~` para +/// el home, el basename para cualquier otro, `/` para la raíz. +pub fn nombre_de_cwd(cwd: &std::path::Path) -> String { + if let Some(home) = std::env::var_os("HOME").filter(|h| !h.is_empty()) { + if cwd == std::path::Path::new(&home) { + return "~".to_string(); + } + } + cwd.file_name() + .map(|n| n.to_string_lossy().into_owned()) + .unwrap_or_else(|| "/".to_string()) +} + +/// Recorta un título a [`TITULO_MAX`] **conservando la cola**: en +/// `~/tawasuyu/02_ruway/shuma` lo que identifica es el final, no el `~`. +pub fn acortar(texto: &str) -> String { + let n = texto.chars().count(); + if n <= TITULO_MAX { + return texto.to_string(); + } + let cola: String = texto.chars().skip(n - (TITULO_MAX - 1)).collect(); + format!("…{cola}") +} + +/// Texto de control → texto pintable: UTF-8 permisivo, sin caracteres de +/// control, recortado a [`TEXTO_MAX`]. +fn sanear(bytes: &[u8]) -> String { + String::from_utf8_lossy(bytes) + .chars() + .filter(|c| !c.is_control()) + .take(TEXTO_MAX) + .collect::() + .trim() + .to_string() +} + +#[cfg(test)] +mod tests { + use super::*; + use vt100::Callbacks; + + /// Un parser cableado al campanario, como lo arma `TuiSession`. + fn parser() -> (vt100::Parser, Arc>) { + let c = Campanario::new(); + let buzon = c.buzon(); + (vt100::Parser::new_with_callbacks(24, 80, 100, c), buzon) + } + + fn leer(b: &Arc>) -> Buzon { + b.lock().unwrap().clone() + } + + #[test] + fn bel_suma_campanada() { + let (mut p, b) = parser(); + p.process(b"hola\x07mundo"); + assert_eq!(leer(&b).campanadas, 1); + // Y el texto llega igual a la pantalla: el BEL no come contenido. + assert!(p.screen().contents().contains("holamundo")); + p.process(b"\x07\x07"); + assert_eq!(leer(&b).campanadas, 3, "monótono, no se resetea solo"); + } + + #[test] + fn osc_2_pone_titulo_y_osc_0_tambien() { + let (mut p, b) = parser(); + p.process(b"\x1b]2;nvim src/lib.rs\x07"); + assert_eq!(leer(&b).titulo.as_deref(), Some("nvim src/lib.rs")); + // OSC 0 pone título e icono a la vez (el caso más común). + p.process(b"\x1b]0;sergio@tawasuyu: ~/tawasuyu\x1b\\"); + assert_eq!(leer(&b).titulo.as_deref(), Some("sergio@tawasuyu: ~/tawasuyu")); + // Título vacío = el programa lo quita. + p.process(b"\x1b]2;\x07"); + assert_eq!(leer(&b).titulo, None); + } + + #[test] + fn titulo_se_sanea_y_se_recorta() { + let largo = "x".repeat(TEXTO_MAX + 40); + let mut c = Campanario::new(); + let b = c.buzon(); + let mut screen = vt100::Parser::new(1, 1, 0); + c.set_window_title(screen.screen_mut(), format!(" a\x01b {largo}").as_bytes()); + let t = leer(&b).titulo.unwrap(); + assert!(t.starts_with("ab"), "control chars fuera: {t:?}"); + assert!(t.chars().count() <= TEXTO_MAX); + } + + #[test] + fn osc_9_777_y_99_son_notificaciones() { + assert_eq!( + notificacion_de(&[b"9", b"compilo"]), + Some(Notificacion { titulo: String::new(), cuerpo: "compilo".into() }) + ); + assert_eq!( + notificacion_de(&[b"777", b"notify", b"cargo", b"terminado"]), + Some(Notificacion { titulo: "cargo".into(), cuerpo: "terminado".into() }) + ); + // OSC 777 sin cuerpo: sólo título. + assert_eq!( + notificacion_de(&[b"777", b"notify", b"cargo"]), + Some(Notificacion { titulo: "cargo".into(), cuerpo: String::new() }) + ); + assert_eq!( + notificacion_de(&[b"99", b"i=1:d=0:p=body", b"listo"]), + Some(Notificacion { titulo: String::new(), cuerpo: "listo".into() }) + ); + // Lo que no es un protocolo de aviso no ensucia el buzón. + assert_eq!(notificacion_de(&[b"52", b"c", b"AAAA"]), None); + assert_eq!(notificacion_de(&[b"777", b"otracosa", b"x"]), None); + } + + #[test] + fn programa_de_saltea_el_andamiaje() { + assert_eq!(programa_de("ls -la").as_deref(), Some("ls")); + assert_eq!(programa_de("/usr/bin/cargo build").as_deref(), Some("cargo")); + assert_eq!(programa_de("sudo systemctl status").as_deref(), Some("systemctl")); + assert_eq!(programa_de("RUST_LOG=debug cargo test").as_deref(), Some("cargo")); + assert_eq!(programa_de("sudo -u x env A=1 vim f.rs").as_deref(), Some("-u")); + assert_eq!(programa_de(" ").as_deref(), None); + } + + #[test] + fn acortar_conserva_la_cola() { + assert_eq!(acortar("corto"), "corto"); + let largo = "~/tawasuyu/02_ruway/shuma/sandbox/shuma-module-shell"; + let a = acortar(largo); + assert_eq!(a.chars().count(), TITULO_MAX); + assert!(a.starts_with('…')); + assert!(largo.ends_with(a.trim_start_matches('…')), "queda la cola: {a}"); + } + + #[test] + fn notificaciones_entran_por_el_parser_y_tienen_tope() { + let (mut p, b) = parser(); + p.process(b"\x1b]777;notify;cargo;build ok\x07"); + let n = leer(&b).notificaciones; + assert_eq!(n.len(), 1); + assert_eq!(n[0].cuerpo, "build ok"); + for i in 0..BUZON_MAX + 10 { + p.process(format!("\x1b]9;aviso {i}\x07").as_bytes()); + } + assert_eq!(leer(&b).notificaciones.len(), BUZON_MAX, "se acota"); + } +} diff --git a/02_ruway/shuma/sandbox/shuma-module-shell/src/codigo.rs b/02_ruway/shuma/sandbox/shuma-module-shell/src/codigo.rs new file mode 100644 index 0000000..fee2f30 --- /dev/null +++ b/02_ruway/shuma/sandbox/shuma-module-shell/src/codigo.rs @@ -0,0 +1,232 @@ +//! Detector de "línea de código" a partir de los colores que trae la cosecha +//! del PTY. +//! +//! claude pinta sus bloques de código con una paleta fija que **no** aparece en +//! la prosa: Monokai para los bloques con resaltado de sintaxis (`#f8f8f2` +//! default, `#75715e` comentario, y los tokens verde/cian/violeta/…) y un cian +//! plano (`#11a8cd`) para los spans/bloques sin lenguaje. La línea que lleva +//! esos colores ES código, y le ponemos un fondo hundido — como se ve el +//! recuadro de código en el propio claude. "Es parte de tu personalidad." +//! +//! El código inline (lavanda `#b1b9f9`) vive **dentro** de la prosa, así que se +//! clasifica como prosa a propósito: un token suelto no debe arrastrar toda una +//! línea de párrafo a fondo de código. +//! +//! Paleta medida contra `/tmp/shuma-consola-dump.txt` (volcado con colores por +//! tramo, 2026-07-22). Son los colores **literales** que claude emite por el +//! PTY, no colores derivados del tema de shuma — por eso el match es por hex +//! exacto (con una tolerancia mínima que absorbe redondeos del empacado). + +use llimphi_ui::llimphi_raster::peniko::Color; + +/// Paleta de código de claude: Monokai (resaltado) + cian plano (sin lenguaje). +const CODIGO: &[(u8, u8, u8)] = &[ + (0x11, 0xa8, 0xcd), // cian plano — bloque/span sin lenguaje (comandos) + (0xf8, 0xf8, 0xf2), // Monokai default fg + (0x75, 0x71, 0x5e), // Monokai comentario + (0xa6, 0xe2, 0x2e), // Monokai verde (string/función) + (0x66, 0xd9, 0xef), // Monokai cian (tipo/keyword) + (0xbe, 0x84, 0xff), // Monokai violeta (constante) + (0xe6, 0xdb, 0x74), // Monokai amarillo (string) + (0xfd, 0x97, 0x1f), // Monokai naranja (parámetro) + (0xae, 0x81, 0xff), // Monokai violeta (número) + (0xf9, 0x26, 0x72), // Monokai rosa (keyword/operador) + (0x50, 0xc8, 0x50), // verde de código (diff-add/marcador en bloque) +]; + +/// Paleta de prosa de claude — explícitamente NO-código, para que un token +/// aislado (código inline, viñeta) no arrastre una línea de párrafo a código. +const PROSA: &[(u8, u8, u8)] = &[ + (0xd6, 0xe8, 0xe8), // prosa normal + (0xb1, 0xb9, 0xf9), // código inline (lavanda) — DENTRO de la prosa + (0x99, 0x99, 0x99), // gris secundario + (0xff, 0xff, 0xff), // blanco (bold/encabezado) + (0x4e, 0xba, 0x65), // verde de prosa (viñeta/check) +]; + +/// Tolerancia por canal al comparar contra la paleta. Chica a propósito: los +/// pares más cercanos (`#f8f8f2` código vs `#ffffff` prosa; `#e6db74` código vs +/// `#dcc878` decoración) distan más que esto, así que no se confunden. +const TOL: i32 = 6; + +fn cerca(c: Color, set: &[(u8, u8, u8)]) -> bool { + let p = c.to_rgba8(); + set.iter().any(|&(r, g, b)| { + (p.r as i32 - r as i32).abs() <= TOL + && (p.g as i32 - g as i32).abs() <= TOL + && (p.b as i32 - b as i32).abs() <= TOL + }) +} + +/// Qué fondo pinta una línea de la consola (H4). +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum Fondo { + /// Sin fondo (prosa). + Ninguno, + /// Bloque de código (slate elevado). + Codigo, + /// Línea **añadida** de un diff — verde. + Anadida, + /// Línea **quitada** de un diff — roja. + Quitada, +} + +/// Verde del marcador `+` de un diff de claude (medido: `#50c850`). +const VERDE_DIFF: (u8, u8, u8) = (0x50, 0xc8, 0x50); +/// Rojo del marcador `-` de un diff de claude (medido: `#dc5a5a`). Distinto del +/// `fg_destructive` del tema (que shuma usa para stderr). +const ROJO_DIFF: (u8, u8, u8) = (0xdc, 0x5a, 0x5a); + +/// Clasifica el fondo de una línea de consola. claude no pinta el diff con +/// fondo sino coloreando el **marcador** `+`/`-` al arranque de la línea (el +/// texto queda en `#f8f8f2`); así que miramos el PRIMER tramo con ancho: verde +/// ⇒ añadida, rojo ⇒ quitada. Si no es diff pero es código, va el slate; si no, +/// nada. Reusa [`linea_es_codigo`] para el caso general. +pub fn fondo_de_linea(runs: &[(usize, usize, Color)]) -> Fondo { + if let Some(&(_, _, c)) = runs.iter().find(|(a, b, _)| b > a) { + if cerca(c, &[VERDE_DIFF]) { + return Fondo::Anadida; + } + if cerca(c, &[ROJO_DIFF]) { + return Fondo::Quitada; + } + } + if linea_es_codigo(runs) { + Fondo::Codigo + } else { + Fondo::Ninguno + } +} + +/// ¿La línea, según sus tramos de color, es una línea de código de claude? +/// +/// Voto por ancho: suma el ancho (en el rango del tramo) de lo pintado con +/// paleta de código vs. paleta de prosa; es código si hubo algo de código y +/// pesa al menos tanto como la prosa. Los colores fuera de ambas paletas (las +/// decoraciones de `shuma-line` para `ls`/paths/urls) no votan — así el output +/// normal de comandos nunca cae a fondo de código. +pub fn linea_es_codigo(runs: &[(usize, usize, Color)]) -> bool { + let mut cod = 0usize; + let mut pro = 0usize; + for &(a, b, c) in runs { + let w = b.saturating_sub(a); + if w == 0 { + continue; + } + if cerca(c, CODIGO) { + cod += w; + } else if cerca(c, PROSA) { + pro += w; + } + } + cod > 0 && cod >= pro +} + +#[cfg(test)] +mod tests { + use super::*; + + fn col(r: u8, g: u8, b: u8) -> Color { + Color::from_rgba8(r, g, b, 255) + } + + #[test] + fn bloque_plano_cian_es_codigo() { + // `[#11a8cd,#d6e8e8] sudo install …` — cian manda, prosa es el rabo. + let runs = vec![(0, 90, col(0x11, 0xa8, 0xcd)), (90, 92, col(0xd6, 0xe8, 0xe8))]; + assert!(linea_es_codigo(&runs)); + } + + #[test] + fn monokai_default_y_comentario_es_codigo() { + // `958 // Atenuación…` — default fg + comentario gris Monokai. + let runs = vec![(0, 8, col(0xf8, 0xf8, 0xf2)), (8, 70, col(0x75, 0x71, 0x5e))]; + assert!(linea_es_codigo(&runs)); + } + + #[test] + fn prosa_normal_no_es_codigo() { + let runs = vec![(0, 60, col(0xd6, 0xe8, 0xe8))]; + assert!(!linea_es_codigo(&runs)); + } + + #[test] + fn prosa_con_codigo_inline_no_es_codigo() { + // Párrafo con un span lavanda de código inline en el medio. + let runs = vec![ + (0, 20, col(0xd6, 0xe8, 0xe8)), + (20, 28, col(0xb1, 0xb9, 0xf9)), + (28, 60, col(0xd6, 0xe8, 0xe8)), + ]; + assert!(!linea_es_codigo(&runs)); + } + + #[test] + fn encabezado_blanco_no_es_codigo() { + // Bold blanco (`#ffffff`) NO se confunde con el default Monokai `#f8f8f2`. + let runs = vec![(0, 30, col(0xff, 0xff, 0xff))]; + assert!(!linea_es_codigo(&runs)); + } + + #[test] + fn decoracion_de_ls_no_vota_como_codigo() { + // Output de `ls` decorado por shuma-line (teal fecha, naranja número): + // colores fuera de ambas paletas → no votan → no es código. + let runs = vec![ + (0, 10, col(0x7e, 0xa6, 0xb4)), // DateTime teal + (10, 16, col(0xd1, 0x9a, 0x66)), // Number naranja + ]; + assert!(!linea_es_codigo(&runs)); + } + + #[test] + fn warn_decoracion_no_colisiona_con_amarillo_monokai() { + // `#dcc878` (Warn de shuma-line) NO cae dentro de TOL de `#e6db74` + // (string amarillo Monokai) → no vota como código. + let runs = vec![(0, 40, col(0xdc, 0xc8, 0x78))]; + assert!(!linea_es_codigo(&runs)); + } + + #[test] + fn sin_tramos_no_es_codigo() { + assert!(!linea_es_codigo(&[])); + } + + #[test] + fn diff_anadida_y_quitada_por_el_marcador() { + // `+cat …` — marcador verde al arranque, texto default. → Añadida. + let anadida = vec![(0, 8, col(0x50, 0xc8, 0x50)), (8, 60, col(0xf8, 0xf8, 0xf2))]; + assert_eq!(fondo_de_linea(&anadida), Fondo::Anadida); + // `-cat …` — marcador rojo al arranque. → Quitada. + let quitada = vec![(0, 8, col(0xdc, 0x5a, 0x5a)), (8, 60, col(0xf8, 0xf8, 0xf2))]; + assert_eq!(fondo_de_linea(&quitada), Fondo::Quitada); + } + + #[test] + fn codigo_de_contexto_va_slate_no_diff() { + // Línea de contexto de un diff (sin marcador de color) → Código, no diff. + let ctx = vec![(0, 60, col(0xf8, 0xf8, 0xf2))]; + assert_eq!(fondo_de_linea(&ctx), Fondo::Codigo); + } + + #[test] + fn prosa_no_lleva_fondo() { + let prosa = vec![(0, 60, col(0xd6, 0xe8, 0xe8))]; + assert_eq!(fondo_de_linea(&prosa), Fondo::Ninguno); + } + + #[test] + fn stderr_rojo_del_tema_no_se_confunde_con_diff_quitada() { + // El rojo de stderr es `#dc6e6e` (fg_destructive), distinto del `#dc5a5a` + // del marcador de diff → NO se clasifica como Quitada. + let stderr = vec![(0, 40, col(0xdc, 0x6e, 0x6e))]; + assert_ne!(fondo_de_linea(&stderr), Fondo::Quitada); + } + + #[test] + fn linea_mixta_gana_el_codigo_si_pesa_igual_o_mas() { + // Mitad prosa, mitad código → empate → código (>= prosa). + let runs = vec![(0, 30, col(0xd6, 0xe8, 0xe8)), (30, 60, col(0x66, 0xd9, 0xef))]; + assert!(linea_es_codigo(&runs)); + } +} diff --git a/02_ruway/shuma/sandbox/shuma-module-shell/src/history_helpers.rs b/02_ruway/shuma/sandbox/shuma-module-shell/src/history_helpers.rs new file mode 100644 index 0000000..92028ea --- /dev/null +++ b/02_ruway/shuma/sandbox/shuma-module-shell/src/history_helpers.rs @@ -0,0 +1,82 @@ +use super::*; + +/// Dónde vive el historial de ESTA ejecución. +/// +/// No es siempre `~/.local/share/shuma/history.jsonl`, y la excepción importa: +/// `State::new` abre el historial y absorbe el del shell, así que **cada test +/// que construye un `State` escribía en el historial real del usuario**. Una +/// corrida de la suite le metía cientos de entradas de fixture (`cwd: /repo`, +/// `:jobs`, `sleep 30`) y, de paso, hacía correr la importación en paralelo +/// desde muchos hilos, duplicándola. En un historial real eso llegó a ser el +/// 79% del archivo: los comandos del usuario quedaron enterrados y el +/// autocompletado dejó de encontrarlos. +/// +/// - `SHUMA_HISTORY_PATH` manda siempre (tests de otros crates, sandboxes). +/// - En `cargo test` de este crate, un archivo temporal por proceso. +/// - Si no, el de siempre. +fn ruta_historial() -> Option { + if let Some(p) = std::env::var_os("SHUMA_HISTORY_PATH") { + return Some(std::path::PathBuf::from(p)); + } + #[cfg(test)] + { + return Some(std::env::temp_dir().join(format!( + "shuma-history-test-{}.jsonl", + std::process::id() + ))); + } + #[cfg(not(test))] + shuma_history::History::default_path() +} + +/// `true` si NO hay que tocar los historiales del shell del usuario. Leerlos es +/// inocuo, pero la importación **escribe** la marca de agua compartida, y +/// hacerlo desde los hilos de la suite es lo que la duplicó. +pub(crate) fn importacion_permitida() -> bool { + if std::env::var_os("SHUMA_HISTORY_PATH").is_some() { + return false; + } + !cfg!(test) +} + +pub(crate) fn open_history() -> shuma_history::History { + if let Some(path) = ruta_historial() { + if let Some(parent) = path.parent() { + let _ = std::fs::create_dir_all(parent); + } + if let Ok(h) = shuma_history::History::open(&path) { + return h; + } + } + // Fallback: historial en /dev/null (existe siempre, append-only OK). + shuma_history::History::open(std::path::PathBuf::from("/dev/null")) + .unwrap_or_else(|_| panic!("no se pudo abrir ni /dev/null como history")) +} + +/// Absorbe los historiales de bash/zsh al historial propio (incremental). +/// No-op si no hay fuentes en disco o si nada creció desde la última vez. +/// Devuelve cuántas líneas se importaron (0 = nada nuevo). +pub(crate) fn absorb_shell_histories(history: &mut shuma_history::History) -> usize { + let sources = shuma_history::foreign::default_sources(); + if sources.is_empty() { + return 0; + } + shuma_history::foreign::absorb_foreign(history, &sources).imported +} + +/// Segundos unix actuales (0 si el reloj está antes de la época). +pub(crate) fn now_unix_secs() -> u64 { + std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .map(|d| d.as_secs()) + .unwrap_or(0) +} + +/// Milisegundos unix actuales — para el parpadeo del caret del input. +pub(crate) fn now_unix_millis() -> u64 { + std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .map(|d| d.as_millis() as u64) + .unwrap_or(0) +} + diff --git a/02_ruway/shuma/sandbox/shuma-module-shell/src/input_editor.rs b/02_ruway/shuma/sandbox/shuma-module-shell/src/input_editor.rs new file mode 100644 index 0000000..04f4ee0 --- /dev/null +++ b/02_ruway/shuma/sandbox/shuma-module-shell/src/input_editor.rs @@ -0,0 +1,289 @@ +//! `InputShuma` — el input de la barra montado sobre el **editor compartido** +//! de Llimphi, con la API en offsets de byte que usa el resto del shell. +//! +//! Hasta 2026-07-21 el input era `shuma_line::LineState`: texto + cursor + ancla +//! hechos a mano. Funcionaba, pero era una isla — cada capacidad (selección por +//! palabra, undo, IME, doble click) había que escribirla acá y sólo la +//! estrenaba shuma. El motor de `llimphi-widget-text-input` ya las tenía todas. +//! +//! El precio de mudarse era un choque de coordenadas: el shell entero razona en +//! **offsets de byte** (el tokenizer, el autocompletado, el mapeo click→texto, +//! el historial) y el motor razona en **(línea, columna de carácter)**. Este +//! módulo es el **único** lugar donde ocurre esa traducción — la alternativa era +//! convertir en los ~200 puntos donde el shell toca el input, y ahí cualquier +//! olvido es un cursor que cae media letra corrido en UTF-8. +//! +//! Lo que **no** vive acá: tokenizar, completar y partir pipelines siguen siendo +//! funciones libres de `shuma-line` sobre el texto. Sólo se mudó la *edición*. + +use llimphi_widget_text_input::{Metricas, TextInputState}; +use shuma_line::{ + complete::{Completion, CompletionSource}, + Dialect, Token, +}; + +/// Offset de byte de la posición `(línea, columna-de-carácter)` en `texto`. +/// Fuera de rango → el final del texto (nunca un offset inválido). +fn byte_de(texto: &str, linea: usize, col: usize) -> usize { + let mut base = 0usize; + for (i, l) in texto.split('\n').enumerate() { + if i == linea { + return base + l.chars().take(col).map(char::len_utf8).sum::(); + } + base += l.len() + 1; // +1 por el '\n' + } + texto.len() +} + +/// `(línea, columna-de-carácter)` del offset de byte `byte` en `texto`. El +/// offset se clampea al largo y al límite de carácter anterior, así un byte +/// calculado desde píxeles no puede caer en medio de una `é`. +fn pos_de(texto: &str, byte: usize) -> (usize, usize) { + let mut b = byte.min(texto.len()); + while b > 0 && !texto.is_char_boundary(b) { + b -= 1; + } + let mut base = 0usize; + for (i, l) in texto.split('\n').enumerate() { + let fin = base + l.len(); + if b <= fin { + return (i, texto[base..b].chars().count()); + } + base = fin + 1; + } + (0, 0) +} + +/// El input del shell. Envuelve el [`TextInputState`] compartido (multilínea: +/// el input de shuma acepta saltos de línea con Shift+Enter y con las +/// construcciones abiertas de shell) y expone la API en bytes del shell. +#[derive(Debug, Clone)] +pub struct InputShuma { + ed: TextInputState, + dialect: Dialect, +} + +impl Default for InputShuma { + fn default() -> Self { + Self::new() + } +} + +impl InputShuma { + pub fn new() -> Self { + Self { ed: TextInputState::multiline(), dialect: Dialect::default() } + } + + /// El estado compartido, para la vista y para aplicar teclas/eventos. + pub fn ed(&self) -> &TextInputState { + &self.ed + } + pub fn ed_mut(&mut self) -> &mut TextInputState { + &mut self.ed + } + + /// Fija la métrica con la que se va a pintar (mono + zoom). El hit-testing + /// del motor la usa para mapear click→carácter, así que tiene que ser la + /// misma con la que se dibuja. + pub fn set_metricas(&self, m: &Metricas) { + self.ed.aplicar_metricas(m); + } + + // ── Texto ── + + pub fn text(&self) -> String { + self.ed.text() + } + + pub fn is_empty(&self) -> bool { + self.ed.is_empty() + } + + /// Reemplaza toda la línea y deja el cursor al final. + pub fn set_text(&mut self, texto: impl Into) { + self.ed.set_text(texto.into()); + } + + pub fn clear(&mut self) { + self.ed.clear(); + } + + /// Inserta texto en el cursor (reemplazando la selección viva, si hay) y lo + /// avanza. Va por el motor, así que **entra al undo**. + pub fn insert(&mut self, s: &str) { + self.ed.insertar(s); + } + + // ── Cursor y selección, en bytes ── + + /// Offset de byte del cursor. + pub fn cursor(&self) -> usize { + let c = self.ed.editor().cursor.caret; + byte_de(&self.text(), c.line, c.col) + } + + /// Posa el cursor en `byte` (clampeado a límite de carácter). No toca la + /// selección — el caller decide si ancla o limpia. + pub fn set_cursor(&mut self, byte: usize) { + let t = self.text(); + let (l, c) = pos_de(&t, byte); + self.ed.editor_mut().set_caret_at(l, c); + } + + /// Rango `[ini, fin)` de la selección en bytes (ordenado), o `None`. + pub fn selection(&self) -> Option<(usize, usize)> { + let cur = self.ed.editor().cursor; + let a = cur.anchor?; + let t = self.text(); + let ca = byte_de(&t, a.line, a.col); + let cc = byte_de(&t, cur.caret.line, cur.caret.col); + (ca != cc).then(|| (ca.min(cc), ca.max(cc))) + } + + pub fn selected_text(&self) -> Option { + self.ed.editor().selected_text() + } + + pub fn select_all(&mut self) { + self.ed.select_all(); + } + + pub fn clear_selection(&mut self) { + self.ed.editor_mut().cursor.collapse(); + } + + /// Selecciona `[a, b)`: ancla en `a`, cursor en `b`. + pub fn select_range(&mut self, a: usize, b: usize) { + let t = self.text(); + let (la, ca) = pos_de(&t, a); + let (lb, cb) = pos_de(&t, b); + let ed = self.ed.editor_mut(); + ed.set_caret_at(la, ca); + ed.extend_selection_to(lb, cb); + } + + /// Selecciona la palabra que cubre `byte` (para el doble click). + pub fn select_word_at(&mut self, byte: usize) { + let t = self.text(); + let (l, c) = pos_de(&t, byte); + self.ed.editor_mut().select_word_at(l, c); + } + + // ── Análisis de shell (funciones libres sobre el texto) ── + + pub fn dialect(&self) -> Dialect { + self.dialect + } + + pub fn set_dialect(&mut self, d: Dialect) { + self.dialect = d; + } + + /// Los tokens clasificados, listos para colorear. + pub fn tokens(&self) -> Vec { + shuma_line::tokenize(&self.text(), self.dialect) + } + + /// Autocompletado en la posición actual del cursor. + pub fn complete(&self, source: &dyn CompletionSource) -> Completion { + shuma_line::complete::complete(&self.text(), self.cursor(), self.dialect, source) + } + + /// Aplica un candidato: reemplaza el rango que indicó la [`Completion`] y + /// deja el cursor tras lo insertado. + pub fn apply_completion(&mut self, completion: &Completion, candidate: &str) { + let (s, e) = (completion.replace_start, completion.replace_end); + let t = self.text(); + if s <= e && e <= t.len() { + self.select_range(s, e); + self.insert(candidate); + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn ida_y_vuelta_byte_pos_es_utf8_segura() { + let t = "café x\nsegunda ñ"; + for b in 0..=t.len() { + let (l, c) = pos_de(t, b); + let back = byte_de(t, l, c); + assert!(t.is_char_boundary(back), "byte {b} → {back} cayó fuera de carácter"); + // Si `b` ya estaba en un límite, la vuelta es exacta. + if t.is_char_boundary(b) { + assert_eq!(back, b, "byte {b} no volvió a su lugar"); + } + } + } + + #[test] + fn el_cursor_en_bytes_atraviesa_los_saltos_de_linea() { + let mut i = InputShuma::new(); + i.set_text("uno\ndos"); + assert_eq!(i.cursor(), "uno\ndos".len(), "set_text deja el cursor al final"); + i.set_cursor(4); // primer byte de la segunda línea + assert_eq!(i.cursor(), 4); + let c = i.ed.editor().cursor.caret; + assert_eq!((c.line, c.col), (1, 0), "byte 4 es (línea 1, col 0)"); + } + + #[test] + fn insertar_reemplaza_la_seleccion_viva() { + let mut i = InputShuma::new(); + i.set_text("hola mundo"); + i.select_range(0, 4); + assert_eq!(i.selected_text().as_deref(), Some("hola")); + i.insert("chau"); + assert_eq!(i.text(), "chau mundo"); + assert!(i.selection().is_none(), "tras reemplazar no queda selección"); + } + + #[test] + fn la_seleccion_en_bytes_sale_ordenada_aunque_se_arrastre_al_reves() { + let mut i = InputShuma::new(); + i.set_text("abcdef"); + i.select_range(5, 2); // ancla a la derecha, cursor a la izquierda + assert_eq!(i.selection(), Some((2, 5))); + assert_eq!(i.cursor(), 2); + } + + #[test] + fn doble_click_toma_la_palabra_bajo_el_byte() { + let mut i = InputShuma::new(); + i.set_text("git commit -m hola"); + i.select_word_at(6); // dentro de "commit" + assert_eq!(i.selected_text().as_deref(), Some("commit")); + } + + #[test] + fn los_tokens_siguen_saliendo_del_texto_vivo() { + let mut i = InputShuma::new(); + i.set_text("cat f | grep x"); + let cmds: Vec<_> = i + .tokens() + .into_iter() + .filter(|t| t.kind == shuma_line::TokenKind::Command) + .map(|t| t.text) + .collect(); + assert_eq!(cmds, vec!["cat", "grep"]); + } + + #[test] + fn aplicar_completado_reemplaza_el_prefijo_y_es_deshacible() { + let mut i = InputShuma::new(); + i.set_text("ca"); + let source = shuma_line::complete::StaticSource { + commands: vec!["cargo".into()], + paths: vec![], + }; + let c = i.complete(&source); + i.apply_completion(&c, "cargo"); + assert_eq!(i.text(), "cargo"); + assert_eq!(i.cursor(), 5); + // Lo que ganamos al mudarnos: esto antes no existía. + assert!(i.ed.editor().can_undo(), "el completado entra al undo"); + } +} diff --git a/02_ruway/shuma/sandbox/shuma-module-shell/src/intent.rs b/02_ruway/shuma/sandbox/shuma-module-shell/src/intent.rs new file mode 100644 index 0000000..d34c2ac --- /dev/null +++ b/02_ruway/shuma/sandbox/shuma-module-shell/src/intent.rs @@ -0,0 +1,215 @@ +//! Clasificador de **intención** del input, SIN prefijos "antihumanos". Decide si +//! una línea es un comando de shell o lenguaje natural para la IA, por heurísticas +//! locales e instantáneas (cero red, cero prefijo). +//! +//! La regla dura: en un shell la **primera palabra es siempre un ejecutable**. Si +//! no lo es —y hay varias palabras o es una pregunta— es lenguaje natural, y +//! encima habría fallado como «command not found». Los comandos reales corren +//! idénticos: sólo se desvía a la IA lo que de shell no era nada. +//! +//! Es una función pura: `es_comando(w)` lo provee el host (corpus del PATH + +//! builtins + alias). Así se testea con un set falso y se reusa en el render (un +//! futuro "preview" de intención sobre el input) sin duplicar la lógica. + +/// Qué querría hacer el usuario con lo tecleado. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum Intencion { + /// Ejecutar en el shell (comando, ruta, sintaxis de shell, o `:`-meta). + Ejecutar, + /// Preguntar a la IA (lenguaje natural: pregunta o imperativo). + Preguntar, +} + +/// Clasifica `linea`. `es_comando(w)` = `true` si `w` resuelve a un ejecutable del +/// PATH / builtin / alias (lo sabe el host). Conservador: ante la duda, `Ejecutar`. +pub fn clasificar(linea: &str, es_comando: impl Fn(&str) -> bool) -> Intencion { + let t = linea.trim(); + if t.is_empty() { + return Intencion::Ejecutar; + } + // `:`-meta, sintaxis de shell dura, o una ruta → siempre shell (no adivinamos). + if t.starts_with(':') || tiene_sintaxis_shell(t) || empieza_con_ruta(t) { + return Intencion::Ejecutar; + } + let w0 = primera_palabra(t); + // La primera palabra ES un comando conocido → shell (git, ls, docker, cargo…). + if es_comando(w0) { + return Intencion::Ejecutar; + } + // Pregunta explícita (termina en `?`/`¿…` o arranca con interrogativo/ + // imperativo) → IA, aunque sea una sola palabra. + if es_pregunta(t, w0) { + return Intencion::Preguntar; + } + // Varias palabras cuya primera NO es comando → en un shell esto SIEMPRE falla + // («command not found»): es lenguaje natural → IA. Una sola palabra + // desconocida se deja al shell (puede ser un binario nuevo, un typo, o una + // app; no la desviamos). + if cuenta_palabras(t) >= 2 { + return Intencion::Preguntar; + } + Intencion::Ejecutar +} + +fn primera_palabra(t: &str) -> &str { + t.split_whitespace().next().unwrap_or("") +} + +fn cuenta_palabras(t: &str) -> usize { + t.split_whitespace().count() +} + +fn empieza_con_ruta(t: &str) -> bool { + t.starts_with("./") || t.starts_with("../") || t.starts_with('/') || t.starts_with("~/") +} + +/// Marcas de que la línea es un comando aunque su primera palabra no esté en el +/// corpus: operadores, pipes, redirects, subshell, glob, asignación de entorno, +/// `&` final, o un wrapper conocido (sudo/env/time…). +fn tiene_sintaxis_shell(t: &str) -> bool { + const OPS: [&str; 9] = ["|", ">", "<", "&&", "||", ";", "$(", "`", "$("]; + if OPS.iter().any(|op| t.contains(op)) { + return true; + } + if t.ends_with('&') { + return true; + } + let w0 = primera_palabra(t); + // Asignación de entorno al inicio: `VAR=...`. + if let Some(eq) = w0.find('=') { + if eq > 0 && w0[..eq].chars().all(|c| c.is_ascii_alphanumeric() || c == '_') { + return true; + } + } + // Wrappers que anteceden a un comando real. + matches!(w0, "sudo" | "doas" | "env" | "command" | "nohup" | "time" | "nice") +} + +/// ¿Parece una pregunta/imperativo dirigido a la IA? +fn es_pregunta(t: &str, w0: &str) -> bool { + if t.ends_with('?') || t.starts_with('¿') { + return true; + } + // Interrogativos + imperativos comunes (es/en) que NO suelen ser comandos. + const MARCAS: &[&str] = &[ + "qué", "que", "cómo", "como", "porqué", "porque", "cuándo", "cuando", "dónde", "donde", + "quién", "quien", "cuál", "cual", "cuánto", "cuanto", "cuánta", "cuanta", "para", + "what", "how", "why", "when", "where", "who", "which", "whose", + // imperativos (pedidos a la IA) + "explica", "explicá", "explicame", "explícame", "hacé", "hazme", "haceme", "dame", + "mostrá", "muéstrame", "escribí", "escribime", "traducí", "traduce", "resumí", "resume", + "generá", "creá", "ayudame", "ayúdame", "convertí", "convierte", "recomendá", "recomienda", + "explain", "translate", "summarize", "generate", "recommend", "suggest", + ]; + let l = w0.to_lowercase(); + MARCAS.contains(&l.as_str()) +} + +/// Normaliza un nombre de app / query para comparar: minúsculas y sin el +/// glifo/símbolo inicial (los labels de la suite arrancan con "¶ ", "▷ ", "Σ "…). +fn normalizar_app(s: &str) -> String { + s.trim() + .trim_start_matches(|c: char| !c.is_alphanumeric()) + .trim() + .to_lowercase() +} + +/// Busca una app cuyo NOMBRE (o su binario) matchee `query` de forma EXACTA +/// (normalizada) — conservador, no fuzzy, para no secuestrar un comando por un +/// typo. Devuelve el comando a lanzar. `apps` = las [`LaunchableApp`] que el +/// host (pata) empuja desde su registro. Es el launcher del input sin prefijo. +pub fn buscar_app(apps: &[crate::types::LaunchableApp], query: &str) -> Option { + let q = normalizar_app(query); + if q.is_empty() { + return None; + } + apps.iter().find_map(|app| { + let n = normalizar_app(&app.nombre); + // El binario: primera palabra del comando, sin ruta. + let bin = app + .comando + .split_whitespace() + .next() + .and_then(|p| p.rsplit('/').next()) + .unwrap_or("") + .to_lowercase(); + (n == q || bin == q).then(|| app.comando.clone()) + }) +} + +#[cfg(test)] +mod tests { + use super::*; + + /// Corpus falso de comandos para los tests. + fn cmd(w: &str) -> bool { + matches!(w, "git" | "ls" | "cd" | "docker" | "cargo" | "grep" | "make" | "find" | "python") + } + + fn clas(l: &str) -> Intencion { + clasificar(l, cmd) + } + + #[test] + fn comandos_reales_van_al_shell() { + assert_eq!(clas("git status"), Intencion::Ejecutar); + assert_eq!(clas("ls -la"), Intencion::Ejecutar); + assert_eq!(clas("docker ps -a"), Intencion::Ejecutar); + assert_eq!(clas("cargo build --release"), Intencion::Ejecutar); + assert_eq!(clas("grep -r foo ."), Intencion::Ejecutar); + } + + #[test] + fn sintaxis_de_shell_y_rutas_van_al_shell() { + assert_eq!(clas("cat a.txt | grep foo"), Intencion::Ejecutar); + assert_eq!(clas("echo hola > /tmp/x"), Intencion::Ejecutar); + assert_eq!(clas("./configure --prefix=/usr"), Intencion::Ejecutar); + assert_eq!(clas("/usr/bin/env python"), Intencion::Ejecutar); + assert_eq!(clas("FOO=bar make"), Intencion::Ejecutar); + assert_eq!(clas("sudo apt install cosas raras aca"), Intencion::Ejecutar); + assert_eq!(clas("long-running &"), Intencion::Ejecutar); + assert_eq!(clas(":buscar docker"), Intencion::Ejecutar); // `:`-meta intacto + } + + #[test] + fn preguntas_y_lenguaje_natural_van_a_la_ia() { + assert_eq!(clas("por qué se cae el servicio?"), Intencion::Preguntar); + assert_eq!(clas("cómo listo los contenedores docker"), Intencion::Preguntar); + assert_eq!(clas("explicame este error de rust"), Intencion::Preguntar); + // Varias palabras cuya primera no es comando → habría sido «not found». + assert_eq!(clas("cuantos planetas tiene el sistema solar"), Intencion::Preguntar); + assert_eq!(clas("necesito un script que borre logs viejos"), Intencion::Preguntar); + } + + #[test] + fn una_palabra_desconocida_se_deja_al_shell() { + // Puede ser un binario recién instalado, un typo, o una app: no desviamos. + assert_eq!(clas("htop"), Intencion::Ejecutar); + assert_eq!(clas("firefox"), Intencion::Ejecutar); + assert_eq!(clas("gitt"), Intencion::Ejecutar); // typo de git → falla como shell, no IA + } + + #[test] + fn una_palabra_interrogativa_si_va_a_la_ia() { + assert_eq!(clas("hola?"), Intencion::Preguntar); + assert_eq!(clas("¿qué"), Intencion::Preguntar); + } + + #[test] + fn el_launcher_matchea_por_nombre_o_binario() { + use crate::types::LaunchableApp; + let apps = vec![ + LaunchableApp::new("¶ Pluma", "pluma-app-llimphi"), + LaunchableApp::new("▷ Media Tube", "media-tube"), + LaunchableApp::new("Firefox", "firefox --new-window"), + ]; + // Por nombre (normalizado, sin el glifo). + assert_eq!(buscar_app(&apps, "pluma"), Some("pluma-app-llimphi".to_string())); + assert_eq!(buscar_app(&apps, "Media Tube"), Some("media-tube".to_string())); + // Por binario. + assert_eq!(buscar_app(&apps, "pluma-app-llimphi"), Some("pluma-app-llimphi".to_string())); + // Sin match / vacío. + assert_eq!(buscar_app(&apps, "algo-random"), None); + assert_eq!(buscar_app(&apps, ""), None); + } +} diff --git a/02_ruway/shuma/sandbox/shuma-module-shell/src/lib.rs b/02_ruway/shuma/sandbox/shuma-module-shell/src/lib.rs index d00180a..2ad7f3d 100644 --- a/02_ruway/shuma/sandbox/shuma-module-shell/src/lib.rs +++ b/02_ruway/shuma/sandbox/shuma-module-shell/src/lib.rs @@ -39,649 +39,230 @@ use llimphi_ui::llimphi_text::Alignment; use llimphi_ui::{Key, KeyEvent, KeyState, NamedKey, View}; use shuma_exec::{CommandSpec, Exec, Killer, RunEvent, RunHandle, StageSpec}; use shuma_intent::SessionGraph; -use shuma_line::{LineState, TokenKind}; +use shuma_line::TokenKind; use shuma_module::{ModuleContributions, ShortcutSpec, Source}; use shuma_remote_exec::RemoteRunHandle; -use std::collections::{HashSet, VecDeque}; +use std::collections::{HashMap, HashSet, VecDeque}; use std::path::PathBuf; use std::sync::{Arc, Mutex}; /// `id` canónico del módulo. El shumarc lo referencia para activarlo. pub const ID: &str = "shell"; -/// Tope de líneas guardadas en el buffer de output — análogo al -/// `cap_log` de matilda. Suficiente para varios runs sin que el panel -/// crezca sin límite. -pub const MAX_OUTPUT_LINES: usize = 500; +/// Tope de líneas guardadas en el buffer de output activo. El scrollback +/// real vive en `State.surf_history` (con spill a disco configurable); +/// este buffer es el que alimenta `body_lines_for_block` y los detectores +/// (`sections::detect_sections`). 500 cortaba `ls -alR` antes de que el +/// detector viera el primer header de directorio. 50k cubre comandos +/// gordos sin pasarse en RAM (~10 MB para líneas de 200 bytes promedio). +pub const MAX_OUTPUT_LINES: usize = 50_000; -/// Tipo de cada línea del buffer — define el color que la `view` usa. -#[derive(Debug, Clone, Copy, PartialEq, Eq)] -pub enum OutputKind { - /// El comando tal como lo tipeó el usuario (precede a su output). - Prompt, - /// stdout del comando. - Stdout, - /// stderr del comando. - Stderr, - /// Mensaje del shell mismo (cd, error de spawn, exit status, etc.). - Notice, -} +// ── Submódulos de tipos, mensajes y fuentes ────────────────────────────────── +/// El input de la barra sobre el editor compartido de Llimphi (ver el módulo). +mod input_editor; +pub use input_editor::InputShuma; -/// Una línea del buffer de output con su tipo (para coloreado) y el -/// bloque de comando al que pertenece. El render agrupa las líneas con -/// el mismo `block` en una *card* desplegable (un `$ cmd` + su salida + -/// su exit status). `block == 0` = líneas sueltas sin comando dueño. -#[derive(Debug, Clone)] -pub struct OutputLine { - pub kind: OutputKind, - pub text: String, - /// Bloque de comando. Lo asigna [`State::push_output`] — cada - /// `Prompt` abre uno nuevo (id monotónico) y las siguientes líneas - /// lo heredan. Por defecto `0` (las constructoras no lo conocen). - pub block: u64, - /// Etapa intermedia del pipe que produjo la línea (tee de - /// `shuma-exec`), 0-based. `None` = salida normal (de la última etapa - /// o de un comando suelto). El render guarda estas líneas para el - /// desplegable de su etapa en vez de mezclarlas con el cuerpo. - pub stage: Option, -} +mod types; +pub use types::*; -impl OutputLine { - pub fn prompt(text: impl Into) -> Self { - Self { - kind: OutputKind::Prompt, - text: text.into(), - block: 0, - stage: None, - } +/// El protocolo de avisos del terminal (BEL, título OSC, notificaciones). +pub mod campana; +pub use campana::{Buzon, Campanario, Notificacion}; + +/// El caudal de salida de un shell, para pintarlo como cava. +pub mod pulso; +pub use pulso::Pulso; +// La marquesina es un contrato compartido (`shuma-module`); se re-exporta aquí +// para que los hosts (pata) la construyan sin sumar la dependencia directa. +pub use shuma_module::{Marquesina, Urgencia}; + +mod msg; +pub use msg::*; + +mod shell_source; +pub use shell_source::*; + +pub mod intent; +pub use intent::{clasificar as clasificar_intencion, Intencion}; + +mod history_helpers; +pub use history_helpers::*; + +// ── Submódulos de UI y lógica ──────────────────────────────────────────────── +/// Íconos XDG de apps `.desktop` (resolución freedesktop + cache de assets). +/// Vivía en pata (que lo re-exporta); bajó aquí para que el panel de completado +/// pinte íconos reales en TODOS los frontends del shell (barra, drawer, +/// standalone), no sólo en la surface flotante de pata. +pub mod app_icons; +mod mouse_xterm; +pub mod sections; +pub mod codigo; + +/// Diagnóstico de ciclo de vida de pestañas/runs (para cazar el bug de "abrir +/// una pestaña mata la de al lado", 2026-07-22). No-op salvo que exista el +/// centinela `/tmp/shuma-diag` — así no cuesta nada en operación normal y se +/// enciende con un `touch` sin recompilar ni pasar env (pata respawnea sola y +/// no hereda env, ver memoria `pata-deploy-respawn`). Append-only a +/// `/tmp/shuma-tab-diag.txt`; el chasis (shuma-shell-llimphi) y el módulo +/// escriben al mismo archivo para poder correlacionar TabNew ↔ muerte de run. +pub fn diag_tab(msg: &str) { + if !std::path::Path::new("/tmp/shuma-diag").exists() { + return; } - pub fn stdout(text: impl Into) -> Self { - Self { - kind: OutputKind::Stdout, - text: text.into(), - block: 0, - stage: None, - } - } - pub fn stderr(text: impl Into) -> Self { - Self { - kind: OutputKind::Stderr, - text: text.into(), - block: 0, - stage: None, - } - } - pub fn notice(text: impl Into) -> Self { - Self { - kind: OutputKind::Notice, - text: text.into(), - block: 0, - stage: None, - } - } - /// Línea capturada de una etapa intermedia del pipe (tee en vivo). Se - /// guarda con su `stage` para el desplegable correspondiente. - pub fn stage_stdout(stage: usize, text: impl Into) -> Self { - Self { - kind: OutputKind::Stdout, - text: text.into(), - block: 0, - stage: Some(stage), - } + use std::io::Write; + let t = std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .map(|d| d.as_millis()) + .unwrap_or(0); + if let Ok(mut f) = std::fs::OpenOptions::new() + .create(true) + .append(true) + .open("/tmp/shuma-tab-diag.txt") + { + let _ = writeln!(f, "{t} {msg}"); } } - -/// Run vivo: handle de ejecución (local directo o vía daemon), un -/// `Killer` opcional (solo en local — el remoto matamos cerrando el -/// stream) y el comando original (para el notice de cierre). -pub struct ActiveRun { - pub handle: BackendHandle, - /// `Some` cuando el run es local (`shuma-exec::RunHandle.killer()`). - /// `None` cuando es remoto — la cancelación va por `handle.kill()`. - pub killer: Option, - pub command: String, - /// Sesión TUI: emulador vt100 + dims del PTY. `Some` cuando el run - /// arrancó bajo `Exec::Pty` (vim/htop/less/etc.); las teclas van al - /// stdin del PTY y la pantalla se renderiza como grid de celdas. - /// El daemon no soporta PTY remoto todavía — TUIs forzados a local. - pub tui: Option, - /// Bloque de output al que se adjunta TODA la salida de este run — - /// fijo desde el arranque. Sin esto, un comando lento que drena en - /// ticks posteriores se mezclaría con el bloque "actual" (p. ej. un - /// builtin tipeado mientras corre), o un job de fondo se metería en - /// la card del foreground. Cada run vive en su propia card. - pub block: u64, -} - -/// Backend de ejecución abstracto. Local va por `shuma-exec`; Daemon -/// (Unix o TCP) va por `shuma-remote-exec`. La API expuesta al módulo -/// shell (`try_events`, `is_finished`, `kill`, `write_input`, `resize`) -/// es la misma — las operaciones de PTY son no-op en remoto. -pub enum BackendHandle { - Local(RunHandle), - Remote(RemoteRunHandle), -} - -impl BackendHandle { - pub fn try_events(&mut self) -> Vec { - match self { - BackendHandle::Local(h) => h.try_events(), - BackendHandle::Remote(h) => h.try_events(), - } - } - pub fn is_finished(&self) -> bool { - match self { - BackendHandle::Local(h) => h.is_finished(), - BackendHandle::Remote(h) => h.is_finished(), - } - } - pub fn kill(&self) { - match self { - BackendHandle::Local(h) => h.kill(), - BackendHandle::Remote(h) => h.kill(), - } - } - pub fn write_input(&self, bytes: Vec) -> bool { - match self { - BackendHandle::Local(h) => h.write_input(bytes), - // En PTY remoto, el asa enruta las teclas al daemon; en runs - // remotos no-PTY es no-op (devuelve false). - BackendHandle::Remote(h) => h.write_input(bytes), - } - } - pub fn resize(&self, rows: u16, cols: u16) -> bool { - match self { - BackendHandle::Local(h) => h.resize(rows, cols), - BackendHandle::Remote(h) => h.resize(rows, cols), - } - } -} - -/// Skin de render para un programa bajo PTY. `Generic` pinta la grilla -/// vt100 cruda; los demás reconstruyen la pantalla como un card -/// themeable propio del programa (deja de verse "como por un vidrio"). -#[derive(Debug, Clone, Copy, PartialEq, Eq)] -pub enum AppSkin { - /// Grilla de celdas vt100 (htop, less, man, btop, …). - Generic, - /// vim/nvim/vi: el buffer como texto en la paleta del tema. - Vim, - /// claude code: un card grande que engloba la sesión (por ahora cae - /// al genérico hasta que esté el parser de bloques). - Claude, -} - -/// Elige el skin a partir del nombre del programa (acepta un path — -/// toma el basename). -pub fn app_skin_for(program: &str) -> AppSkin { - let base = program.rsplit('/').next().unwrap_or(program); - match base { - "vi" | "vim" | "nvim" | "view" | "nvi" => AppSkin::Vim, - "claude" => AppSkin::Claude, - _ => AppSkin::Generic, - } -} - -/// Sesión TUI sobre PTY — bufferea el parser vt100 y los dims actuales. -pub struct TuiSession { - pub parser: vt100::Parser, - pub rows: u16, - pub cols: u16, - /// Programa bajo el PTY (basename incluido) — define el skin. - pub program: String, - /// Skin de render elegido al arrancar. - pub skin: AppSkin, -} - -impl TuiSession { - pub fn new(program: &str, rows: u16, cols: u16) -> Self { - Self { - parser: vt100::Parser::new(rows, cols, 0), - rows, - cols, - program: program.to_string(), - skin: app_skin_for(program), - } - } - - /// Cambia las dimensiones del buffer interno del parser. El resize - /// del PTY real (que dispara SIGWINCH al child) lo hace el caller - /// vía `RunHandle::resize`. - pub fn set_size(&mut self, rows: u16, cols: u16) { - if rows == self.rows && cols == self.cols { - return; - } - self.parser.screen_mut().set_size(rows, cols); - self.rows = rows; - self.cols = cols; - } -} - -impl std::fmt::Debug for ActiveRun { - fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { - f.debug_struct("ActiveRun") - .field("command", &self.command) - .field("finished", &self.handle.is_finished()) - .field("tui", &self.tui.is_some()) - .finish() - } -} - -/// Dims fijos para el PTY mientras el chasis no exponga el ancho real -/// del panel. 80×24 es el default histórico y vim/htop arrancan bien. -const PTY_ROWS: u16 = 24; -const PTY_COLS: u16 = 80; - -/// Tabla de comandos que pedimos PTY automáticamente. Otros pueden -/// pedirlo con el prefijo `:tui ...`. -const TUI_ALLOWLIST: &[&str] = &[ - "vi", "vim", "nvim", "nano", "emacs", "helix", "hx", "htop", "btop", "top", "less", "more", - "man", "claude", "tig", "tui", "watch", -]; - -/// Selección activa/última en el card de vim, en coordenadas locales px -/// del panel (`ax,ay` = ancla del press; `hx,hy` = cabeza/cursor). -/// `active` = hay un drag en curso. -#[derive(Debug, Clone, Copy)] -pub struct VimSel { - pub ax: f32, - pub ay: f32, - pub hx: f32, - pub hy: f32, - pub active: bool, -} - -#[derive(Clone)] -pub struct State { - pub source: Source, - pub cwd: PathBuf, - pub input: LineState, - pub output: Vec, - pub focused: bool, - /// Run en ejecución, si hay. Cloneable por `Arc>` — la - /// derivación `Clone` del state nos obliga a esto (el chasis clona - /// el state en cada `route_to_instance`). - pub running: Option>>, - /// Cola de líneas pendientes — cuando el usuario presiona Enter - /// mientras hay un run vivo, el nuevo comando entra acá y arranca - /// cuando el actual cierra. - pub queue: VecDeque, - /// Fuente de completion (binarios en `$PATH` + paths bajo cwd). Es - /// `Arc` porque el `complete()` de `shuma-line` la usa por - /// referencia y el state se clona en cada `route_to_instance`. - pub completion_source: Arc, - /// Historial durable de líneas submitted — alimenta ghost - /// suggestion + Up/Down + Ctrl-R fuzzy. - pub history: Arc>, - /// Cursor de navegación del historial. `None` = no navegando. - pub history_cursor: Option, - /// Overlay de búsqueda Ctrl-R activo. `None` = no abierto. - pub history_search: Option, - /// Último rect (w, h) píxel del panel TUI — lo escribe el painter - /// y lo lee `drain_run` para disparar resize si cambia. Cero = - /// "todavía no se pintó". - pub last_tui_rect: Arc>, - /// Métricas reales (char_w, line_h) del monospace del card de vim, - /// medidas por el painter sobre el layout de parley y leídas por - /// `copy_vim_selection`. Cero = todavía sin medir (usar fallback). - pub vim_metrics: Arc>, - /// Jobs en background — arrancados con sufijo `&` en la línea. No - /// son el "foreground" (ese es `running`); su output se mergea al - /// buffer prefijado por `[N]`. Builtins `:jobs`, `:term N`, - /// `:stop N`, `:cont N` operan sobre estos. - pub bg_jobs: Vec>>, - /// Grafo de intenciones de la sesión — alimenta el lienzo de - /// contexto (`shuma-module-canvas`). Cada `start_run` registra un - /// nodo `%cN` y `drain_run` lo cierra con el status del exit. - pub intent_graph: SessionGraph, - /// `%cN` del run en foreground actual; `None` cuando no hay nada - /// corriendo. Se setea en `start_run` y se consume en `drain_run`. - pub current_run_node: Option, - /// Bytes acumulados de stdout+stderr del run actual; se vuelca al - /// nodo del grafo cuando el comando cierra (`complete`). - pub current_run_bytes: u64, - /// Selección del card de vim (drag-to-select). `None` = sin selección. - pub vim_sel: Option, - /// Contador monotónico de bloques de comando. Cada `Prompt` lo - /// incrementa; nunca se reusa, así el colapso sobrevive al capado - /// del buffer (los ids no se reciclan al drenar líneas viejas). - pub block_seq: u64, - /// Bloque al que se adjuntan las líneas nuevas (el último `Prompt`). - pub current_block: u64, - /// Bloques colapsados por el usuario (click en el header de la card). - /// Se renderizan plegados, mostrando sólo el header + un resumen. - pub collapsed: HashSet, - /// Etapas de pipe desplegadas — `(block, stage)`. Click en un chip de - /// etapa alterna la pertenencia; al estar presente se muestran sus - /// líneas capturadas en vivo (tee) bajo la fila de etapas. - pub expanded_stages: HashSet<(u64, usize)>, - /// Patrones de comandos inferidos del historial (`shuma-infer`). Se - /// recalculan al cerrar cada comando y alimentan el ghost con la - /// secuencia predicha (no sólo el historial reciente). Vacío al - /// arrancar y hasta tener suficiente historial. - pub patterns: Vec, - /// Tope de captura de stdout por run, en bytes. `0` = sin tope. Lo fija - /// el builtin `:limit `. - pub capture_limit_bytes: usize, - /// Si volcar a disco la salida que excede el tope (`:spill on`). Sólo - /// tiene efecto con `capture_limit_bytes > 0`. - pub spill: bool, - /// Bloque cuyo stdout alimenta el stdin del próximo run (reprocess — - /// el `%pN` del lienzo). Lo arma el chip ↻ de una card y se consume en - /// el siguiente submit. `None` = sin reprocess armado. - pub reprocess_source: Option, - /// Grupos de comandos guardados con `:save ` — ejecutables por - /// F1..F8 (índice 0-based = número de F menos 1). - pub groups: Vec, - /// Largo del historial en el último `:save` — los comandos desde acá - /// son los que entran al próximo grupo. - pub group_anchor: usize, - /// Completado activo (popup de candidatos). `Some` = popup abierto (Tab - /// con ≥2 opciones); se navega con Tab/flechas y se acepta con Enter. - pub completion: Option, - /// Candidato resaltado dentro del popup de completado. - pub completion_index: usize, - /// Scroll del panel de output, en px medidos desde el fondo. `0` = - /// pegado al fondo (lo último siempre visible, como una terminal). - /// Crece al rodar la rueda hacia arriba (ver historial). Lo clampa - /// la `view` contra el overflow real. - pub scroll_px: f32, - /// Alto del viewport de output (lo publica el painter del panel cada - /// frame; lo lee la `view` y el handler de rueda al frame siguiente). - pub out_viewport_h: Arc>, - /// Overflow vertical del output (content_h − viewport_h, ≥0). Lo - /// publica la `view` y lo usa `Msg::Scroll` para clampar `scroll_px` - /// sin recalcular la geometría en el handler. - pub out_overflow: Arc>, -} - -/// Estado del overlay de búsqueda Ctrl-R. -#[derive(Debug, Clone, Default)] -pub struct HistorySearch { - pub query: String, - pub selected: usize, -} - -/// Grupo de comandos guardado (`:save `) — una secuencia ejecutable -/// como una sola línea (`l1 && l2 && …`) desde una tecla de función. -#[derive(Debug, Clone)] -pub struct CommandGroup { - pub name: String, - pub lines: Vec, -} - -impl State { - pub fn new(source: Source) -> Self { - let cwd = std::env::current_dir().unwrap_or_else(|_| PathBuf::from("/")); - let completion_source = Arc::new(ShellSource::new(&cwd)); - let history = Arc::new(Mutex::new(open_history())); - // El anchor de grupos arranca al final del historial durable: el - // primer `:save` agrupa sólo lo tipeado en ESTA sesión, no meses - // de historial persistido. - let group_anchor = history.lock().map(|h| h.len()).unwrap_or(0); - Self { - source, - cwd, - input: LineState::new(), - output: Vec::new(), - focused: true, - running: None, - queue: VecDeque::new(), - completion_source, - history, - history_cursor: None, - history_search: None, - last_tui_rect: Arc::new(Mutex::new((0.0, 0.0))), - vim_metrics: Arc::new(Mutex::new((0.0, 0.0))), - bg_jobs: Vec::new(), - intent_graph: SessionGraph::new(), - current_run_node: None, - current_run_bytes: 0, - vim_sel: None, - block_seq: 0, - current_block: 0, - collapsed: HashSet::new(), - expanded_stages: HashSet::new(), - patterns: Vec::new(), - capture_limit_bytes: 0, - spill: false, - reprocess_source: None, - groups: Vec::new(), - group_anchor, - completion: None, - completion_index: 0, - scroll_px: 0.0, - out_viewport_h: Arc::new(Mutex::new(0.0)), - out_overflow: Arc::new(Mutex::new(0.0)), - } - } - - /// Empuja una línea al buffer asignándole bloque. Cada `Prompt` abre - /// un bloque nuevo (id monotónico); las demás líneas heredan el - /// bloque abierto. El render usa esto para agrupar cada comando con - /// su salida en una card desplegable. - pub(crate) fn push_output(&mut self, mut line: OutputLine) { - if line.kind == OutputKind::Prompt { - self.block_seq += 1; - self.current_block = self.block_seq; - } - line.block = self.current_block; - push_line(&mut self.output, line); - } - - /// Reserva un bloque nuevo sin tocar `current_block` — para runs que - /// drenan asíncronos (foreground lento, jobs de fondo) y necesitan su - /// propia card aunque otros comandos se intercalen mientras tanto. - pub(crate) fn open_block(&mut self) -> u64 { - self.block_seq += 1; - self.block_seq - } - - /// Empuja una línea en un bloque explícito (no en `current_block`). - /// La usa el drenado de runs async para que su salida quede en SU - /// card y no en la del comando que el usuario tipeó mientras tanto. - pub(crate) fn push_in_block(&mut self, block: u64, mut line: OutputLine) { - line.block = block; - push_line(&mut self.output, line); - } - - /// Vacía el buffer y el set de colapsos. No resetea `block_seq` — - /// mantener ids monotónicos es inofensivo y evita reusos. - pub(crate) fn clear_output(&mut self) { - self.output.clear(); - self.collapsed.clear(); - self.expanded_stages.clear(); - self.reprocess_source = None; - self.scroll_px = 0.0; - } - - /// Cantidad de líneas en el buffer — alimenta el monitor. - pub fn output_len(&self) -> usize { - self.output.len() - } - - /// `true` si hay un comando ejecutándose ahora. - pub fn is_running(&self) -> bool { - self.running.is_some() - } - - /// Snapshot del grafo de intenciones — el chasis lo lee cada tick - /// y lo sincroniza al `shuma-module-canvas` activo. - pub fn intent_graph(&self) -> &SessionGraph { - &self.intent_graph - } -} - -/// Fuente de candidatos del shell — implementa -/// [`shuma_line::CompletionSource`]: -/// -/// - `commands()`: escanea `$PATH` la primera vez y cachea el resultado. -/// - `paths(prefix)`: listado del dir derivado del `prefix`, resolviendo -/// relativos contra `cwd`. -#[derive(Debug)] -pub struct ShellSource { - cwd: PathBuf, - commands: std::sync::OnceLock>, -} - -impl ShellSource { - pub fn new(cwd: &std::path::Path) -> Self { - Self { - cwd: cwd.to_path_buf(), - commands: std::sync::OnceLock::new(), - } - } -} - -impl shuma_line::CompletionSource for ShellSource { - fn commands(&self) -> Vec { - self.commands - .get_or_init(|| { - let path = std::env::var_os("PATH").unwrap_or_default(); - let mut out: Vec = Vec::new(); - for dir in std::env::split_paths(&path) { - if let Ok(rd) = std::fs::read_dir(&dir) { - for ent in rd.flatten() { - if let Some(name) = ent.file_name().to_str() { - out.push(name.to_string()); - } - } - } - } - out.sort(); - out.dedup(); - out - }) - .clone() - } - fn paths(&self, prefix: &str) -> Vec { - let (dir_part, file_part) = match prefix.rfind('/') { - Some(i) => (&prefix[..=i], &prefix[i + 1..]), - None => ("", prefix), - }; - let dir: PathBuf = if dir_part.is_empty() { - self.cwd.clone() - } else if dir_part.starts_with('/') { - PathBuf::from(dir_part) - } else if let Some(stripped) = dir_part.strip_prefix("~/") { - if let Ok(home) = std::env::var("HOME") { - PathBuf::from(home).join(stripped) - } else { - self.cwd.join(dir_part) - } - } else { - self.cwd.join(dir_part) - }; - let Ok(rd) = std::fs::read_dir(&dir) else { - return Vec::new(); - }; - let mut out: Vec = Vec::new(); - for ent in rd.flatten() { - let name = match ent.file_name().to_str() { - Some(n) => n.to_string(), - None => continue, - }; - if !name.starts_with(file_part) { - continue; - } - // Ocultos: sólo aparecen si el prefix los pidió explícito. - if name.starts_with('.') && !file_part.starts_with('.') { - continue; - } - let mut full = format!("{dir_part}{name}"); - if ent.file_type().map(|t| t.is_dir()).unwrap_or(false) { - full.push('/'); - } - out.push(full); - } - out.sort(); - out - } -} - -/// Abre el historial en `$XDG_DATA_HOME/shuma/history.jsonl` (o el -/// fallback de `directories`). Si no se puede abrir, devuelve un -/// historial vacío en `/dev/null` — el shell sigue funcionando sin -/// persistencia. -fn open_history() -> shuma_history::History { - if let Some(path) = shuma_history::History::default_path() { - if let Some(parent) = path.parent() { - let _ = std::fs::create_dir_all(parent); - } - if let Ok(h) = shuma_history::History::open(&path) { - return h; - } - } - // Fallback: historial en /dev/null (existe siempre, append-only OK). - shuma_history::History::open(std::path::PathBuf::from("/dev/null")) - .unwrap_or_else(|_| panic!("no se pudo abrir ni /dev/null como history")) -} - -#[derive(Debug, Clone)] -pub enum Msg { - /// Tecla recibida desde el chasis. Enter ejecuta, Tab completa, - /// flechas y edición van al `LineState`. - Key(KeyEvent), - /// Click sobre el input box — re-foca (sigue siendo el único - /// campo, pero lo mantenemos por simetría con otros módulos). - FocusInput, - /// Limpia el buffer de output — disparado por el shortcut `Clear` - /// o el builtin `clear`. - Clear, - /// Drena eventos del run activo (si hay) y pinta líneas nuevas. - /// Lo dispara el chasis a alta frecuencia (~100 ms). - Tick, - /// SIGTERM al run activo (Ctrl-C o shortcut `Cancel`). - Cancel, - /// Click en una decoración del output — el dispatch decide la - /// acción (cd, xdg-open, pre-llenar el input, etc.). - OpenDecoration(shuma_line::DecorationKind), - /// Inserta `text` en la posición actual del cursor del input. La - /// dispara el chasis cuando otro módulo (p. ej. `shuma-module-canvas` - /// al clickear un nodo) quiere empujar una referencia `%pN`/`%cN` - /// al REPL. Cierra los overlays de búsqueda y deja el cursor justo - /// después del texto insertado. - InsertAtCursor(String), - /// Pega el clipboard al PTY del TUI activo — click derecho o botón - /// del medio sobre el panel de vim (paste estilo terminal). - VimPaste, - /// Drag de selección sobre el card de vim. `dx`/`dy` = delta desde el - /// evento anterior; `ax`/`ay` = posición del press (local al panel). - VimDrag { - end: bool, - dx: f32, - dy: f32, - ax: f32, - ay: f32, - }, - /// Alterna plegado/desplegado de la card de un comando. La dispara el - /// click en el header de la card (chevron + comando). - ToggleBlock(u64), - /// Rueda del mouse sobre el panel de output. `delta` ya viene en px - /// (positivo = rodar hacia arriba / ver historial). Ajusta `scroll_px`. - Scroll(f32), - /// Re-ejecuta `line` como un comando nuevo — la dispara el click en - /// una etapa de pipe de una card SIN captura en vivo (fallback `sh -c`). - RunLine(String), - /// Alterna el desplegable de una etapa de pipe con captura en vivo - /// (tee). La dispara el click en su chip; muestra/oculta las líneas - /// intermedias ya capturadas sin re-ejecutar nada. - ToggleStage { block: u64, stage: usize }, - /// Arma el reprocess: el stdout del bloque `block` alimentará el stdin - /// del próximo comando. La dispara el chip ↻ de una card. Si ya estaba - /// armado el mismo bloque, lo desarma (toggle). - SetReprocess(u64), - /// Ejecuta el grupo guardado de índice `idx` (0-based). La dispara el - /// click en su card del panel de grupos (equivale a la tecla F{idx+1}). - RunGroup(usize), -} - mod update; mod view; +pub use mouse_xterm::{XBtn, XPhase}; pub use update::*; pub use view::*; +/// Arma el `Scrollback` persistente desde la config: cap en MiB + +/// (opcional) spill a un archivo en `$XDG_RUNTIME_DIR/shuma-.spill` +/// (o el path explícito de la config). Errores al armar el spill se +/// degradan a "sin spill" (el history funciona igual, sólo pierde el +/// archivo de archive). +fn build_surf_history(config: &shuma_config::Config) -> llimphi_widget_terminal::Scrollback { + let limit_bytes = config.scrollback.limit_mb.saturating_mul(1024 * 1024); + let mut sb = llimphi_widget_terminal::Scrollback::new(limit_bytes); + if config.scrollback.spill { + let path = if !config.scrollback.spill_path.is_empty() { + PathBuf::from(&config.scrollback.spill_path) + } else { + let dir = std::env::var_os("XDG_RUNTIME_DIR") + .map(PathBuf::from) + .unwrap_or_else(|| std::env::temp_dir()); + dir.join(format!("shuma-{}.spill", std::process::id())) + }; + if let Ok(spill) = llimphi_widget_terminal::SpillStore::create(&path) { + sb.enable_spill(spill); + } + // Sin spill si falló crear el archivo — no es fatal, el shell sigue. + } + sb +} + +/// Inicio efectivo (global id) de la ventana del archive para un +/// `window_start` deseado y un `spilled_count` dado. Puro y testeable. +/// +/// - `None` (cola automática): las últimas [`MAX_SPILLED_VISIBLE`]. +/// - `Some(id)`: el inicio que pidió el paginado, pero **clampeado** a no +/// cargar más de [`MAX_SPILLED_LOADED`] líneas desde el final (piso duro) y +/// a no pasar del propio `spilled_count`. +pub(crate) fn spill_effective_start(window_start: Option, spilled_count: usize) -> u64 { + let floor = spilled_count.saturating_sub(MAX_SPILLED_LOADED) as u64; + match window_start { + None => spilled_count.saturating_sub(MAX_SPILLED_VISIBLE) as u64, + Some(id) => id.max(floor).min(spilled_count as u64), + } +} + +/// Decide si paginar hacia atrás el archive al estar cerca del tope del +/// contenido. Devuelve `Some(nuevo_window_start)` si hay líneas más viejas +/// por cargar y el scroll está a tiro del borde superior; `None` si no hay +/// que paginar (ya en el inicio, contra el piso de carga, o lejos del tope). +/// Puro y testeable — no toca I/O ni estado. +pub(crate) fn spill_page_back( + window_start: Option, + spilled_count: usize, + scroll_y: f32, + row_h: f32, +) -> Option { + // Sólo cuando el viewport está pegado al borde superior del contenido. + if scroll_y > row_h * 3.0 { + return None; + } + let effective = spill_effective_start(window_start, spilled_count); + let floor = spilled_count.saturating_sub(MAX_SPILLED_LOADED) as u64; + // Ya en el inicio del archive, o tocando el piso de carga → nada que traer. + if effective == 0 || effective <= floor { + return None; + } + let new_start = effective.saturating_sub(SPILL_PAGE as u64).max(floor); + (new_start < effective).then_some(new_start) +} + +/// Refresca el cache de líneas spilled visibles si la ventana cambió (nuevo +/// spill al final, o el usuario paginó hacia atrás). Lee `[effective_start, +/// spilled_count)` del archive vía `Scrollback::read_spilled`. Si el read +/// falla por I/O, la entrada queda como `` (no propaga — el view +/// sigue). Síncrono: el costo es N reads, una sola vez por cambio de ventana +/// (early-return si nada cambió, así no cuesta por frame). +pub(crate) fn refresh_surf_spilled_visible( + history: &Arc>, + cache: &Arc>, +) { + // Snapshot del estado del history sin retener el lock durante el I/O. + let (spilled_count, hist_clone) = { + let Ok(h) = history.lock() else { return }; + (h.spilled_count(), h.clone()) + }; + let first_id = { + let Ok(c) = cache.lock() else { return }; + let first_id = spill_effective_start(c.window_start, spilled_count); + // Fresco si no spilleó más Y la ventana arranca donde ya está cargada. + if c.cached_at == spilled_count && c.first_id == first_id && !c.lines.is_empty() { + return; + } + // Caso degenerado: sin nada spilleado, limpia el cache. + if spilled_count == 0 { + return; + } + first_id + }; + // Refresh: leer `[first_id, spilled_count)`. + let n = (spilled_count as u64).saturating_sub(first_id) as usize; + let mut lines = Vec::with_capacity(n); + for i in 0..n { + let id = first_id + i as u64; + match hist_clone.read_spilled(id) { + Ok(Some(text)) => lines.push(text), + Ok(None) => lines.push(String::new()), + Err(_) => lines.push("".into()), + } + } + if let Ok(mut c) = cache.lock() { + c.lines = lines; + c.first_id = first_id; + c.cached_at = spilled_count; + } +} + +/// Appendea el texto de `line` a la `Scrollback` persistente sólo si es una +/// línea de **body** (no Prompt, no salida de etapa intermedia, no notice +/// de cierre `✔/✘/⏹`). Espeja el filtro de `body_lines_for_block` para +/// que el history acumule sólo lo que el view ve como cuerpo. Errores del +/// lock se ignoran (poison defensivo). +fn push_to_surf_history( + history: &Arc>, + line: &OutputLine, +) { + if line.kind == OutputKind::Prompt { + return; + } + if line.stage.is_some() { + return; + } + if view::is_status_line(&line.text) { + return; + } + if let Ok(mut h) = history.lock() { + h.push_line(&line.text); + } +} + pub fn contributions(_state: &State) -> ModuleContributions { ModuleContributions { monitors: vec![], @@ -695,1039 +276,4 @@ pub fn contributions(_state: &State) -> ModuleContributions { } #[cfg(test)] -mod tests { - use super::*; - use llimphi_ui::Modifiers; - - fn ev(key: Key, text: Option<&str>) -> KeyEvent { - KeyEvent { - key, - state: KeyState::Pressed, - text: text.map(|s| s.to_string()), - modifiers: Modifiers::default(), - repeat: false, - } - } - - /// Aplica `Msg::Tick` hasta que el run vivo se cierre (o se acabe el - /// presupuesto). Imita lo que el chasis hace a 100 ms entre ticks. - fn drain_until_idle(mut s: State) -> State { - let deadline = std::time::Instant::now() + std::time::Duration::from_secs(10); - while s.is_running() { - s = update(s, Msg::Tick); - if std::time::Instant::now() > deadline { - panic!("run no terminó en 10s"); - } - std::thread::sleep(std::time::Duration::from_millis(10)); - } - // Un Tick más por si quedó algo en el canal después del Exited. - update(s, Msg::Tick) - } - - #[test] - fn id_is_stable() { - assert_eq!(ID, "shell"); - } - - #[test] - fn placeholder_state_constructs() { - let s = State::new(Source::Local); - assert!(s.output.is_empty()); - assert!(s.cwd.is_absolute() || s.cwd == PathBuf::from("/")); - } - - #[test] - fn pwd_builtin_writes_cwd() { - let mut s = State::new(Source::Local); - s.input.set_text("pwd"); - s = update(s, Msg::Key(ev(Key::Named(NamedKey::Enter), None))); - assert!(s.output.iter().any(|l| l.text.starts_with("$ pwd"))); - assert!(s.output.iter().any(|l| l.kind == OutputKind::Stdout)); - } - - #[test] - fn clear_builtin_empties_output() { - let mut s = State::new(Source::Local); - s.input.set_text("pwd"); - s = update(s, Msg::Key(ev(Key::Named(NamedKey::Enter), None))); - assert!(!s.output.is_empty()); - s.input.set_text("clear"); - s = update(s, Msg::Key(ev(Key::Named(NamedKey::Enter), None))); - assert!(s.output.is_empty()); - } - - #[test] - fn clear_msg_empties_output() { - let mut s = State::new(Source::Local); - s.output.push(OutputLine::stdout("hola")); - s = update(s, Msg::Clear); - assert!(s.output.is_empty()); - } - - #[test] - fn cd_to_root_changes_cwd() { - let mut s = State::new(Source::Local); - s.input.set_text("cd /"); - s = update(s, Msg::Key(ev(Key::Named(NamedKey::Enter), None))); - assert_eq!(s.cwd, PathBuf::from("/")); - } - - #[test] - fn cd_to_nonexistent_logs_error() { - let mut s = State::new(Source::Local); - s.input.set_text("cd /nope/this/does/not/exist"); - s = update(s, Msg::Key(ev(Key::Named(NamedKey::Enter), None))); - assert!(s.output.iter().any(|l| l.text.starts_with("cd:"))); - } - - #[test] - fn external_command_captures_stdout() { - let mut s = State::new(Source::Local); - s.cwd = PathBuf::from("/"); - s.input.set_text("echo hola_mundo"); - s = update(s, Msg::Key(ev(Key::Named(NamedKey::Enter), None))); - assert!(s.is_running(), "Enter debe arrancar el run"); - s = drain_until_idle(s); - let combined: Vec = s.output.iter().map(|l| l.text.clone()).collect(); - assert!( - combined.iter().any(|t| t == "hola_mundo"), - "esperaba stdout 'hola_mundo' en {combined:?}" - ); - assert!(combined.iter().any(|t| t == "✔ exit 0")); - } - - #[test] - fn external_command_failure_writes_exit_nonzero() { - let mut s = State::new(Source::Local); - s.cwd = PathBuf::from("/"); - s.input.set_text("false"); - s = update(s, Msg::Key(ev(Key::Named(NamedKey::Enter), None))); - s = drain_until_idle(s); - assert!(s.output.iter().any(|l| l.text.starts_with("✘ exit"))); - } - - #[test] - fn long_running_command_does_not_block_update() { - // `sleep 0.3` debería volver de `update` inmediatamente (no - // bloquear ~300 ms como con `Command::output`). Si el spawn es - // no-bloqueante, `update` retorna en pocos milisegundos. - let mut s = State::new(Source::Local); - s.cwd = PathBuf::from("/"); - s.input.set_text("sleep 0.3"); - let t0 = std::time::Instant::now(); - s = update(s, Msg::Key(ev(Key::Named(NamedKey::Enter), None))); - let elapsed = t0.elapsed(); - assert!( - elapsed.as_millis() < 100, - "update bloqueó {elapsed:?} — debería volver al instante" - ); - assert!(s.is_running(), "el sleep debe seguir vivo tras Enter"); - s = drain_until_idle(s); - assert!(s.output.iter().any(|l| l.text == "✔ exit 0")); - } - - #[test] - fn second_enter_queues_while_busy() { - let mut s = State::new(Source::Local); - s.cwd = PathBuf::from("/"); - s.input.set_text("sleep 0.2"); - s = update(s, Msg::Key(ev(Key::Named(NamedKey::Enter), None))); - assert!(s.is_running()); - s.input.set_text("echo segunda"); - s = update(s, Msg::Key(ev(Key::Named(NamedKey::Enter), None))); - assert_eq!(s.queue.len(), 1, "segunda línea debe quedar en cola"); - s = drain_until_idle(s); - // Tras drenar, la cola arrancó y ya cerró el segundo run. - assert_eq!(s.queue.len(), 0); - let combined: Vec = s.output.iter().map(|l| l.text.clone()).collect(); - assert!(combined.iter().any(|t| t == "segunda"), "{combined:?}"); - } - - #[test] - fn cancel_terminates_active_run() { - let mut s = State::new(Source::Local); - s.cwd = PathBuf::from("/"); - s.input.set_text("sleep 30"); - s = update(s, Msg::Key(ev(Key::Named(NamedKey::Enter), None))); - assert!(s.is_running()); - // El coordinador de `shuma-exec` puebla `Killer.children` en - // background — un Cancel inmediato podría llegar antes y la - // señal caería en el vacío. Esperar a que aparezca el PID. - let arc = s.running.as_ref().unwrap().clone(); - let deadline = std::time::Instant::now() + std::time::Duration::from_millis(500); - while std::time::Instant::now() < deadline { - let has_pid = arc - .lock() - .unwrap() - .killer - .as_ref() - .map(|k| !k.pids().is_empty()) - .unwrap_or(false); - if has_pid { - break; - } - std::thread::sleep(std::time::Duration::from_millis(10)); - } - assert!( - arc.lock() - .unwrap() - .killer - .as_ref() - .map(|k| !k.pids().is_empty()) - .unwrap_or(false), - "el coordinador no expuso el PID en 500ms" - ); - s = update(s, Msg::Cancel); - s = drain_until_idle(s); - assert!(!s.is_running(), "sleep 30 debe morir al cancelar"); - assert!(s.output.iter().any(|l| l.text.starts_with("⏹ cancel"))); - } - - #[test] - fn empty_submit_does_nothing_but_clears_input() { - let mut s = State::new(Source::Local); - s.input.set_text(" "); - s = update(s, Msg::Key(ev(Key::Named(NamedKey::Enter), None))); - assert!(s.output.is_empty()); - assert!(s.input.text().is_empty()); - } - - #[test] - fn output_buffer_caps_at_max() { - let mut buf: Vec = Vec::new(); - for i in 0..MAX_OUTPUT_LINES + 50 { - push_line(&mut buf, OutputLine::stdout(format!("línea {i}"))); - } - assert_eq!(buf.len(), MAX_OUTPUT_LINES); - assert!(buf[0].text.contains("50")); - } - - #[test] - fn tab_completion_inserts_unique_candidate() { - // Si el prefijo tiene un único match, Tab debe completarlo. - let mut s = State::new(Source::Local); - s.input.set_text("ec"); - // Forzar un source determinístico para no depender de $PATH. - struct Fixed; - impl shuma_line::CompletionSource for Fixed { - fn commands(&self) -> Vec { - vec!["echo".into()] - } - fn paths(&self, _: &str) -> Vec { - vec![] - } - } - s.completion_source = Arc::new(ShellSource::new(&s.cwd)); - // Bypassear: aplicamos completion manualmente con el Fixed source, - // ya que apply_completion_msg usa s.completion_source. - let comp = s.input.complete(&Fixed); - let candidate = comp.candidates.first().cloned().unwrap_or_default(); - s.input.apply_completion(&comp, &candidate); - assert_eq!(s.input.text(), "echo"); - } - - #[test] - fn common_prefix_returns_longest_shared_start() { - let xs: Vec = vec!["cargo".into(), "cargo-edit".into(), "cargot".into()]; - assert_eq!(common_prefix(&xs), "cargo"); - let ys: Vec = vec!["abc".into(), "xyz".into()]; - assert_eq!(common_prefix(&ys), ""); - } - - #[test] - fn arrow_up_walks_history_backwards() { - let mut s = State::new(Source::Local); - s.cwd = PathBuf::from("/"); - // Insertar entradas a mano vía History (no via run_submitted, que - // dispararía procesos reales). - { - let mut h = s.history.lock().unwrap(); - let _ = h.append(shuma_history::Entry::new("uno", "/", 1)); - let _ = h.append(shuma_history::Entry::new("dos", "/", 2)); - } - s = update(s, Msg::Key(ev(Key::Named(NamedKey::ArrowUp), None))); - assert_eq!(s.input.text(), "dos"); - s = update(s, Msg::Key(ev(Key::Named(NamedKey::ArrowUp), None))); - assert_eq!(s.input.text(), "uno"); - s = update(s, Msg::Key(ev(Key::Named(NamedKey::ArrowDown), None))); - assert_eq!(s.input.text(), "dos"); - } - - #[test] - fn ctrl_r_opens_search_overlay() { - let mut s = State::new(Source::Local); - let ctrl_r = KeyEvent { - key: Key::Character("r".into()), - state: KeyState::Pressed, - text: Some("r".into()), - modifiers: Modifiers { - ctrl: true, - ..Default::default() - }, - repeat: false, - }; - s = update(s, Msg::Key(ctrl_r)); - assert!(s.history_search.is_some()); - } - - #[test] - fn ghost_extends_from_history_when_prefix_matches() { - let mut s = State::new(Source::Local); - { - let mut h = s.history.lock().unwrap(); - let _ = h.append(shuma_history::Entry::new("cargo build --release", "/", 1)); - } - s.input.set_text("cargo bu"); - let g = current_ghost(&s); - // Devuelve el sufijo que falta para llegar a la línea histórica. - assert_eq!(g.as_deref(), Some("ild --release")); - } - - #[test] - fn build_spec_routes_known_tui_command_to_pty() { - let (spec, tui) = build_spec("vim README.md", "/"); - assert!(matches!(spec.exec, shuma_exec::Exec::Pty { .. })); - assert!(tui.is_some()); - } - - #[test] - fn build_spec_routes_plain_command_to_shell() { - let (spec, tui) = build_spec("ls -la", "/"); - assert!(matches!(spec.exec, shuma_exec::Exec::Shell { .. })); - assert!(tui.is_none()); - } - - #[test] - fn build_spec_routes_simple_pipe_to_direct_with_capture() { - // Un pipe simple corre directo (sin bash) y con captura por etapa. - let (spec, tui) = build_spec("ls -la | grep foo", "/"); - match &spec.exec { - shuma_exec::Exec::Direct { stages } => { - assert_eq!(stages.len(), 2, "dos etapas"); - assert_eq!(stages[0].program, "ls"); - assert_eq!(stages[1].program, "grep"); - } - other => panic!("esperaba Exec::Direct, fue {other:?}"), - } - assert!(spec.capture_stages, "el pipe directo activa el tee"); - assert!(tui.is_none()); - } - - #[test] - fn build_spec_pipe_with_quotes_falls_back_to_shell() { - // `shuma_line::Stage` no recoge StringLit en args, así que un pipe - // con comillas debe ir a `sh -c` o perdería el argumento citado. - let (spec, _) = build_spec("echo 'a | b' | cat", "/"); - assert!(matches!(spec.exec, shuma_exec::Exec::Shell { .. })); - assert!(!spec.capture_stages); - } - - #[test] - fn build_spec_pipe_with_glob_falls_back_to_shell() { - let (spec, _) = build_spec("ls *.rs | cat", "/"); - assert!(matches!(spec.exec, shuma_exec::Exec::Shell { .. })); - } - - #[test] - fn simple_pipe_stages_rejects_single_command() { - // Un único comando no gana nada del modo directo (no hay tubería - // que interceptar) → `None`, cae a `sh -c`. - assert!(simple_pipe_stages("ls -la").is_none()); - } - - #[test] - fn simple_pipe_stages_rejects_trailing_pipe() { - // Etapa sin comando (línea incompleta) → None. - assert!(simple_pipe_stages("ls |").is_none()); - } - - #[test] - fn piped_command_captures_intermediate_stage_output() { - // `echo hola | cat`: stage0 (echo) se captura en vivo como una - // OutputLine con stage=Some(0); la salida final (cat) sale como - // stdout normal (stage None). - let mut s = State::new(Source::Local); - s.cwd = PathBuf::from("/"); - s.input.set_text("echo hola | cat"); - s = update(s, Msg::Key(ev(Key::Named(NamedKey::Enter), None))); - assert!(s.is_running(), "el pipe debe arrancar un run"); - s = drain_until_idle(s); - let stage0: Vec<&OutputLine> = s - .output - .iter() - .filter(|l| l.stage == Some(0)) - .collect(); - assert!( - stage0.iter().any(|l| l.text == "hola"), - "esperaba 'hola' capturado de la etapa 0, output: {:?}", - s.output.iter().map(|l| (l.stage, &l.text)).collect::>() - ); - // La salida final (cat) llega como stdout normal sin stage. - assert!(s - .output - .iter() - .any(|l| l.stage.is_none() && l.text == "hola")); - assert!(s.output.iter().any(|l| l.text == "✔ exit 0")); - } - - #[test] - fn infer_predicts_next_command_in_a_repeated_sequence() { - // Historial con el patrón `git pull` → `make` repetido dos veces y - // un `git pull` final: el motor debe predecir `make` como - // continuación. cwd `/tmp/...` sin marcadores → sin gating. - let mut s = State::new(Source::Local); - let dir = "/tmp/shuma-infer-pred-test"; - { - let mut h = s.history.lock().unwrap(); - for (i, line) in ["git pull", "make", "git pull", "make", "git pull"] - .iter() - .enumerate() - { - let _ = h.append(shuma_history::Entry::new(*line, dir, i as u64)); - } - } - refresh_patterns(&mut s); - assert!(!s.patterns.is_empty(), "debe emerger el patrón git→make"); - // La continuación predicha empieza por `make` (puede seguir con el - // resto del patrón más largo, p. ej. `make && git pull`). - let pred = predicted_sequence(&s).expect("predice una continuación"); - assert!( - pred.starts_with("make"), - "tras `git pull` predice `make…`, fue {pred:?}" - ); - } - - #[test] - fn ghost_uses_prediction_before_history() { - // Con el patrón aprendido, tipear `ma` debe sugerir `ke` (de la - // predicción `make`), aunque el historial no tenga un match mejor. - let mut s = State::new(Source::Local); - let dir = "/tmp/shuma-infer-ghost-test"; - { - let mut h = s.history.lock().unwrap(); - for (i, line) in ["git pull", "make", "git pull", "make", "git pull"] - .iter() - .enumerate() - { - let _ = h.append(shuma_history::Entry::new(*line, dir, i as u64)); - } - } - refresh_patterns(&mut s); - s.input.set_text("ma"); - assert_eq!(current_ghost(&s).as_deref(), Some("ke")); - } - - #[test] - fn git_branch_reads_head_ref() { - // `.git/HEAD` con `ref: refs/heads/` → Some(rama). Usamos un - // tmpdir aislado para no depender del repo real. - let base = std::env::temp_dir().join(format!("shuma-gb-{}", std::process::id())); - let git = base.join(".git"); - std::fs::create_dir_all(&git).unwrap(); - std::fs::write(git.join("HEAD"), "ref: refs/heads/feature/x\n").unwrap(); - // Desde un subdirectorio: debe subir hasta encontrar `.git`. - let sub = base.join("sub/dir"); - std::fs::create_dir_all(&sub).unwrap(); - assert_eq!(git_branch(&sub).as_deref(), Some("feature/x")); - let _ = std::fs::remove_dir_all(&base); - } - - #[test] - fn git_branch_none_outside_repo() { - let base = std::env::temp_dir().join(format!("shuma-nogit-{}", std::process::id())); - std::fs::create_dir_all(&base).unwrap(); - assert_eq!(git_branch(&base), None); - let _ = std::fs::remove_dir_all(&base); - } - - #[test] - fn limit_builtin_sets_capture_bytes() { - let mut s = State::new(Source::Local); - s.input.set_text(":limit 5"); - s = update(s, Msg::Key(ev(Key::Named(NamedKey::Enter), None))); - assert_eq!(s.capture_limit_bytes, 5 * 1024 * 1024); - assert!(!s.is_running(), "`:limit` no spawnea proceso"); - // `:limit 0` quita el tope. - s.input.set_text(":limit 0"); - s = update(s, Msg::Key(ev(Key::Named(NamedKey::Enter), None))); - assert_eq!(s.capture_limit_bytes, 0); - } - - #[test] - fn spill_builtin_toggles_flag() { - let mut s = State::new(Source::Local); - s.input.set_text(":spill on"); - s = update(s, Msg::Key(ev(Key::Named(NamedKey::Enter), None))); - assert!(s.spill); - s.input.set_text(":spill off"); - s = update(s, Msg::Key(ev(Key::Named(NamedKey::Enter), None))); - assert!(!s.spill); - } - - #[test] - fn save_group_captures_recent_commands() { - let mut s = State::new(Source::Local); - s.cwd = PathBuf::from("/"); - // Dos comandos reales (no meta) + un :save. - for line in ["echo uno", "echo dos"] { - s.input.set_text(line); - s = update(s, Msg::Key(ev(Key::Named(NamedKey::Enter), None))); - s = drain_until_idle(s); - } - s.input.set_text(":save build"); - s = update(s, Msg::Key(ev(Key::Named(NamedKey::Enter), None))); - assert_eq!(s.groups.len(), 1); - assert_eq!(s.groups[0].name, "build"); - assert_eq!(s.groups[0].lines, vec!["echo uno", "echo dos"]); - // El anchor avanzó: un segundo :save sin comandos nuevos no agrupa. - s.input.set_text(":save vacio"); - s = update(s, Msg::Key(ev(Key::Named(NamedKey::Enter), None))); - assert_eq!(s.groups.len(), 1, "no se crea grupo vacío"); - } - - #[test] - fn run_group_msg_executes_group() { - let mut s = State::new(Source::Local); - s.cwd = PathBuf::from("/"); - s.groups.push(CommandGroup { - name: "g".into(), - lines: vec!["echo desde_panel".into()], - }); - s = update(s, Msg::RunGroup(0)); - s = drain_until_idle(s); - assert!(s.output.iter().any(|l| l.text == "desde_panel")); - // Índice fuera de rango: no-op. - s = update(s, Msg::RunGroup(9)); - assert!(!s.is_running()); - } - - #[test] - fn fkey_runs_saved_group() { - let mut s = State::new(Source::Local); - s.cwd = PathBuf::from("/"); - // F1 sin grupos: no hace nada. - s = update(s, Msg::Key(ev(Key::Named(NamedKey::F1), None))); - assert!(!s.is_running()); - // Guardamos un grupo de un comando y lo corremos con F1. - s.groups.push(CommandGroup { - name: "g".into(), - lines: vec!["echo desde_f1".into()], - }); - s = update(s, Msg::Key(ev(Key::Named(NamedKey::F1), None))); - s = drain_until_idle(s); - assert!(s.output.iter().any(|l| l.text == "desde_f1")); - } - - #[test] - fn reprocess_feeds_block_stdout_as_stdin() { - // Corre `printf "b\\na\\nc\\n"`, arma reprocess sobre su bloque, y - // corre `sort`: debe recibir esa salida por stdin y ordenarla. - let mut s = State::new(Source::Local); - s.cwd = PathBuf::from("/"); - s.input.set_text("printf 'b\\na\\nc\\n'"); - s = update(s, Msg::Key(ev(Key::Named(NamedKey::Enter), None))); - s = drain_until_idle(s); - let src_block = s.output.iter().find(|l| l.text == "b").unwrap().block; - s = update(s, Msg::SetReprocess(src_block)); - assert_eq!(s.reprocess_source, Some(src_block)); - s.input.set_text("sort"); - s = update(s, Msg::Key(ev(Key::Named(NamedKey::Enter), None))); - assert!(s.reprocess_source.is_none(), "el submit consume el reprocess"); - s = drain_until_idle(s); - // La salida de `sort` (en su propio bloque) está ordenada: a,b,c. - let sorted: Vec = s - .output - .iter() - .filter(|l| l.block != src_block && l.kind == OutputKind::Stdout) - .map(|l| l.text.clone()) - .collect(); - assert_eq!(sorted, vec!["a", "b", "c"], "sort recibió el stdin reprocesado"); - } - - #[test] - fn set_reprocess_toggles_off_same_block() { - let mut s = State::new(Source::Local); - s = update(s, Msg::SetReprocess(3)); - assert_eq!(s.reprocess_source, Some(3)); - s = update(s, Msg::SetReprocess(3)); - assert_eq!(s.reprocess_source, None, "re-armar el mismo bloque desarma"); - } - - fn fake_completion(cands: &[&str], start: usize, end: usize) -> shuma_line::Completion { - shuma_line::Completion { - kind: shuma_line::CompletionKind::Command, - candidates: cands.iter().map(|s| s.to_string()).collect(), - replace_start: start, - replace_end: end, - } - } - - #[test] - fn completion_tab_cycles_then_wraps() { - let mut s = State::new(Source::Local); - s.completion = Some(fake_completion(&["cargo", "cat", "cal"], 0, 0)); - s.completion_index = 0; - s = update(s, Msg::Key(ev(Key::Named(NamedKey::Tab), None))); - assert_eq!(s.completion_index, 1); - s = update(s, Msg::Key(ev(Key::Named(NamedKey::Tab), None))); - assert_eq!(s.completion_index, 2); - s = update(s, Msg::Key(ev(Key::Named(NamedKey::Tab), None))); - assert_eq!(s.completion_index, 0, "Tab cicla con wrap"); - } - - #[test] - fn completion_arrows_cycle_both_ways() { - let mut s = State::new(Source::Local); - s.completion = Some(fake_completion(&["a", "b", "c"], 0, 0)); - s.completion_index = 0; - s = update(s, Msg::Key(ev(Key::Named(NamedKey::ArrowUp), None))); - assert_eq!(s.completion_index, 2, "↑ desde 0 va al último"); - s = update(s, Msg::Key(ev(Key::Named(NamedKey::ArrowDown), None))); - assert_eq!(s.completion_index, 0); - } - - #[test] - fn completion_enter_accepts_without_submitting() { - let mut s = State::new(Source::Local); - s.input.set_text("ca"); - s.completion = Some(fake_completion(&["cargo", "cat"], 0, 2)); - s.completion_index = 1; // "cat" - s = update(s, Msg::Key(ev(Key::Named(NamedKey::Enter), None))); - assert_eq!(s.input.text(), "cat", "Enter aplica el resaltado"); - assert!(s.completion.is_none(), "y cierra el popup"); - assert!(!s.is_running(), "Enter con popup NO ejecuta el comando"); - } - - #[test] - fn completion_escape_closes_without_change() { - let mut s = State::new(Source::Local); - s.input.set_text("ca"); - s.completion = Some(fake_completion(&["cargo", "cat"], 0, 2)); - s = update(s, Msg::Key(ev(Key::Named(NamedKey::Escape), None))); - assert!(s.completion.is_none()); - assert_eq!(s.input.text(), "ca", "Esc no toca el texto"); - } - - #[test] - fn typing_closes_completion_popup() { - let mut s = State::new(Source::Local); - s.input.set_text("ca"); - s.completion = Some(fake_completion(&["cargo", "cat"], 0, 2)); - let key = KeyEvent { - key: Key::Character("r".into()), - state: KeyState::Pressed, - text: Some("r".into()), - modifiers: Modifiers::default(), - repeat: false, - }; - s = update(s, Msg::Key(key)); - assert!(s.completion.is_none(), "tipear cierra el popup"); - assert_eq!(s.input.text(), "car", "y la tecla se procesa normal"); - } - - #[test] - fn toggle_stage_flips_expanded_set() { - let mut s = State::new(Source::Local); - s = update(s, Msg::ToggleStage { block: 2, stage: 0 }); - assert!(s.expanded_stages.contains(&(2, 0)), "primer toggle despliega"); - s = update(s, Msg::ToggleStage { block: 2, stage: 0 }); - assert!( - !s.expanded_stages.contains(&(2, 0)), - "segundo toggle repliega" - ); - } - - #[test] - fn build_spec_tui_prefix_overrides_default() { - // `:tui ls` no es típico, pero el prefix lo fuerza igual. - let (spec, tui) = build_spec(":tui ls", "/"); - assert!(matches!(spec.exec, shuma_exec::Exec::Pty { .. })); - assert!(tui.is_some()); - } - - #[test] - fn key_to_pty_bytes_handles_special_keys() { - let enter = ev(Key::Named(NamedKey::Enter), None); - assert_eq!(key_to_pty_bytes(&enter), b"\r"); - let up = ev(Key::Named(NamedKey::ArrowUp), None); - assert_eq!(key_to_pty_bytes(&up), b"\x1b[A"); - let esc = ev(Key::Named(NamedKey::Escape), None); - assert_eq!(key_to_pty_bytes(&esc), b"\x1b"); - // Ctrl-C → 0x03. - let ctrl_c = KeyEvent { - key: Key::Character("c".into()), - state: KeyState::Pressed, - text: Some("c".into()), - modifiers: Modifiers { - ctrl: true, - ..Default::default() - }, - repeat: false, - }; - assert_eq!(key_to_pty_bytes(&ctrl_c), vec![3u8]); - } - - #[test] - fn source_daemon_failure_surfaces_as_notice() { - // Sin daemon corriendo, start_run con Source::Daemon debe - // dejar un notice rojo y no enredarse — el shell sigue vivo. - let mut s = State::new(Source::Daemon { - socket: Some(PathBuf::from("/tmp/shuma-no-existe-test.sock")), - label: None, - }); - let _ = std::fs::remove_file("/tmp/shuma-no-existe-test.sock"); - s.input.set_text("echo hola"); - s = update(s, Msg::Key(ev(Key::Named(NamedKey::Enter), None))); - assert!(s.output.iter().any(|l| l.text.starts_with("✘ daemon:"))); - assert!(!s.is_running(), "no debe quedar un run vivo si falló"); - } - - #[test] - fn ampersand_suffix_starts_background_job() { - let mut s = State::new(Source::Local); - s.cwd = PathBuf::from("/"); - s.input.set_text("sleep 5 &"); - s = update(s, Msg::Key(ev(Key::Named(NamedKey::Enter), None))); - assert!(!s.is_running(), "& no debe dejar un foreground vivo"); - assert_eq!(s.bg_jobs.len(), 1); - // El header de la card del job: `[0] $ sleep 5 &`. - assert!(s - .output - .iter() - .any(|l| l.text.contains("[0]") && l.text.contains("sleep 5"))); - // Cancelar el job así no queda sleep colgado en el host. - s = update(s, Msg::Key(ev(Key::Named(NamedKey::Enter), None))); - s.input.set_text(":term 0"); - s = update(s, Msg::Key(ev(Key::Named(NamedKey::Enter), None))); - assert!(s - .output - .iter() - .any(|l| l.text.contains("[0] SIGTERM enviado"))); - } - - #[test] - fn jobs_builtin_lists_background_jobs() { - let mut s = State::new(Source::Local); - s.cwd = PathBuf::from("/"); - s.input.set_text("sleep 5 &"); - s = update(s, Msg::Key(ev(Key::Named(NamedKey::Enter), None))); - s.input.set_text(":jobs"); - s = update(s, Msg::Key(ev(Key::Named(NamedKey::Enter), None))); - assert!(s - .output - .iter() - .any(|l| l.text.contains("[0]") && l.text.contains("sleep"))); - s.input.set_text(":term 0"); - s = update(s, Msg::Key(ev(Key::Named(NamedKey::Enter), None))); - } - - #[test] - fn jobs_builtin_empty_shows_notice() { - let mut s = State::new(Source::Local); - s.input.set_text(":jobs"); - s = update(s, Msg::Key(ev(Key::Named(NamedKey::Enter), None))); - assert!(s.output.iter().any(|l| l.text.contains("sin jobs"))); - } - - #[test] - fn enter_with_open_quote_inserts_newline_instead_of_submit() { - let mut s = State::new(Source::Local); - s.input.set_text("echo 'hola"); - s = update(s, Msg::Key(ev(Key::Named(NamedKey::Enter), None))); - // No debe haber arrancado un run — Enter agregó \n. - assert!(!s.is_running()); - assert_eq!(s.input.text(), "echo 'hola\n"); - } - - #[test] - fn shift_enter_always_inserts_newline() { - let mut s = State::new(Source::Local); - s.input.set_text("ls"); // texto completo, sin continuation pendiente - let shift_enter = KeyEvent { - key: Key::Named(NamedKey::Enter), - state: KeyState::Pressed, - text: None, - modifiers: Modifiers { - shift: true, - ..Default::default() - }, - repeat: false, - }; - s = update(s, Msg::Key(shift_enter)); - assert!(!s.is_running(), "shift+enter no debe ejecutar"); - assert_eq!(s.input.text(), "ls\n"); - } - - #[test] - fn paste_key_event_is_recognized() { - // Ctrl-V con texto en clipboard se procesa como paste (no - // termina llamando apply_key con el carácter 'v'). Sin display - // server (CI), read_clipboard devuelve None y el state no - // cambia. Pero verificamos que la rama de paste se toma. - let mut s = State::new(Source::Local); - s.input.set_text("hola"); - let ctrl_v = KeyEvent { - key: Key::Character("v".into()), - state: KeyState::Pressed, - text: Some("v".into()), - modifiers: Modifiers { - ctrl: true, - ..Default::default() - }, - repeat: false, - }; - s = update(s, Msg::Key(ctrl_v)); - // El input no debe llevar una 'v' al final — la rama paste se - // tragó la tecla (y en CI sin clipboard no insertó nada). - assert_eq!(s.input.text(), "hola"); - } - - #[test] - fn ansi_idx_palette_matches_expected_basics() { - // Idx 0 = negro, 15 = blanco, 196 = rojo claro del cubo. - let black = ansi_idx_to_color(0); - assert_eq!(black.components[0], 0.0); - let white = ansi_idx_to_color(15); - assert!(white.components[0] > 0.99); - } - - #[test] - fn arrow_right_at_end_accepts_ghost() { - let mut s = State::new(Source::Local); - { - let mut h = s.history.lock().unwrap(); - let _ = h.append(shuma_history::Entry::new("cargo build --release", "/", 1)); - } - s.input.set_text("cargo bu"); - s = update(s, Msg::Key(ev(Key::Named(NamedKey::ArrowRight), None))); - assert_eq!(s.input.text(), "cargo build --release"); - } - - #[test] - fn partition_line_segments_a_line_with_a_url() { - use shuma_line::{Decoration, DecorationKind}; - let theme = Theme::dark(); - let text = "abrí https://tawasuyu.net y mirá"; - let url_start = text.find("https").unwrap(); - let url_end = url_start + "https://tawasuyu.net".len(); - let decs = vec![Decoration { - start: url_start, - end: url_end, - kind: DecorationKind::Url(text[url_start..url_end].to_string()), - }]; - let pieces = partition_line(text, &decs, theme.fg_text, &theme); - assert_eq!(pieces.len(), 3, "pre, url, post: {pieces:?}"); - assert_eq!(pieces[0].color, theme.fg_text); - assert!(pieces[0].deco.is_none()); - assert_eq!(pieces[1].color, theme.accent); - assert!(matches!(pieces[1].deco, Some(DecorationKind::Url(_)))); - assert_eq!(pieces[2].color, theme.fg_text); - } - - #[test] - fn open_decoration_cd_into_a_directory() { - let mut s = State::new(Source::Local); - let target = std::env::temp_dir(); - let kind = shuma_line::DecorationKind::Path { - abs: target.clone(), - is_dir: true, - is_executable: false, - is_symlink: false, - }; - s = update(s, Msg::OpenDecoration(kind)); - // cwd cambia al directorio target (no comparamos canónico — el - // open_decoration acepta el path tal cual viene si es dir). - assert_eq!(s.cwd, target); - } - - #[test] - fn open_decoration_git_sha_prefills_input() { - let mut s = State::new(Source::Local); - let kind = shuma_line::DecorationKind::GitSha("abcdef0123456".into()); - s = update(s, Msg::OpenDecoration(kind)); - assert_eq!(s.input.text(), "git show abcdef0123456"); - } - - #[test] - fn open_decoration_path_executable_prefills_input() { - let mut s = State::new(Source::Local); - let kind = shuma_line::DecorationKind::Path { - abs: PathBuf::from("/usr/bin/ls"), - is_dir: false, - is_executable: true, - is_symlink: false, - }; - s = update(s, Msg::OpenDecoration(kind)); - assert_eq!(s.input.text(), "/usr/bin/ls"); - } - - #[test] - fn dispatch_maps_clear() { - assert!(matches!(dispatch("shell.clear"), Some(Msg::Clear))); - assert!(matches!(dispatch("shell.cancel"), Some(Msg::Cancel))); - assert!(dispatch("desconocido").is_none()); - } - - #[test] - fn contributions_expose_clear_and_cancel_shortcuts() { - let s = State::new(Source::Local); - let c = contributions(&s); - assert!(c.monitors.is_empty()); - let labels: Vec<&str> = c.shortcuts.iter().map(|s| s.label.as_str()).collect(); - assert!(labels.contains(&"Clear"), "{labels:?}"); - assert!(labels.contains(&"Cancel"), "{labels:?}"); - } - - #[test] - fn typing_appends_to_input() { - let mut s = State::new(Source::Local); - // El widget text-input usa apply_key con KeyEvent que incluye texto. - let key = KeyEvent { - key: Key::Character("h".into()), - state: KeyState::Pressed, - text: Some("h".into()), - modifiers: Modifiers::default(), - repeat: false, - }; - s = update(s, Msg::Key(key)); - assert_eq!(s.input.text(), "h"); - } - - #[test] - fn external_command_records_intention_in_graph() { - let mut s = State::new(Source::Local); - s.cwd = PathBuf::from("/"); - assert!(s.intent_graph().is_empty(), "grafo arranca vacío"); - s.input.set_text("echo lienzo"); - s = update(s, Msg::Key(ev(Key::Named(NamedKey::Enter), None))); - assert_eq!( - s.intent_graph().len(), - 1, - "Enter debe registrar el `%c1` en el grafo" - ); - assert_eq!(s.intent_graph().commands()[0].intention, "echo lienzo"); - s = drain_until_idle(s); - let node = &s.intent_graph().commands()[0]; - assert_eq!(node.status, shuma_intent::NodeStatus::Ok); - assert!( - node.output_bytes >= 7, - "esperaba ≥7 bytes (len de 'lienzo\\n'), recibí {}", - node.output_bytes - ); - } - - #[test] - fn failed_command_records_failed_status() { - let mut s = State::new(Source::Local); - s.cwd = PathBuf::from("/"); - s.input.set_text("false"); - s = update(s, Msg::Key(ev(Key::Named(NamedKey::Enter), None))); - s = drain_until_idle(s); - assert_eq!(s.intent_graph().len(), 1); - assert_eq!( - s.intent_graph().commands()[0].status, - shuma_intent::NodeStatus::Failed - ); - } - - #[test] - fn builtin_does_not_register_in_graph() { - let mut s = State::new(Source::Local); - s.input.set_text("pwd"); - s = update(s, Msg::Key(ev(Key::Named(NamedKey::Enter), None))); - assert!( - s.intent_graph().is_empty(), - "builtins no entran al grafo de intenciones" - ); - } - - #[test] - fn insert_at_cursor_appends_into_input() { - let mut s = State::new(Source::Local); - // `set_text` deja el cursor al final, así que `insert` extiende. - s.input.set_text("sort "); - s = update(s, Msg::InsertAtCursor("%p1".into())); - assert_eq!(s.input.text(), "sort %p1"); - } - - #[test] - fn push_output_groups_lines_into_command_blocks() { - let mut s = State::new(Source::Local); - s.push_output(OutputLine::prompt("$ ls")); - s.push_output(OutputLine::stdout("a.txt")); - s.push_output(OutputLine::stdout("b.txt")); - s.push_output(OutputLine::notice("✔ exit 0")); - let b = s.output[0].block; - assert!(b > 0, "el prompt debe abrir un bloque > 0"); - assert!( - s.output.iter().all(|l| l.block == b), - "comando + salida + exit comparten bloque: {:?}", - s.output.iter().map(|l| l.block).collect::>() - ); - // Un segundo prompt abre un bloque nuevo y monotónico. - s.push_output(OutputLine::prompt("$ pwd")); - assert!( - s.output.last().unwrap().block > b, - "el segundo comando abre un bloque nuevo" - ); - } - - #[test] - fn push_in_block_keeps_async_output_out_of_foreground_card() { - // El bug de "output mezclado": un job async drenando en su bloque - // NO debe contaminar el bloque del comando de foreground, aunque - // `current_block` apunte a este último. - let mut s = State::new(Source::Local); - s.push_output(OutputLine::prompt("$ fg")); // abre bloque fg - let fg_block = s.current_block; - let job_block = s.open_block(); // bloque propio del job (current sigue en fg) - s.push_in_block(job_block, OutputLine::stdout("salida del job")); - s.push_output(OutputLine::stdout("salida del fg")); - let bg = s - .output - .iter() - .find(|l| l.text == "salida del job") - .unwrap() - .block; - let fg = s - .output - .iter() - .find(|l| l.text == "salida del fg") - .unwrap() - .block; - assert_eq!(bg, job_block); - assert_eq!(fg, fg_block); - assert_ne!(bg, fg, "job y foreground en cards distintas"); - } - - #[test] - fn scroll_clamps_between_zero_and_overflow() { - let mut s = State::new(Source::Local); - *s.out_overflow.lock().unwrap() = 100.0; - s = update(s, Msg::Scroll(40.0)); - assert_eq!(s.scroll_px, 40.0); - s = update(s, Msg::Scroll(200.0)); // pasa del tope → clamp a overflow - assert_eq!(s.scroll_px, 100.0); - s = update(s, Msg::Scroll(-500.0)); // de vuelta al fondo - assert_eq!(s.scroll_px, 0.0); - } - - #[test] - fn toggle_block_flips_collapsed_set() { - let mut s = State::new(Source::Local); - s = update(s, Msg::ToggleBlock(3)); - assert!(s.collapsed.contains(&3), "primer toggle colapsa"); - s = update(s, Msg::ToggleBlock(3)); - assert!(!s.collapsed.contains(&3), "segundo toggle despliega"); - } - - #[test] - fn clear_output_also_drops_collapsed_set() { - let mut s = State::new(Source::Local); - s.push_output(OutputLine::prompt("$ ls")); - s.collapsed.insert(s.output[0].block); - s.clear_output(); - assert!(s.output.is_empty()); - assert!(s.collapsed.is_empty(), "clear limpia también los colapsos"); - } -} +mod tests; diff --git a/02_ruway/shuma/sandbox/shuma-module-shell/src/mouse_xterm.rs b/02_ruway/shuma/sandbox/shuma-module-shell/src/mouse_xterm.rs new file mode 100644 index 0000000..e24da06 --- /dev/null +++ b/02_ruway/shuma/sandbox/shuma-module-shell/src/mouse_xterm.rs @@ -0,0 +1,234 @@ +//! Codificación xterm de eventos de mouse para PTY/vt100. +//! +//! Convierte un evento de Llimphi (click/wheel sobre el panel TUI) en los +//! bytes que el programa bajo el PTY espera leer cuando habilitó alguna +//! variante del **xterm mouse protocol**. El parser [`vt100`] expone qué modo +//! y qué encoding pidió el programa: este módulo sólo emite la secuencia +//! adecuada — no decide si el mouse está habilitado (eso lo chequea el +//! caller con `screen.mouse_protocol_mode()`). +//! +//! ## Codings soportados +//! +//! - **Default** (X10/“legacy”): `\x1b[M Cb Cx Cy` con cada byte ASCII +//! (cols/rows limitados a 1..=223). +//! - **SGR** (`DECSET 1006`): `\x1b[< Cb ; Cx ; Cy M` para press / `m` para +//! release. Soporta cols/rows arbitrarios y release distinguible. +//! - **UTF-8** (`DECSET 1005`): variante intermedia — emitida como Default +//! por simplicidad (los TUIs modernos negocian SGR). +//! +//! ## Modos +//! +//! - `Press` (X10): sólo press, sin release ni motion. +//! - `PressRelease` (VT200), `ButtonMotion`, `AnyMotion`: además de press, +//! reporta release (y motion si está en modo motion — no implementado). + +use vt100::{MouseProtocolEncoding, MouseProtocolMode}; + +/// Botón del mouse en términos xterm (Cb base, sin modificadores). +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum XBtn { + Left = 0, + Middle = 1, + Right = 2, + /// Rueda hacia arriba (button 4 = Cb 64 en Default; 64 en SGR). + WheelUp = 64, + /// Rueda hacia abajo (button 5). + WheelDown = 65, +} + +/// Fase del evento. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum XPhase { + Press, + Release, +} + +/// Encodea un evento de mouse para el `(mode, encoding)` activos. Devuelve +/// la secuencia de bytes a escribir al stdin del PTY, o `None` si el modo no +/// reporta este tipo de evento (p. ej. `Press` (X10) sólo reporta press). +/// +/// `col`/`row` son 1-based (la celda *visible*; el caller las clampa al +/// tamaño del grid antes de llamar). +pub fn encode( + mode: MouseProtocolMode, + encoding: MouseProtocolEncoding, + btn: XBtn, + phase: XPhase, + col: u16, + row: u16, +) -> Option> { + // Mouse deshabilitado: nada que mandar. + if matches!(mode, MouseProtocolMode::None) { + return None; + } + // X10 sólo reporta press. Release/motion se filtran en origen. + if matches!(mode, MouseProtocolMode::Press) && phase == XPhase::Release { + return None; + } + match encoding { + MouseProtocolEncoding::Sgr => Some(encode_sgr(btn, phase, col, row)), + MouseProtocolEncoding::Default | MouseProtocolEncoding::Utf8 => { + Some(encode_default(btn, phase, col, row)) + } + } +} + +/// SGR (`DECSET 1006`): `\x1b[< Cb ; Cx ; Cy M` para press, `m` para release. +/// Sin limites de col/row. +fn encode_sgr(btn: XBtn, phase: XPhase, col: u16, row: u16) -> Vec { + let cb = btn as u32; + let terminator = if matches!(phase, XPhase::Release) { 'm' } else { 'M' }; + format!("\x1b[<{};{};{}{}", cb, col, row, terminator).into_bytes() +} + +/// Default/X10: `\x1b[M Cb Cx Cy` con cada coord = pos+32 (offset por 1, base +/// 1). En release el bit bajo del Cb se setea a 3 (button-release ambiguo). +fn encode_default(btn: XBtn, phase: XPhase, col: u16, row: u16) -> Vec { + let mut cb = btn as u32; + if matches!(phase, XPhase::Release) { + // En X10 default, release usa el código 3 en los bits bajos (cualquier + // botón). vt100 entiende esto. + cb = (cb & !0b11) | 0b11; + } + // Clampea coords a 1..=223 (cabe en un byte ASCII tras el offset de 32). + let c = (col.clamp(1, 223) as u32) + 32; + let r = (row.clamp(1, 223) as u32) + 32; + let cb_byte = (cb + 32).min(255) as u8; + let c_byte = c.min(255) as u8; + let r_byte = r.min(255) as u8; + vec![0x1b, b'[', b'M', cb_byte, c_byte, r_byte] +} + +/// Helper: convierte `(lx, ly, rect_w, rect_h, grid_cols, grid_rows)` en +/// `(col, row)` 1-based clampeado al tamaño del grid. Replica el cálculo de +/// `cell_w`/`cell_h` del painter del `generic_grid_panel` para mantener la +/// hit-test consistente con lo pintado. +pub fn local_to_cell( + lx: f32, + ly: f32, + rect_w: f32, + rect_h: f32, + grid_cols: u16, + grid_rows: u16, +) -> (u16, u16) { + // Padding interior del painter (debe seguir a `tui_panel::generic_grid_panel`). + const PAD: f32 = 6.0; + let avail_w = (rect_w - PAD * 2.0).max(1.0); + let avail_h = (rect_h - PAD * 2.0).max(1.0); + let cell_w = (avail_w / grid_cols as f32).max(1.0); + let cell_h = (avail_h / grid_rows as f32).max(1.0); + let lx_in = (lx - PAD).max(0.0); + let ly_in = (ly - PAD).max(0.0); + // Clampea como f32 ANTES de castear a u16 (un click muy lejos del rect + // —p.ej. coords basura— no debe overflowear el cast). El `+1` que sigue + // queda dentro de los límites del grid. + let col_f = (lx_in / cell_w).clamp(0.0, grid_cols.max(1) as f32 - 1.0); + let row_f = (ly_in / cell_h).clamp(0.0, grid_rows.max(1) as f32 - 1.0); + let col = (col_f as u16) + 1; + let row = (row_f as u16) + 1; + (col, row) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn sgr_press_left_at_5_3() { + let bytes = encode_sgr(XBtn::Left, XPhase::Press, 5, 3); + assert_eq!(bytes, b"\x1b[<0;5;3M"); + } + + #[test] + fn sgr_release_right_at_10_7() { + let bytes = encode_sgr(XBtn::Right, XPhase::Release, 10, 7); + assert_eq!(bytes, b"\x1b[<2;10;7m"); + } + + #[test] + fn sgr_wheel_up_y_down() { + let up = encode_sgr(XBtn::WheelUp, XPhase::Press, 1, 1); + let down = encode_sgr(XBtn::WheelDown, XPhase::Press, 1, 1); + assert_eq!(up, b"\x1b[<64;1;1M"); + assert_eq!(down, b"\x1b[<65;1;1M"); + } + + #[test] + fn default_press_left_at_1_1() { + let bytes = encode_default(XBtn::Left, XPhase::Press, 1, 1); + // Cb = 0+32 = 32 (' '), col = 1+32 = 33 ('!'), row = 1+32 = 33 ('!'). + assert_eq!(bytes, b"\x1b[M !!"); + } + + #[test] + fn default_release_marca_button_release() { + let bytes = encode_default(XBtn::Left, XPhase::Release, 1, 1); + // Cb = (0&!3)|3 = 3; 3+32 = 35 ('#'). + assert_eq!(bytes, b"\x1b[M#!!"); + } + + #[test] + fn x10_mode_filtra_release() { + // En X10 los release no se reportan — el `encode` devuelve None. + assert!( + encode(MouseProtocolMode::Press, MouseProtocolEncoding::Sgr, XBtn::Left, XPhase::Release, 1, 1) + .is_none() + ); + } + + #[test] + fn modo_none_filtra_todo() { + assert!( + encode(MouseProtocolMode::None, MouseProtocolEncoding::Sgr, XBtn::Left, XPhase::Press, 1, 1) + .is_none() + ); + } + + #[test] + fn vt200_reporta_press_y_release() { + assert!(encode( + MouseProtocolMode::PressRelease, + MouseProtocolEncoding::Sgr, + XBtn::Left, + XPhase::Press, + 5, + 3, + ) + .is_some()); + assert!(encode( + MouseProtocolMode::PressRelease, + MouseProtocolEncoding::Sgr, + XBtn::Left, + XPhase::Release, + 5, + 3, + ) + .is_some()); + } + + #[test] + fn cell_centrada_cae_en_la_celda_correcta() { + // Grid 10x5 en un rect 100x50 con padding 6: cell_w = (100-12)/10 = 8.8 + // cell_h = (50-12)/5 = 7.6. Click en (6+8.8*2 + 4, 6+7.6*1 + 2) ≈ (27.6, 15.6). + // Esperado: col 3, row 2 (1-based). + let (c, r) = local_to_cell(27.6, 15.6, 100.0, 50.0, 10, 5); + assert_eq!(c, 3); + assert_eq!(r, 2); + } + + #[test] + fn cell_clampa_al_grid() { + // Click muy lejos del rect: la última celda. + let (c, r) = local_to_cell(1e6, 1e6, 100.0, 50.0, 10, 5); + assert_eq!(c, 10); + assert_eq!(r, 5); + } + + #[test] + fn cell_pad_va_a_la_primera_celda() { + // Click dentro del padding (esquina superior-izquierda): cae a (1,1). + let (c, r) = local_to_cell(0.0, 0.0, 100.0, 50.0, 10, 5); + assert_eq!(c, 1); + assert_eq!(r, 1); + } +} diff --git a/02_ruway/shuma/sandbox/shuma-module-shell/src/msg.rs b/02_ruway/shuma/sandbox/shuma-module-shell/src/msg.rs new file mode 100644 index 0000000..c88b2da --- /dev/null +++ b/02_ruway/shuma/sandbox/shuma-module-shell/src/msg.rs @@ -0,0 +1,279 @@ +use super::*; + + +#[derive(Debug, Clone)] +pub enum Msg { + /// Tecla recibida desde el chasis. Enter ejecuta, Tab completa, + /// flechas y edición van al `LineState`. + Key(KeyEvent), + /// Click sobre el input box — re-foca y dirige el Enter a la "línea" + /// (arrancar comandos nuevos), limpiando cualquier foco de stdin a un + /// job vivo. + FocusInput, + /// Desenfoca el input: apaga el cue de foco (caret + marco brillante). Lo + /// dispara sacar el mouse de la línea (`on_pointer_leave`) y el host cuando + /// el compositor le quita el teclado (KB leave / click en otra ventana), así + /// el foco visual no se queda pegado. + BlurInput, + /// Un gesto del mouse sobre el texto del input, ya envuelto por el widget + /// compartido: press (que posa el caret y escala a palabra/línea por + /// doble/triple click), arrastre de selección, o click derecho. + /// + /// Reemplaza al par `InputPress`/`InputDragSel`, que existía porque shuma + /// hacía su propio mapeo click→byte. Ahora el mapeo lo hace el motor, con la + /// misma métrica con la que pinta — que es la única forma de que el caret + /// caiga siempre entre los glifos que se ven. + InputArea(llimphi_widget_text_input::TextAreaEvent), + /// Click en el botón de micrófono del input: alterna encender/apagar la + /// escucha por voz (el «llamado shuma»). Deja el intent para que el host + /// arranque/pare `rimay-voz-host`; el dictado entra por `InsertAtCursor`. + ToggleMic, + /// Click en el botón de **enviar** del input (el que reemplaza al micrófono + /// cuando hay texto): submitea la línea igual que Enter, sin meter salto + /// aunque haya una construcción abierta (es un envío explícito). + Submit, + /// Click en una fila del popup de completado (índice global sobre el total + /// en capas). Resalta esa fila y la acepta — una app se lanza, un + /// token/línea/grupo se inserta. La dispara el host desde su surface + /// flotante de completado (o el popup in-drawer). + PickCompletion(usize), + /// Dirige el input al stdin del comando vivo del bloque dado (click o + /// hover sobre su card). El Enter de la línea le manda el texto hasta + /// que el usuario re-foca la línea u otro job, o el comando cierra. + FocusJob(u64), + /// Limpia el buffer de output — disparado por el shortcut `Clear` + /// o el builtin `clear`. + Clear, + /// Drena eventos del run activo (si hay) y pinta líneas nuevas. + /// Lo dispara el chasis a alta frecuencia (~100 ms). + Tick, + /// SIGTERM al run activo (Ctrl-C o shortcut `Cancel`). + Cancel, + /// Click en una decoración del output — el dispatch decide la + /// acción (cd, xdg-open, pre-llenar el input, etc.). + OpenDecoration(shuma_line::DecorationKind), + /// Inserta `text` en la posición actual del cursor del input. La + /// dispara el chasis cuando otro módulo (p. ej. `shuma-module-canvas` + /// al clickear un nodo) quiere empujar una referencia `%pN`/`%cN` + /// al REPL. Cierra los overlays de búsqueda y deja el cursor justo + /// después del texto insertado. + InsertAtCursor(String), + /// Empuja un mensaje `Notice` al output sin abrir un bloque nuevo — + /// para que el chasis (o cualquier consumidor) comunique fallas + /// (podman, askpass, ...) en la vista del shell. + PushNotice(String), + /// Ajusta el zoom del texto del shell por un factor multiplicativo + /// (e.g. 1.1 zoom in 10%, 1/1.1 zoom out). Ctrl+rueda lo dispara con + /// pasos pequeños; Ctrl+= / Ctrl+- con pasos más grandes. + ZoomBy(f32), + /// Resetea el zoom a 1.0. Ctrl+0 lo dispara. + ZoomReset, + /// Arrastre del divisor historial/cola-viva del PTY inline: delta en px + /// (positivo = agrandar la cola hacia arriba). + ColaAlto(f32), + /// Mueve el scroll horizontal del shell por `dx` px (positivo = ver + /// hacia la derecha del texto). Shift+rueda lo dispara. Cap a [0, ∞). + ScrollHoriz(f32), + /// Pega el clipboard al PTY del TUI activo — click derecho o botón + /// del medio sobre el panel de vim (paste estilo terminal). + VimPaste, + /// Pega el **cuasi-clipboard PRIMARY** (lo último seleccionado) — botón + /// medio sobre el panel de output. Va al PTY si hay consola viva, o al + /// input si no. Selección PRIMARY estilo X11, separada del Ctrl+V. + PrimaryPaste, + /// Drag de selección sobre el card de vim. `dx`/`dy` = delta desde el + /// evento anterior; `ax`/`ay` = posición del press (local al panel). + VimDrag { + end: bool, + dx: f32, + dy: f32, + ax: f32, + ay: f32, + }, + /// Alterna plegado/desplegado de la card de un comando. La dispara el + /// click en el header de la card (chevron + comando). + ToggleBlock(u64), + /// Alterna plegado/desplegado de una **sub-sección** dentro del bloque + /// `block` (índice `idx` según `sections::detect_sections`). Click en + /// el header de la sección lo dispara. + ToggleSection { block: u64, idx: usize }, + /// Click en un header de columna de una sub-sección tipo tabla. Cicla: + /// sin orden → asc(col) → desc(col) → sin orden. + SortSectionColumn { + block: u64, + section: usize, + col: usize, + }, + /// Rueda del mouse sobre el panel de output. `delta` ya viene en px + /// (positivo = rodar hacia arriba / ver historial). Ajusta `scroll_px`. + Scroll(f32), + /// Re-ejecuta `line` como un comando nuevo — la dispara el click en + /// una etapa de pipe de una card SIN captura en vivo (fallback `sh -c`). + RunLine(String), + /// Alterna el desplegable de una etapa de pipe con captura en vivo + /// (tee). La dispara el click en su chip; muestra/oculta las líneas + /// intermedias ya capturadas sin re-ejecutar nada. + ToggleStage { block: u64, stage: usize }, + /// Arma el reprocess: el stdout del bloque `block` alimentará el stdin + /// del próximo comando. La dispara el chip ↻ de una card. Si ya estaba + /// armado el mismo bloque, lo desarma (toggle). + SetReprocess(u64), + /// Ejecuta el grupo guardado de índice `idx` (0-based). La dispara el + /// click en su card del panel de grupos (equivale a la tecla F{idx+1}). + RunGroup(usize), + /// A1 — acepta la coreografía emergente (identificada por su `signature`): + /// la guarda como grupo ejecutable (F-key) con su nombre sugerido. La + /// dispara el chip «guardar» sobre el input. + AcceptChoreography(Vec), + /// A1 — descarta la oferta de coreografía (por `signature`): no se vuelve + /// a ofrecer en la sesión. La dispara el chip «descartar». + DismissChoreography(Vec), + /// A2 — acepta el alias para una línea larga repetida: lo agrega a la config + /// viva y lo aprende al shumarc (`[aliases]`). La dispara el chip «aliasar». + AcceptAlias(String), + /// A2 — descarta la oferta de alias (por la línea): no se vuelve a ofrecer + /// en la sesión. La dispara el chip «descartar» del alias. + DismissAlias(String), + /// A4 — acepta la corrección «¿quisiste decir…?» del bloque `block`: lleva + /// la línea corregida al input (para revisarla y ejecutar con Enter) y + /// limpia la oferta. La dispara el click en su notice. + AcceptDidYouMean(u64), + /// E2 — inserta la referencia `%cN` del bloque `block` al final del input + /// (su stdout se materializa como fuente del pipe). La dispara el click en + /// el tag `%cN` del header. + InsertBlockRef(u64), + /// Reemplaza el input con `texto` y le da el foco — SIN ejecutar. La + /// disparan las acciones de redirección/filtro que necesitan que el usuario + /// complete la línea (`:filtra %cN.K `, `:write %cN.K `): prellenan el + /// comando con el objetivo y dejan el cursor para que escriba el resto. + PrefillInput(String), + /// Copia al clipboard el **bloque entero** `block`: el comando (`$ …`) + /// envuelto junto con su salida completa (stdout **y** stderr). La dispara + /// el botón ⧉ del header del bloque en la superficie; no depende de que + /// haya selección. + CopyCommandBlock(u64), + /// Copia al clipboard **sólo el comando** de `block` (`$ …`), sin su salida. + /// La dispara el botón chico `$` del header del bloque en la superficie. + CopyCommandOnly(u64), + /// Marca un bloque para cotejar; con otro ya marcado, dispara `:compara` + /// entre ambos. La dispara el chip ⇄ del header de un bloque con salida. + CompareWith(u64), + /// Click sobre el panel de un TUI bajo PTY (htop/less/btop/…). Si el + /// programa habilitó mouse (`vt100::MouseProtocolMode != None`), encodea + /// el click en xterm-mouse y lo escribe al stdin del PTY. `button` es 0 + /// (izquierdo), 1 (medio), 2 (derecho). `lx`/`ly` son coords relativas + /// al rect del panel; `rect_w`/`rect_h` el tamaño del rect (para + /// convertir a celdas). + TuiMouseClick { + button: u8, + lx: f32, + ly: f32, + rect_w: f32, + rect_h: f32, + }, + /// Rueda sobre el panel TUI. `dy` positivo = arriba (botón 4); negativo + /// = abajo (botón 5). Se emite un evento de mouse por cada "tick" de + /// rueda lógica. Las coords se usan para reportar dónde estaba el + /// cursor (algunos TUIs lo respetan). + TuiMouseWheel { + dy: f32, + lx: f32, + ly: f32, + rect_w: f32, + rect_h: f32, + }, + /// Drag del mouse sobre el cuerpo de output en modo **superficie** + /// (`SHUMA_TERMINAL_SURFACE=1`). El primer Move arranca/colapsa la + /// selección al `(lx0, ly0)`; los siguientes la extienden; el End la + /// deja fijada para que el usuario copie. `dx`/`dy` son deltas desde el + /// evento previo (el `update` los acumula sobre `(ax, ay)`). + SurfSelectDrag { + phase: llimphi_ui::DragPhase, + dx: f32, + dy: f32, + ax: f32, + ay: f32, + }, + /// Limpia la selección viva del cuerpo de output (lo dispara una tecla, + /// un click en blanco, etc.). No-op si ya está vacía. + SurfClearSelection, + /// Copia al clipboard el texto de la selección viva del cuerpo de + /// output. No-op si no hay selección. Reusa el clipboard global del + /// proceso (vía `arboard`). + SurfCopySelection, + /// Doble-click sobre el cuerpo de output en modo superficie: selecciona + /// la palabra bajo el punto (paridad con terminales clásicas). El + /// `update` resuelve `(lx, ly)` a `Point` con `point_at_geo`, computa + /// los boundaries de palabra en el texto de la línea y arma una + /// `SelectionRange` sobre esa palabra. + SurfDoubleClick { + lx: f32, + ly: f32, + rect_w: f32, + rect_h: f32, + }, + /// Right-click sobre el cuerpo de output en modo superficie: abre el + /// menú contextual en `(x, y)` (coords del nodo raíz del shell). Las + /// acciones operan sobre el scrollback entero (no por-bloque como el + /// `BodyMenu` del legacy) — Copiar selección, Copiar todo, Seleccionar + /// todo. + SurfOpenMenu { x: f32, y: f32 }, + /// Elegir un item del menú contextual del surface (0-based). + SurfMenuPick(usize), + /// Cerrar el menú contextual del surface (scrim / Esc). + SurfMenuDismiss, + /// Abre la barra de búsqueda (Ctrl+F). Si ya estaba abierta, re-foca el + /// input vacío (paridad con browsers/editores). Si no hay layout + /// publicado todavía, abre igual — el primer keystroke recomputará. + FindOpen, + /// Cierra la barra de búsqueda (Esc). Limpia `find` y la selección + /// derivada de un match. No toca `surf_selection` si vino de un drag + /// del mouse y no de un match (la heurística: si `find` existía y + /// tenía un `current`, era nuestro highlight; lo limpiamos). + FindClose, + /// Agrega un char a la query de búsqueda y re-busca. + FindChar(char), + /// Borra el último char de la query y re-busca. + FindBackspace, + /// Avanza al siguiente match (Enter / F3 / botón). + FindNext, + /// Retrocede al match previo (Shift+Enter / Shift+F3 / botón). + FindPrev, + /// Togglea case-insensitive (botón `Aa` o atajo). Re-busca con la + /// nueva política. + FindToggleCase, + /// E5 — resultado de una invocación al LLM (`:?`/`:explica`/`:resume`). + /// Lo dispatcha el host (chasis) tras correr `pluma-llm` en un thread; + /// el módulo sólo expresó la intención (`State::llm_request`). `kind` + /// decide el destino: `Command` → al input (revisar y Enter, NUNCA + /// auto-ejecuta), `Text` → al output del bloque. + LlmResult { + kind: LlmKind, + ok: bool, + text: String, + }, + /// Resultado de una búsqueda semántica (`:buscar`). Lo dispatcha el host + /// tras embeber con `rimay-verbo`; el módulo sólo expresó la intención + /// (`State::semantic_request`). `ok=false` → `hits` trae un único mensaje de + /// error; si no, `hits` son `(comando, score)` ordenados por parecido. + SemanticResult { + ok: bool, + hits: Vec<(String, f32)>, + }, +} + +impl Msg { + /// `true` si el mensaje es el **press** del mouse sobre el texto del input + /// (el que posa el caret) — no el arrastre de selección ni el click derecho. + /// + /// Existe para que un host (pata) pueda tratarlo como "click en el input" + /// —abrir el drawer, tomar foco— sin tener que depender del widget de texto + /// ni destripar su enum de eventos. Seleccionar arrastrando no es pedir que + /// se despliegue nada, por eso sólo cuenta el press. + pub fn es_press_en_input(&self) -> bool { + matches!( + self, + Msg::InputArea(llimphi_widget_text_input::TextAreaEvent::Press(_, _)) + ) + } +} diff --git a/02_ruway/shuma/sandbox/shuma-module-shell/src/pulso.rs b/02_ruway/shuma/sandbox/shuma-module-shell/src/pulso.rs new file mode 100644 index 0000000..a0841e1 --- /dev/null +++ b/02_ruway/shuma/sandbox/shuma-module-shell/src/pulso.rs @@ -0,0 +1,217 @@ +//! `pulso` — el **caudal de salida** de un shell, muestreado como un cava. +//! +//! La pregunta que responde: *¿cuánto se está moviendo esto?* No «¿corre algo?» +//! (eso ya lo dice [`crate::Activity`]) sino con cuánta intensidad. Un `cargo +//! build` escupiendo miles de líneas y un `sleep 300` están los dos «corriendo», +//! y no se parecen en nada. +//! +//! Cada byte que sale de un run (stdout, stderr, PTY crudo, cosecha del +//! scrollback) se suma con [`Pulso::sumar`]. Una vez por `Msg::Tick` (100 ms) el +//! [`Pulso::muestrear`] cierra el bin: guarda cuánto entró desde el anterior, +//! normalizado, en un anillo de [`MUESTRAS`] posiciones. Eso son ~2,4 s de +//! historia — el ancho exacto de un hilo de cava bajo una pestaña. +//! +//! La normalización es **logarítmica** a propósito: entre «nada» y «una línea» +//! hay más diferencia perceptual que entre 100 KB y 200 KB. Con escala lineal, +//! un `ls` no movería un píxel al lado de un `cargo build`. + +/// Bins del anillo. A 100 ms por bin son 2,4 s de historia. +pub const MUESTRAS: usize = 24; + +/// Caudal (bytes por bin) que satura la barra. Con escala log, 256 KiB en +/// 100 ms (≈2,5 MB/s) es «esto va a toda máquina». +const TECHO: f32 = 262_144.0; + +/// Bins de quietud antes de que una pestaña empiece a apagarse (30 s). +const QUIETO_DESDE: u32 = 300; +/// Bins de quietud en los que termina de apagarse (3 min). +const QUIETO_HASTA: u32 = 1_800; +/// Opacidad mínima de una pestaña dormida: se apaga, no desaparece. +const ATENUACION_MIN: f32 = 0.45; + +/// Anillo de caudal de un shell. Barato de clonar (`State` se clona por tick). +#[derive(Debug, Clone)] +pub struct Pulso { + /// Bytes acumulados desde siempre — monótono, nunca se resetea (a + /// diferencia de `current_run_bytes`, que vuelve a cero en cada run y + /// daría deltas negativos en los bordes). + acum: u64, + /// Valor de `acum` en el último [`Self::muestrear`]. + ultimo: u64, + /// Anillo de niveles normalizados (0..1). + muestras: [f32; MUESTRAS], + /// Próxima posición a escribir. + cursor: usize, + /// Bins consecutivos sin un solo byte. + quietud: u32, +} + +impl Default for Pulso { + fn default() -> Self { + Self { + acum: 0, + ultimo: 0, + muestras: [0.0; MUESTRAS], + cursor: 0, + quietud: 0, + } + } +} + +impl Pulso { + /// Suma bytes salidos. Se llama desde el drenaje, en cada camino de salida. + pub fn sumar(&mut self, bytes: u64) { + self.acum = self.acum.saturating_add(bytes); + } + + /// Cierra el bin actual: normaliza lo que entró desde la última llamada y + /// lo empuja al anillo. Una vez por `Msg::Tick`. + pub fn muestrear(&mut self) { + let delta = self.acum.saturating_sub(self.ultimo); + self.ultimo = self.acum; + let nivel = normalizar(delta); + self.muestras[self.cursor] = nivel; + self.cursor = (self.cursor + 1) % MUESTRAS; + if delta == 0 { + self.quietud = self.quietud.saturating_add(1); + } else { + self.quietud = 0; + } + } + + /// El anillo en orden cronológico: `[0]` es lo más viejo, el último lo más + /// reciente. Es lo que pinta el hilo de cava, de izquierda a derecha. + pub fn barras(&self) -> [f32; MUESTRAS] { + let mut out = [0.0; MUESTRAS]; + for (i, slot) in out.iter_mut().enumerate() { + *slot = self.muestras[(self.cursor + i) % MUESTRAS]; + } + out + } + + /// Pico de la ventana — cuánta presencia darle al color. + pub fn nivel(&self) -> f32 { + self.muestras.iter().copied().fold(0.0_f32, f32::max) + } + + /// Bins consecutivos sin salida (a 10 Hz: `/10` = segundos). + pub fn quietud(&self) -> u32 { + self.quietud + } + + /// Cuánto pintar una pestaña dormida: `1.0` mientras hay movimiento + /// reciente, bajando hasta [`ATENUACION_MIN`] tras minutos de silencio. Es + /// la política de «idle largo se apaga», acá y no repartida por las vistas. + pub fn atenuacion(&self) -> f32 { + if self.quietud <= QUIETO_DESDE { + return 1.0; + } + if self.quietud >= QUIETO_HASTA { + return ATENUACION_MIN; + } + let t = (self.quietud - QUIETO_DESDE) as f32 / (QUIETO_HASTA - QUIETO_DESDE) as f32; + 1.0 - t * (1.0 - ATENUACION_MIN) + } +} + +/// Bytes de un bin → nivel 0..1 en escala logarítmica. +fn normalizar(bytes: u64) -> f32 { + if bytes == 0 { + return 0.0; + } + let x = (bytes as f32).min(TECHO); + ((1.0 + x).ln() / (1.0 + TECHO).ln()).clamp(0.0, 1.0) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn el_anillo_sale_en_orden_cronologico() { + let mut p = Pulso::default(); + // Tres bins con caudal creciente, separados por muestreos. + for n in [10_u64, 1_000, 100_000] { + p.sumar(n); + p.muestrear(); + } + let b = p.barras(); + // Los tres últimos slots son los tres bins, en orden. + let ultimos = &b[MUESTRAS - 3..]; + assert!(ultimos[0] > 0.0 && ultimos[0] < ultimos[1] && ultimos[1] < ultimos[2]); + // Y lo anterior sigue en cero (nunca hubo caudal). + assert!(b[..MUESTRAS - 3].iter().all(|v| *v == 0.0)); + } + + #[test] + fn sin_bytes_no_hay_nivel_y_crece_la_quietud() { + let mut p = Pulso::default(); + for _ in 0..5 { + p.muestrear(); + } + assert_eq!(p.nivel(), 0.0); + assert_eq!(p.quietud(), 5); + // Un byte corta la racha. + p.sumar(1); + p.muestrear(); + assert_eq!(p.quietud(), 0); + assert!(p.nivel() > 0.0); + } + + #[test] + fn el_anillo_da_la_vuelta_sin_perder_la_ventana() { + let mut p = Pulso::default(); + for _ in 0..MUESTRAS * 3 { + p.muestrear(); + } + p.sumar(5_000); + p.muestrear(); + let b = p.barras(); + assert!(b[MUESTRAS - 1] > 0.0, "lo último muestreado va al final"); + assert!(b[..MUESTRAS - 1].iter().all(|v| *v == 0.0)); + } + + #[test] + fn escala_log_separa_lo_chico_de_lo_grande() { + // Una línea corta tiene que VERSE, no ser un píxel al lado de un build. + let una_linea = normalizar(40); + let un_build = normalizar(200_000); + assert!(una_linea > 0.15, "una línea se ve: {una_linea}"); + assert!(un_build > una_linea && un_build <= 1.0); + assert_eq!(normalizar(0), 0.0); + // Saturación: más allá del techo no se dispara nada raro. + assert!(normalizar(u64::MAX) <= 1.0); + } + + #[test] + fn el_reset_de_un_run_no_hace_delta_negativo() { + // `Pulso` acumula por su cuenta justamente para esto: aunque el + // contador del run vuelva a cero, acá nunca hay retroceso. + let mut p = Pulso::default(); + p.sumar(10_000); + p.muestrear(); + let alto = p.nivel(); + p.muestrear(); // run terminado, sin bytes nuevos + assert!(p.barras()[MUESTRAS - 1] == 0.0); + assert!(alto > 0.0); + } + + #[test] + fn atenuacion_apaga_recien_tras_minutos() { + let mut p = Pulso::default(); + assert_eq!(p.atenuacion(), 1.0); + for _ in 0..QUIETO_DESDE { + p.muestrear(); + } + assert_eq!(p.atenuacion(), 1.0, "30 s de silencio todavía no apagan"); + for _ in 0..(QUIETO_HASTA - QUIETO_DESDE) / 2 { + p.muestrear(); + } + let media = p.atenuacion(); + assert!(media < 1.0 && media > ATENUACION_MIN); + for _ in 0..QUIETO_HASTA { + p.muestrear(); + } + assert_eq!(p.atenuacion(), ATENUACION_MIN, "se apaga pero no se borra"); + } +} diff --git a/02_ruway/shuma/sandbox/shuma-module-shell/src/sections.rs b/02_ruway/shuma/sandbox/shuma-module-shell/src/sections.rs new file mode 100644 index 0000000..af9e57b --- /dev/null +++ b/02_ruway/shuma/sandbox/shuma-module-shell/src/sections.rs @@ -0,0 +1,1893 @@ +//! Sub-collapsables inteligentes dentro del bloque de un comando. +//! +//! Un comando puede emitir output con estructura conocida (un árbol `ls -R`, +//! un log de `claude code` con secciones, un diff con hunks…). Este módulo +//! mira el comando y, si conoce el patrón, parte la salida en `Section`s — +//! cada una se renderiza con su propio header colapsable dentro del card. +//! +//! Cuando ningún detector matchea, retorna `None` y el block cae al render +//! por defecto (text-editor virtualizado de líneas planas). +//! +//! El estado de qué sub-collapsable está plegado vive en +//! [`crate::State::section_collapsed`] como `(block, section_idx)`. + +/// Cómo renderizar el body de una sección. +#[derive(Debug, Clone)] +pub enum SectionKind { + /// Líneas planas — render por defecto (text-editor / line store). + Lines(Vec), + /// Tabla con columnas + filas. El renderer las pinta como grid con + /// headers clickeables para ordenar. + Table { + columns: Vec, + rows: Vec>, + }, + /// SUBSECCIONES anidadas: paneles dentro del panel. Cada hija se pinta con + /// su propio header expandible, indentada un nivel. Recursivo (N niveles). + Group(Vec
), +} + +impl SectionKind { + /// Conteo de elementos representativo (líneas o filas). + pub fn count(&self) -> usize { + match self { + SectionKind::Lines(v) => v.len(), + SectionKind::Table { rows, .. } => rows.len(), + SectionKind::Group(cs) => cs.len(), + } + } + + /// Acceso compatible al modo "lines" — quien renderiza por líneas + /// virtualizadas (el path histórico) puede pedir un slice. + pub fn as_lines(&self) -> Option<&[String]> { + match self { + SectionKind::Lines(v) => Some(v.as_slice()), + _ => None, + } + } +} + +/// Un trozo del output con un título y su body. El render lo pinta como +/// chevron + header clickeable + (si está abierto) las líneas / tabla. +#[derive(Debug, Clone)] +pub struct Section { + pub title: String, + pub kind: SectionKind, +} + +impl Section { + /// Helper de compatibilidad para callers que ya leían `.lines`. Si la + /// sección es Lines, devuelve sus líneas; si es Table, las serializa + /// como joined-text (no se usa en render — sólo para fallback). + pub fn lines(&self) -> Vec { + match &self.kind { + SectionKind::Lines(v) => v.clone(), + SectionKind::Table { rows, .. } => rows.iter().map(|r| r.join(" ")).collect(), + SectionKind::Group(cs) => cs.iter().flat_map(|c| c.lines()).collect(), + } + } +} + +/// Detecta si `cmd` tiene un patrón conocido y devuelve la lista de +/// secciones derivadas de `lines`. Si no aplica, retorna `None`. +pub fn detect_sections(cmd: &str, lines: &[String]) -> Option> { + let cmd_trimmed = cmd.trim_start().trim_start_matches('$').trim_start(); + let tokens: Vec<&str> = cmd_trimmed.split_whitespace().collect(); + if tokens.is_empty() { + return None; + } + match tokens[0] { + // Log de un agente claude-code (cosechado del scrollback del PTY): + // prosa visible + secciones-herramienta plegadas. + "claude" => detect_claude(lines), + "ls" => detect_ls(&tokens[1..], lines), + ":stats" => detect_stats(lines), + "git" if tokens.get(1) == Some(&"status") => detect_git_status(&tokens[2..], lines), + "git" if tokens.get(1) == Some(&"diff") => detect_diff(lines), + "git" if tokens.get(1) == Some(&"log") => detect_git_log(lines), + "diff" => detect_diff(lines), + "env" | "printenv" => detect_env(lines), + "cargo" | "rustc" => detect_cargo(lines), + // `ip addr`/`ip link` (e `ifconfig`): un volcado denso de interfaces. + // Lo partimos en una sección por interfaz (como `git status` largo). + // `ip route`/`ip neigh` NO aplican (no son por-interfaz) → quedan fuera. + "ip" if matches!( + tokens.get(1).copied(), + Some("addr" | "a" | "address" | "link" | "l") + ) => + { + detect_net_interfaces(lines) + } + "ifconfig" => detect_net_interfaces(lines), + "mount" => detect_mount(lines), + "du" => detect_du(lines), + // `free` no va por `header_table`: su columna de etiqueta (`Mem:`, + // `Swap:`) no tiene header, así que se perdería. Parser dedicado. + "free" => detect_free(lines), + // Comandos cuya salida es una tabla con header alineado a ancho fijo: + // `docker ps`, `podman ps`, `kubectl get`, `systemctl list-units`, + // `ps aux`, `df -h`, `lsblk`… Las columnas se cortan por la posición + // de inicio de cada header (left-aligned, padded al ancho de la + // columna). El detector es seguro: si no ve un header tabular, + // devuelve `None` y el bloque cae al render plano. + "docker" | "podman" | "kubectl" | "systemctl" | "ps" | "df" | "lsblk" | "ss" + | "netstat" => header_table(lines) + .map(|(columns, rows)| { + vec![Section { title: String::new(), kind: SectionKind::Table { columns, rows } }] + }) + // `docker inspect`/`kubectl … -o json` emiten JSON, no tabla: si el + // header_table no enganchó, probamos pretty-print de JSON. + .or_else(|| detect_json(lines)), + // Fallback por contenido: si la salida es un blob JSON compacto + // (típico de `docker inspect`, `kubectl -o json`, una API), lo + // pretty-printeamos para que sea legible. Gated y barato (ver guardas). + _ => detect_json(lines), + } +} + +/// Tope de bytes para intentar parsear JSON (corre por frame en el render — +/// acotamos el costo). Más grande que esto cae al plano. +const JSON_MAX_BYTES: usize = 64 * 1024; + +/// Detecta un **blob JSON compacto** y lo pretty-printea como sección `json`. +/// Sólo se mete cuando aporta: salida que arranca con `{`/`[`, ≤4 líneas no +/// vacías (ya multilínea = ya formateada, se respeta), bajo `JSON_MAX_BYTES`, +/// y que parsea a objeto/array. `None` en cualquier otro caso (cae al plano). +fn detect_json(lines: &[String]) -> Option> { + let no_vacias = lines.iter().filter(|l| !l.trim().is_empty()).count(); + if no_vacias == 0 || no_vacias > 4 { + return None; // vacío, o ya viene en varias líneas (formateado) + } + let joined = lines.join("\n"); + let t = joined.trim(); + if t.len() > JSON_MAX_BYTES || !(t.starts_with('{') || t.starts_with('[')) { + return None; + } + let val: serde_json::Value = serde_json::from_str(t).ok()?; + // Sólo contenedores (un escalar `"hola"` o `42` no gana nada con esto). + if !val.is_object() && !val.is_array() { + return None; + } + let pretty = serde_json::to_string_pretty(&val).ok()?; + let body: Vec = pretty.lines().map(String::from).collect(); + Some(vec![Section { title: "json".into(), kind: SectionKind::Lines(body) }]) +} + +/// Tabla con header alineado a ancho fijo (`docker ps`, `kubectl get`, `ps +/// aux`, `df`…). Toma la primera línea no vacía como header, deriva la +/// posición de inicio de cada columna (un no-espacio precedido de ≥2 +/// espacios, o el inicio de línea) y corta cada fila por esas posiciones. +/// Esto maneja celdas vacías (slice vacío) y valores con espacios simples +/// (`Up 2 hours`, `2 hours ago`). `None` si no hay ≥2 columnas o ninguna +/// fila de datos. +fn header_table(lines: &[String]) -> Option<(Vec, Vec>)> { + let header_idx = lines.iter().position(|l| !l.trim().is_empty())?; + let hchars: Vec = lines[header_idx].chars().collect(); + // Posiciones (en chars) donde arranca cada columna. + let mut starts: Vec = Vec::new(); + for (i, c) in hchars.iter().enumerate() { + if c.is_whitespace() { + continue; + } + let nuevo = i == 0 + || (i >= 2 && hchars[i - 1].is_whitespace() && hchars[i - 2].is_whitespace()); + if nuevo { + starts.push(i); + } + } + if starts.len() < 2 { + return None; + } + let slice = |chars: &[char], a: usize, b: Option| -> String { + let end = b.unwrap_or(chars.len()).min(chars.len()); + let a = a.min(chars.len()); + if a >= end { + return String::new(); + } + chars[a..end].iter().collect::().trim().to_string() + }; + let columns: Vec = (0..starts.len()) + .map(|k| slice(&hchars, starts[k], starts.get(k + 1).copied())) + .collect(); + let mut rows: Vec> = Vec::new(); + for line in &lines[header_idx + 1..] { + if line.trim().is_empty() { + continue; + } + let rc: Vec = line.chars().collect(); + let cells: Vec = (0..starts.len()) + .map(|k| slice(&rc, starts[k], starts.get(k + 1).copied())) + .collect(); + if cells.iter().all(|c| c.is_empty()) { + continue; + } + rows.push(cells); + } + if rows.is_empty() { + None + } else { + Some((columns, rows)) + } +} + +/// `git status`: en forma corta (`-s`/`--short`/`--porcelain`) una tabla +/// `XY · estado · archivo`; en forma larga, una sección por grupo (rama, +/// staged, modificados, sin seguimiento, conflictos). +fn detect_git_status(args: &[&str], lines: &[String]) -> Option> { + let short = args.iter().any(|a| { + matches!(*a, "-s" | "--short" | "--porcelain") + || (a.starts_with('-') && !a.starts_with("--") && a.contains('s')) + }); + if short { + detect_git_status_short(lines) + } else { + detect_git_status_long(lines) + } +} + +/// Etiqueta legible para el código `XY` de `git status -s`. +fn git_xy_label(xy: &str) -> String { + let c: Vec = xy.chars().collect(); + let x = c.first().copied().unwrap_or(' '); + let y = c.get(1).copied().unwrap_or(' '); + if x == '?' && y == '?' { + return "sin seguimiento".into(); + } + if x == '!' && y == '!' { + return "ignorado".into(); + } + if x == 'U' || y == 'U' || (x == 'A' && y == 'A') || (x == 'D' && y == 'D') { + return "conflicto".into(); + } + // El staged (X) manda para el verbo; si no, el del árbol (Y). + let code = if x != ' ' { x } else { y }; + let staged = if x != ' ' && x != '?' { " (staged)" } else { "" }; + let verbo = match code { + 'M' => "modificado", + 'A' => "agregado", + 'D' => "borrado", + 'R' => "renombrado", + 'C' => "copiado", + 'T' => "tipo cambiado", + _ => "—", + }; + format!("{verbo}{staged}") +} + +fn detect_git_status_short(lines: &[String]) -> Option> { + let mut rows: Vec> = Vec::new(); + for l in lines { + // `-sb` antepone una línea de rama `## main...origin/main`. + if l.starts_with("##") { + continue; + } + let chars: Vec = l.chars().collect(); + if chars.len() < 3 { + continue; + } + let xy: String = chars[..2].iter().collect(); + let file: String = chars[3..].iter().collect::().trim().to_string(); + if file.is_empty() { + continue; + } + rows.push(vec![xy.clone(), git_xy_label(&xy), file]); + } + if rows.is_empty() { + return None; + } + Some(vec![Section { + title: String::new(), + kind: SectionKind::Table { + columns: vec!["XY".into(), "estado".into(), "archivo".into()], + rows, + }, + }]) +} + +fn detect_git_status_long(lines: &[String]) -> Option> { + // Título humano para cada encabezado de grupo de `git status`. + fn heading_of(line: &str) -> Option<&'static str> { + let t = line.trim_start(); + if t.starts_with("Changes to be committed") { + Some("staged") + } else if t.starts_with("Changes not staged for commit") { + Some("modificados") + } else if t.starts_with("Untracked files") { + Some("sin seguimiento") + } else if t.starts_with("Unmerged paths") { + Some("conflictos") + } else { + None + } + } + let mut preamble: Vec = Vec::new(); + let mut sections: Vec
= Vec::new(); + let mut cur: Option<(String, Vec)> = None; + let flush = |cur: &mut Option<(String, Vec)>, out: &mut Vec
| { + if let Some((title, body)) = cur.take() { + if !body.is_empty() { + out.push(Section { title, kind: SectionKind::Lines(body) }); + } + } + }; + for l in lines { + if let Some(title) = heading_of(l) { + flush(&mut cur, &mut sections); + cur = Some((title.to_string(), Vec::new())); + continue; + } + let t = l.trim(); + // Las líneas de pista `(use "git …")` y las vacías no son archivos. + if t.is_empty() || t.starts_with('(') { + continue; + } + match cur.as_mut() { + Some((_, body)) => body.push(t.to_string()), + None => preamble.push(t.to_string()), + } + } + flush(&mut cur, &mut sections); + if sections.is_empty() { + return None; + } + // La preamble (rama / tracking) va primero para no perderla. + if !preamble.is_empty() { + sections.insert( + 0, + Section { title: "rama".to_string(), kind: SectionKind::Lines(preamble) }, + ); + } + Some(sections) +} + +/// `git diff` / `diff` unificado: una sección colapsable por archivo. Corta +/// en cada `diff --git a/… b/…` (preferido) o, si no hay, en cada par +/// `--- …`. El título es el path del archivo. `None` si no se ve estructura. +fn detect_diff(lines: &[String]) -> Option> { + let starts_file = |l: &str| l.starts_with("diff --git ") || l.starts_with("diff -"); + let has_git_headers = lines.iter().any(|l| starts_file(l)); + let mut sections: Vec
= Vec::new(); + let mut preamble: Vec = Vec::new(); + let mut cur: Option<(String, Vec)> = None; + let flush = |cur: &mut Option<(String, Vec)>, out: &mut Vec
| { + if let Some((title, body)) = cur.take() { + out.push(Section { title, kind: SectionKind::Lines(body) }); + } + }; + for l in lines { + // Header de archivo: `diff --git a/x b/x` → título = el path b/. + let is_header = if has_git_headers { + starts_file(l) + } else { + // Sin `diff --git`: cortamos en `+++ b/path` (segunda mitad del par). + l.starts_with("+++ ") + }; + if is_header { + flush(&mut cur, &mut sections); + let title = diff_title(l); + cur = Some((title, vec![l.clone()])); + } else if let Some((_, body)) = cur.as_mut() { + body.push(l.clone()); + } else { + preamble.push(l.clone()); + } + } + flush(&mut cur, &mut sections); + if sections.is_empty() { + return None; + } + let pre: Vec = preamble.into_iter().filter(|l| !l.trim().is_empty()).collect(); + if !pre.is_empty() { + sections.insert(0, Section { title: "resumen".into(), kind: SectionKind::Lines(pre) }); + } + Some(sections) +} + +/// Extrae el path de un header de diff (`diff --git a/x b/x` o `+++ b/x`). +fn diff_title(line: &str) -> String { + if let Some(rest) = line.strip_prefix("diff --git ") { + // `a/path b/path` → preferimos el lado b (destino). + if let Some((_, b)) = rest.split_once(" b/") { + return b.trim().to_string(); + } + return rest.trim().to_string(); + } + if let Some(rest) = line.strip_prefix("+++ ") { + return rest.trim().trim_start_matches("b/").to_string(); + } + line.trim().to_string() +} + +/// `git log` (formato completo): una sección colapsable por commit (corta en +/// cada línea `commit `). En formato `--oneline` (` asunto`) cae a +/// una tabla `hash · asunto`. `None` si no se ve ninguno de los dos. +fn detect_git_log(lines: &[String]) -> Option> { + let first = lines.iter().find(|l| !l.trim().is_empty())?; + if first.starts_with("commit ") { + // Formato completo: secciones por commit. + let mut sections: Vec
= Vec::new(); + let mut cur: Option<(String, Vec)> = None; + for l in lines { + if l.starts_with("commit ") { + if let Some((title, body)) = cur.take() { + sections.push(Section { title, kind: SectionKind::Lines(body) }); + } + let short = l.strip_prefix("commit ").unwrap_or("").trim(); + let short = short.get(..short.len().min(10)).unwrap_or(short); + cur = Some((format!("commit {short}"), vec![l.clone()])); + } else if let Some((_, body)) = cur.as_mut() { + body.push(l.clone()); + } + } + if let Some((title, body)) = cur.take() { + sections.push(Section { title, kind: SectionKind::Lines(body) }); + } + return if sections.is_empty() { None } else { Some(sections) }; + } + // Formato --oneline: ` asunto`. Tabla hash · asunto. + let mut rows: Vec> = Vec::new(); + for l in lines { + let l = l.trim(); + if l.is_empty() { + continue; + } + let Some((hash, subject)) = l.split_once(char::is_whitespace) else { + return None; + }; + let is_hash = (7..=40).contains(&hash.len()) + && hash.chars().all(|c| c.is_ascii_hexdigit()); + if !is_hash { + return None; + } + rows.push(vec![hash.to_string(), subject.trim().to_string()]); + } + if rows.is_empty() { + return None; + } + Some(vec![Section { + title: String::new(), + kind: SectionKind::Table { columns: vec!["hash".into(), "asunto".into()], rows }, + }]) +} + +/// `env` / `printenv`: tabla `variable · valor` partiendo cada línea por el +/// primer `=`. Las líneas de continuación (valores multilínea) se ignoran. +fn detect_env(lines: &[String]) -> Option> { + let mut rows: Vec> = Vec::new(); + let mut no_vacias = 0usize; + for l in lines { + if l.trim().is_empty() { + continue; + } + no_vacias += 1; + let Some((key, val)) = l.split_once('=') else { + continue; + }; + // Una clave de env válida no tiene espacios — filtra basura. + if key.is_empty() || key.contains(char::is_whitespace) { + continue; + } + rows.push(vec![key.to_string(), val.to_string()]); + } + // Guarda contra falsos positivos (`env FOO=bar cmd` cuya salida no es + // KEY=VAL): exigimos que la mayoría de las líneas sean asignaciones. + if rows.is_empty() || rows.len() * 5 < no_vacias * 3 { + return None; + } + Some(vec![Section { + title: String::new(), + kind: SectionKind::Table { columns: vec!["variable".into(), "valor".into()], rows }, + }]) +} + +/// Extrae el nombre de interfaz del encabezado de un bloque de `ip addr` +/// (`1: eth0: <…>`) o `ifconfig` (`eth0: flags=…`). Cae al primer token si +/// el formato no matchea. +fn iface_name(line: &str) -> String { + let t = line.trim_end(); + // `ip addr`: "N: name: …" — el primer campo es el índice numérico. + if let Some((head, rest)) = t.split_once(": ") { + if !head.is_empty() && head.chars().all(|c| c.is_ascii_digit()) { + return rest.split(':').next().unwrap_or(rest).trim().to_string(); + } + } + // `ifconfig`: "name: flags=…" o "name Link encap:…" — el nombre va antes + // del primer ':' o espacio. + t.split([':', ' ']).next().unwrap_or(t).trim().to_string() +} + +/// `ip addr`/`ip link`/`ifconfig`: un volcado plano de varias interfaces. +/// Cada encabezado **no indentado** abre una interfaz; sus líneas indentadas +/// (link/inet/inet6/…) son su cuerpo. Devuelve una sección por interfaz, con +/// el nombre como título navegable. `None` si no hay ≥2 interfaces (entonces +/// no vale la pena estructurar — cae al render plano). +fn detect_net_interfaces(lines: &[String]) -> Option> { + let mut sections: Vec
= Vec::new(); + let mut cur: Option<(String, Vec)> = None; + let flush = |cur: &mut Option<(String, Vec)>, out: &mut Vec
| { + if let Some((title, body)) = cur.take() { + out.push(Section { title, kind: SectionKind::Lines(body) }); + } + }; + for line in lines { + if line.trim().is_empty() { + continue; // las blancas separan bloques; no aportan al cuerpo + } + let indented = line.starts_with(char::is_whitespace); + if !indented { + // Encabezado de interfaz nueva. + flush(&mut cur, &mut sections); + cur = Some((iface_name(line), vec![line.clone()])); + } else if let Some((_, body)) = cur.as_mut() { + body.push(line.clone()); + } else { + // Línea indentada antes de cualquier encabezado: preámbulo suelto. + cur = Some((String::new(), vec![line.clone()])); + } + } + flush(&mut cur, &mut sections); + (sections.len() >= 2).then_some(sections) +} + +/// `mount`: cada línea es `DISPOSITIVO on MONTAJE type FS (opciones)`. Lo +/// volcamos a una tabla ordenable (dispositivo · montaje · tipo · opciones). +/// Las líneas que no matchean el patrón se saltan; `None` si ninguna lo hace. +fn detect_mount(lines: &[String]) -> Option> { + let mut rows: Vec> = Vec::new(); + for l in lines { + let t = l.trim(); + if t.is_empty() { + continue; + } + let Some((dev, rest)) = t.split_once(" on ") else { + continue; + }; + let Some((mnt, rest2)) = rest.split_once(" type ") else { + continue; + }; + // `rest2` = "ext4 (rw,relatime)" → tipo + opciones entre paréntesis. + let (fstype, opts) = match rest2.split_once(" (") { + Some((fs, o)) => (fs.trim(), o.trim_end_matches(')')), + None => (rest2.trim(), ""), + }; + rows.push(vec![ + dev.to_string(), + mnt.to_string(), + fstype.to_string(), + opts.to_string(), + ]); + } + if rows.is_empty() { + return None; + } + Some(vec![Section { + title: String::new(), + kind: SectionKind::Table { + columns: vec!["dispositivo".into(), "montaje".into(), "tipo".into(), "opciones".into()], + rows, + }, + }]) +} + +/// `du`/`du -h`: cada línea es `TAMAÑORUTA`. Tabla ordenable (tamaño · +/// ruta) — ordenar por tamaño es el caso de uso. El tamaño debe empezar con +/// dígito (filtra ruido); `None` si la mayoría de las líneas no son `du`. +fn detect_du(lines: &[String]) -> Option> { + let mut rows: Vec> = Vec::new(); + let mut no_vacias = 0usize; + for l in lines { + let t = l.trim_end(); + if t.trim().is_empty() { + continue; + } + no_vacias += 1; + let mut it = t.split_whitespace(); + let Some(size) = it.next() else { continue }; + // Un tamaño de `du` empieza con dígito (`4.0K`, `4096`, `1.2G`). + if !size.starts_with(|c: char| c.is_ascii_digit()) { + continue; + } + let path = it.collect::>().join(" "); + if path.is_empty() { + continue; + } + rows.push(vec![size.to_string(), path]); + } + // Guarda contra falsos positivos: la mayoría de las líneas deben matchear. + if rows.is_empty() || rows.len() * 5 < no_vacias * 3 { + return None; + } + Some(vec![Section { + title: String::new(), + kind: SectionKind::Table { + columns: vec!["tamaño".into(), "ruta".into()], + rows, + }, + }]) +} + +/// `free`/`free -h`: el header (`total used free shared buff/cache available`) +/// no tiene columna para las etiquetas de fila (`Mem:`, `Swap:`), así que +/// `header_table` las perdería. Aquí prependeamos una columna vacía y leemos +/// cada fila como `etiqueta + valores`. `None` si el header no parece de `free`. +fn detect_free(lines: &[String]) -> Option> { + let header_idx = lines.iter().position(|l| !l.trim().is_empty())?; + let header_cols: Vec = lines[header_idx].split_whitespace().map(String::from).collect(); + // Confianza: el header de `free` lleva `total` y `used`. + if !header_cols.iter().any(|c| c == "total") || !header_cols.iter().any(|c| c == "used") { + return None; + } + let mut columns = vec![String::new()]; // columna de la etiqueta de fila + columns.extend(header_cols); + let mut rows: Vec> = Vec::new(); + for l in &lines[header_idx + 1..] { + if l.trim().is_empty() { + continue; + } + let mut cells: Vec = l.split_whitespace().map(String::from).collect(); + if cells.is_empty() { + continue; + } + // Pad/trunca al ancho de columnas (Swap: trae menos campos que Mem:). + cells.resize(columns.len(), String::new()); + rows.push(cells); + } + if rows.is_empty() { + return None; + } + Some(vec![Section { + title: String::new(), + kind: SectionKind::Table { columns, rows }, + }]) +} + +/// Salida de `cargo`/`rustc`: una sección colapsable por diagnóstico +/// (cada `error…`/`warning:` arranca uno; el preámbulo `Compiling…` va a +/// «salida»). `None` si no hay ningún diagnóstico — así un `cargo run` +/// normal cae al render plano. +fn detect_cargo(lines: &[String]) -> Option> { + let is_diag = |l: &str| l.starts_with("error") || l.starts_with("warning:"); + if !lines.iter().any(|l| is_diag(l)) { + return None; + } + let mut preamble: Vec = Vec::new(); + let mut diags: Vec
= Vec::new(); + let mut cur: Option<(String, Vec)> = None; + for l in lines { + if is_diag(l) { + if let Some((title, body)) = cur.take() { + diags.push(Section { title, kind: SectionKind::Lines(body) }); + } + // El título lleva la línea del diagnóstico; el body, el contexto + // (los `-->`, el caret, las notas). Sin duplicar la primera línea. + cur = Some((l.trim_end().to_string(), Vec::new())); + } else if let Some((_, body)) = cur.as_mut() { + body.push(l.clone()); + } else { + preamble.push(l.clone()); + } + } + if let Some((title, body)) = cur.take() { + diags.push(Section { title, kind: SectionKind::Lines(body) }); + } + let mut out: Vec
= Vec::new(); + let pre: Vec = preamble.into_iter().filter(|l| !l.trim().is_empty()).collect(); + if !pre.is_empty() { + out.push(Section { title: "salida".to_string(), kind: SectionKind::Lines(pre) }); + } + out.extend(diags); + if out.is_empty() { + None + } else { + Some(out) + } +} + +/// Detecta el reporte de `:stats` (E6): líneas de resumen sin tabulador y un +/// bloque tab-separado (header + filas). Devuelve dos secciones: «resumen» +/// (las líneas sin tab) y «por comando» (la tabla ordenable). El productor es +/// [`crate::update::apply_stats`]; el delimitador `\t` no aparece en nombres +/// de binario ni en los enteros que emite, así que el ida-y-vuelta es estable. +fn detect_stats(lines: &[String]) -> Option> { + let mut resumen: Vec = Vec::new(); + let mut header: Option> = None; + let mut rows: Vec> = Vec::new(); + for line in lines { + if !line.contains('\t') { + if !line.trim().is_empty() { + resumen.push(line.clone()); + } + continue; + } + let cells: Vec = line.split('\t').map(|c| c.to_string()).collect(); + match &header { + None => header = Some(cells), + Some(h) if cells.len() == h.len() => rows.push(cells), + // Fila desalineada: la ignoramos en vez de romper la tabla. + Some(_) => {} + } + } + let columns = header?; + let mut sections = Vec::new(); + if !resumen.is_empty() { + sections.push(Section { + title: "resumen".to_string(), + kind: SectionKind::Lines(resumen), + }); + } + sections.push(Section { + title: "por comando".to_string(), + kind: SectionKind::Table { columns, rows }, + }); + Some(sections) +} + +/// Detecta el output de `ls` con `-l` y/o `-R` y devuelve secciones: +/// - `-R` solo: una sección por directorio, cada una con líneas planas. +/// - `-l` solo: una sección sin título con `SectionKind::Table` parseada. +/// - `-lR`: una sección por directorio, cada una con tabla. +/// Devuelve `None` si no aparece ni `-l` ni `-R`, o si el output no +/// matchea el patrón clásico. +fn detect_ls(flags: &[&str], lines: &[String]) -> Option> { + let has_long = flags + .iter() + .any(|f| f.starts_with('-') && !f.starts_with("--") && f.contains('l')) + || flags.iter().any(|f| *f == "--long" || *f == "--format=long"); + let recursive = flags + .iter() + .any(|f| f.starts_with('-') && !f.starts_with("--") && f.contains('R')) + || flags.iter().any(|f| *f == "--recursive"); + if !has_long && !recursive { + return None; + } + if recursive { + // El patrón `ls -R` siempre arranca con un header `path:`. + if !lines.first().map(|l| l.trim_end().ends_with(':')).unwrap_or(false) { + return None; + } + let mut sections: Vec
= Vec::new(); + let mut current_title: Option = None; + let mut current_lines: Vec = Vec::new(); + let flush = |title: Option, lines: Vec, out: &mut Vec
, long: bool| { + if let Some(t) = title { + let kind = if long { + parse_ls_long_table(&lines) + .map(|(cols, rows)| SectionKind::Table { columns: cols, rows }) + .unwrap_or_else(|| SectionKind::Lines(lines.clone())) + } else { + SectionKind::Lines(lines) + }; + out.push(Section { title: t, kind }); + } + }; + for line in lines { + let trimmed = line.trim_end(); + if trimmed.ends_with(':') + && !trimmed.starts_with(' ') + && !trimmed.starts_with('\t') + { + flush( + current_title.take(), + std::mem::take(&mut current_lines), + &mut sections, + has_long, + ); + current_title = Some(trimmed.trim_end_matches(':').to_string()); + } else if trimmed.is_empty() { + continue; + } else if current_title.is_some() { + current_lines.push(line.clone()); + } else { + return None; + } + } + flush(current_title, current_lines, &mut sections, has_long); + if sections.is_empty() { + None + } else { + Some(sections) + } + } else { + // `-l` solo: una sección única sin header con tabla. + let (cols, rows) = parse_ls_long_table(lines)?; + Some(vec![Section { + title: String::new(), + kind: SectionKind::Table { columns: cols, rows }, + }]) + } +} + +/// Parser básico de líneas `ls -l`. Cada línea típica: +/// `-rw-r--r-- 1 sergio sergio 1234 mar 1 12:34 nombre con espacios` +/// Columns: perms, links, owner, group, size, date (3 tokens), name. +/// La primera línea `total N` se descarta. Devuelve `None` si no se ve el +/// patrón en al menos una línea (puede haber unas pocas no-conformes que +/// se ignoran — devolvemos `Some` si rescatamos ≥1 fila). +fn parse_ls_long_table(lines: &[String]) -> Option<(Vec, Vec>)> { + let cols = vec![ + "permisos".to_string(), + "links".to_string(), + "owner".to_string(), + "group".to_string(), + "size".to_string(), + "fecha".to_string(), + "nombre".to_string(), + ]; + let mut rows: Vec> = Vec::new(); + for line in lines { + let trimmed = line.trim_end(); + if trimmed.is_empty() { + continue; + } + if trimmed.starts_with("total ") { + continue; + } + // Tomamos los primeros 8 tokens whitespace-separated; el resto es + // el nombre (que puede tener espacios). + let mut it = trimmed.split_whitespace(); + let perms = match it.next() { + Some(s) => s.to_string(), + None => continue, + }; + // Sanity check: perms tiene 10 caracteres (drwxr-xr-x) o con ACL `+`. + if perms.len() < 10 { + continue; + } + let links = match it.next() { + Some(s) => s.to_string(), + None => continue, + }; + let owner = match it.next() { + Some(s) => s.to_string(), + None => continue, + }; + let group = match it.next() { + Some(s) => s.to_string(), + None => continue, + }; + let size = match it.next() { + Some(s) => s.to_string(), + None => continue, + }; + let d1 = match it.next() { + Some(s) => s.to_string(), + None => continue, + }; + let d2 = match it.next() { + Some(s) => s.to_string(), + None => continue, + }; + let d3 = match it.next() { + Some(s) => s.to_string(), + None => continue, + }; + let fecha = format!("{d1} {d2} {d3}"); + let nombre = it.collect::>().join(" "); + if nombre.is_empty() { + continue; + } + rows.push(vec![perms, links, owner, group, size, fecha, nombre]); + } + if rows.is_empty() { + None + } else { + Some((cols, rows)) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn ls_r_clasico_se_parte_por_directorio() { + let lines = vec![ + ".:".to_string(), + "a b c".to_string(), + "".to_string(), + "./sub:".to_string(), + "d e".to_string(), + ]; + let secs = detect_sections("ls -R", &lines).expect("detect"); + assert_eq!(secs.len(), 2); + assert_eq!(secs[0].title, "."); + assert_eq!(secs[0].as_lines_for_test(), Some(vec!["a b c".to_string()])); + assert_eq!(secs[1].title, "./sub"); + assert_eq!(secs[1].as_lines_for_test(), Some(vec!["d e".to_string()])); + } + + #[test] + fn ip_addr_se_parte_por_interfaz() { + let lines = vec![ + "1: lo: mtu 65536 qdisc noqueue state UNKNOWN".to_string(), + " link/loopback 00:00:00:00:00:00 brd 00:00:00:00:00:00".to_string(), + " inet 127.0.0.1/8 scope host lo".to_string(), + "2: eth0: mtu 1500 qdisc fq state UP".to_string(), + " link/ether aa:bb:cc:dd:ee:ff brd ff:ff:ff:ff:ff:ff".to_string(), + " inet 192.168.1.10/24 brd 192.168.1.255 scope global eth0".to_string(), + ]; + let secs = detect_sections("ip addr", &lines).expect("detect"); + assert_eq!(secs.len(), 2); + assert_eq!(secs[0].title, "lo"); + assert_eq!(secs[1].title, "eth0"); + // El cuerpo conserva encabezado + líneas indentadas de la interfaz. + let body0 = secs[0].as_lines_for_test().unwrap(); + assert_eq!(body0.len(), 3); + assert!(body0[2].contains("127.0.0.1/8")); + } + + #[test] + fn ifconfig_se_parte_por_interfaz() { + let lines = vec![ + "eth0: flags=4163 mtu 1500".to_string(), + " inet 192.168.1.10 netmask 255.255.255.0".to_string(), + " ether aa:bb:cc:dd:ee:ff txqueuelen 1000".to_string(), + "lo: flags=73 mtu 65536".to_string(), + " inet 127.0.0.1 netmask 255.0.0.0".to_string(), + ]; + let secs = detect_sections("ifconfig", &lines).expect("detect"); + assert_eq!(secs.len(), 2); + assert_eq!(secs[0].title, "eth0"); + assert_eq!(secs[1].title, "lo"); + } + + #[test] + fn ip_route_no_se_estructura() { + // `ip route` no es por-interfaz → el detector ni se invoca. + let lines = vec![ + "default via 192.168.1.1 dev eth0".to_string(), + "192.168.1.0/24 dev eth0 proto kernel scope link".to_string(), + ]; + assert!(detect_sections("ip route", &lines).is_none()); + } + + #[test] + fn una_sola_interfaz_no_vale_la_pena() { + let lines = vec![ + "1: lo: mtu 65536".to_string(), + " inet 127.0.0.1/8 scope host lo".to_string(), + ]; + assert!(detect_sections("ip addr", &lines).is_none()); + } + + #[test] + fn ss_se_lee_como_tabla() { + let lines = vec![ + "Netid State Recv-Q Send-Q Local-Address Peer-Address".to_string(), + "tcp LISTEN 0 128 0.0.0.0:22 0.0.0.0:*".to_string(), + "tcp ESTAB 0 0 10.0.0.1:22 10.0.0.5:5051".to_string(), + ]; + let secs = detect_sections("ss -tn", &lines).expect("detect"); + match &secs[0].kind { + SectionKind::Table { columns, rows } => { + assert!(columns.len() >= 4); + assert_eq!(rows.len(), 2); + } + _ => panic!("esperaba Table"), + } + } + + #[test] + fn json_compacto_se_pretty_printea() { + let lines = vec![r#"{"name":"web","ports":[80,443],"tls":true}"#.to_string()]; + let secs = detect_sections("docker inspect web", &lines).expect("detect"); + assert_eq!(secs[0].title, "json"); + let body = secs[0].as_lines_for_test().unwrap(); + // Pretty-print: varias líneas indentadas. + assert!(body.len() > 3); + assert!(body.iter().any(|l| l.contains("\"name\": \"web\""))); + } + + #[test] + fn json_array_tambien() { + let lines = vec![r#"[{"a":1},{"a":2}]"#.to_string()]; + let secs = detect_sections("curl -s http://x/api", &lines).expect("detect"); + assert_eq!(secs[0].title, "json"); + } + + #[test] + fn json_no_estructurado_o_escalar_cae_al_plano() { + // No-JSON. + assert!(detect_sections("echo hola", &[String::from("hola mundo")]).is_none()); + // Escalar JSON: no gana nada. + assert!(detect_sections("echo", &[String::from("42")]).is_none()); + // Ya multilínea (formateado): se respeta, no se re-envuelve. + let pretty = vec![ + "{".to_string(), + " \"a\": 1,".to_string(), + " \"b\": 2,".to_string(), + " \"c\": 3,".to_string(), + " \"d\": 4".to_string(), + "}".to_string(), + ]; + assert!(detect_sections("cat x.json", &pretty).is_none()); + } + + #[test] + fn mount_se_lee_como_tabla() { + let lines = vec![ + "/dev/sda1 on / type ext4 (rw,relatime)".to_string(), + "proc on /proc type proc (rw,nosuid,nodev,noexec)".to_string(), + "tmpfs on /run type tmpfs (rw,nosuid,nodev)".to_string(), + ]; + let secs = detect_sections("mount", &lines).expect("detect"); + match &secs[0].kind { + SectionKind::Table { columns, rows } => { + assert_eq!(columns.len(), 4); + assert_eq!(rows.len(), 3); + assert_eq!(rows[0], vec!["/dev/sda1", "/", "ext4", "rw,relatime"]); + } + _ => panic!("esperaba Table"), + } + } + + #[test] + fn du_se_lee_como_tabla_tamaño_ruta() { + let lines = vec![ + "4.0K\t./a".to_string(), + "12K\t./carpeta con espacios".to_string(), + "16K\t.".to_string(), + ]; + let secs = detect_sections("du -h", &lines).expect("detect"); + match &secs[0].kind { + SectionKind::Table { columns, rows } => { + assert_eq!(columns, &vec!["tamaño".to_string(), "ruta".to_string()]); + assert_eq!(rows.len(), 3); + assert_eq!(rows[1], vec!["12K", "./carpeta con espacios"]); + } + _ => panic!("esperaba Table"), + } + } + + #[test] + fn du_descarta_si_no_parece_du() { + // Salida que no es `du` (no empieza con tamaño) → cae al plano. + let lines = vec![ + "hola mundo".to_string(), + "esto no es du".to_string(), + ]; + assert!(detect_sections("du", &lines).is_none()); + } + + #[test] + fn free_conserva_las_etiquetas_de_fila() { + let lines = vec![ + " total used free shared buff/cache available".to_string(), + "Mem: 15Gi 8.0Gi 2.0Gi 500Mi 5.0Gi 6.0Gi".to_string(), + "Swap: 8.0Gi 1.0Gi 7.0Gi".to_string(), + ]; + let secs = detect_sections("free -h", &lines).expect("detect"); + match &secs[0].kind { + SectionKind::Table { columns, rows } => { + // Columna 0 = etiqueta (sin header) + las 6 de free. + assert_eq!(columns.len(), 7); + assert_eq!(columns[1], "total"); + assert_eq!(rows[0][0], "Mem:"); + assert_eq!(rows[0][1], "15Gi"); + // Swap trae menos campos → se rellena al ancho. + assert_eq!(rows[1][0], "Swap:"); + assert_eq!(rows[1].len(), 7); + } + _ => panic!("esperaba Table"), + } + } + + #[test] + fn ls_l_solo_devuelve_tabla() { + let lines = vec![ + "total 8".to_string(), + "-rw-r--r-- 1 u u 0 mar 1 12:00 a".to_string(), + "-rw-r--r-- 1 u u 42 mar 1 12:00 nombre con espacios".to_string(), + ]; + let secs = detect_sections("ls -l", &lines).expect("detect"); + assert_eq!(secs.len(), 1); + match &secs[0].kind { + SectionKind::Table { columns, rows } => { + assert_eq!(columns.len(), 7); + assert_eq!(rows.len(), 2); + assert_eq!(rows[1][6], "nombre con espacios"); + } + _ => panic!("esperaba Table"), + } + } + + #[test] + fn ls_sin_l_ni_R_no_se_secciona() { + let lines = vec!["a".to_string(), "b".to_string()]; + assert!(detect_sections("ls -a", &lines).is_none()); + } + + #[test] + fn comando_desconocido_no_secciona() { + let lines = vec!["foo".to_string()]; + assert!(detect_sections("echo foo", &lines).is_none()); + } + + #[test] + fn ls_lR_combinado_da_tablas_por_dir() { + let lines = vec![ + ".:".to_string(), + "total 8".to_string(), + "-rw-r--r-- 1 u u 0 mar 1 12:00 a".to_string(), + "".to_string(), + "./d:".to_string(), + "total 4".to_string(), + "-rw-r--r-- 1 u u 0 mar 1 12:00 b".to_string(), + ]; + let secs = detect_sections("ls -lR", &lines).expect("detect"); + assert_eq!(secs.len(), 2); + assert!(matches!(secs[0].kind, SectionKind::Table { .. })); + assert!(matches!(secs[1].kind, SectionKind::Table { .. })); + assert_eq!(secs[1].title, "./d"); + } + + #[test] + fn stats_se_parte_en_resumen_y_tabla() { + let lines = vec![ + "120 comandos en historial · 8 binarios distintos · 100 con código de salida".to_string(), + "comando\tveces\tfallos\t%fallo\tp50ms\tp95ms\túltimo".to_string(), + "cargo\t30\t2\t6\t1500\t4200\t2m".to_string(), + "git\t12\t0\t0\t40\t90\t1h".to_string(), + ]; + let secs = detect_sections(":stats", &lines).expect("detect"); + assert_eq!(secs.len(), 2); + assert_eq!(secs[0].title, "resumen"); + assert!(matches!(secs[0].kind, SectionKind::Lines(_))); + assert_eq!(secs[1].title, "por comando"); + match &secs[1].kind { + SectionKind::Table { columns, rows } => { + assert_eq!(columns.len(), 7); + assert_eq!(rows.len(), 2); + assert_eq!(rows[0][0], "cargo"); + assert_eq!(rows[0][1], "30"); + } + _ => panic!("esperaba Table"), + } + } + + #[test] + fn docker_ps_se_parsea_como_tabla() { + let lines = vec![ + "CONTAINER ID IMAGE COMMAND CREATED STATUS PORTS NAMES".to_string(), + "abc123def456 nginx:1.27 \"/docker-entrypoint.…\" 2 hours ago Up 2 hours 0.0.0.0:80->80/tcp web".to_string(), + "789aaa111bbb postgres:16 \"docker-entrypoint.s…\" 3 days ago Exited (0) db".to_string(), + ]; + let secs = detect_sections("docker ps -a", &lines).expect("detect"); + assert_eq!(secs.len(), 1); + match &secs[0].kind { + SectionKind::Table { columns, rows } => { + assert_eq!(columns.len(), 7); + assert_eq!(columns[0], "CONTAINER ID"); + assert_eq!(columns[4], "STATUS"); + assert_eq!(rows.len(), 2); + // Valores con espacio simple se mantienen unidos. + assert_eq!(rows[0][4], "Up 2 hours"); + assert_eq!(rows[0][6], "web"); + // Celda PORTS vacía en el segundo no descoloca NAMES. + assert_eq!(rows[1][5], ""); + assert_eq!(rows[1][6], "db"); + } + _ => panic!("esperaba Table"), + } + } + + #[test] + fn git_status_corto_da_tabla_con_estado() { + let lines = vec![ + "## main...origin/main".to_string(), + " M src/foo.rs".to_string(), + "A src/bar.rs".to_string(), + "?? nohup.out".to_string(), + ]; + let secs = detect_sections("git status -s", &lines).expect("detect"); + assert_eq!(secs.len(), 1); + match &secs[0].kind { + SectionKind::Table { columns, rows } => { + assert_eq!(columns, &["XY", "estado", "archivo"]); + assert_eq!(rows.len(), 3); // la línea ## se omite + assert_eq!(rows[0][2], "src/foo.rs"); + assert_eq!(rows[0][1], "modificado"); + assert!(rows[1][1].contains("staged")); + assert_eq!(rows[2][1], "sin seguimiento"); + } + _ => panic!("esperaba Table"), + } + } + + #[test] + fn git_status_largo_se_parte_por_grupo() { + let lines = vec![ + "On branch main".to_string(), + "Your branch is up to date with 'origin/main'.".to_string(), + "".to_string(), + "Changes to be committed:".to_string(), + " (use \"git restore --staged ...\" to unstage)".to_string(), + "\tmodified: a.rs".to_string(), + "".to_string(), + "Untracked files:".to_string(), + " (use \"git add ...\" to include)".to_string(), + "\tnohup.out".to_string(), + ]; + let secs = detect_sections("git status", &lines).expect("detect"); + // rama + staged + sin seguimiento. + assert_eq!(secs.len(), 3); + assert_eq!(secs[0].title, "rama"); + assert_eq!(secs[1].title, "staged"); + assert_eq!(secs[1].as_lines_for_test().unwrap(), vec!["modified: a.rs"]); + assert_eq!(secs[2].title, "sin seguimiento"); + assert_eq!(secs[2].as_lines_for_test().unwrap(), vec!["nohup.out"]); + } + + #[test] + fn cargo_diagnosticos_una_seccion_por_error() { + let lines = vec![ + " Compiling shuma v0.1.0".to_string(), + "error[E0308]: mismatched types".to_string(), + " --> src/foo.rs:3:5".to_string(), + "warning: unused variable `x`".to_string(), + " --> src/bar.rs:9:9".to_string(), + "error: could not compile `shuma`".to_string(), + ]; + let secs = detect_sections("cargo build", &lines).expect("detect"); + // salida + 3 diagnósticos. + assert_eq!(secs.len(), 4); + assert_eq!(secs[0].title, "salida"); + assert!(secs[1].title.starts_with("error[E0308]")); + assert_eq!(secs[1].as_lines_for_test().unwrap(), vec![" --> src/foo.rs:3:5"]); + assert!(secs[2].title.starts_with("warning")); + assert!(secs[3].title.starts_with("error: could not compile")); + } + + #[test] + fn cargo_sin_diagnosticos_no_secciona() { + let lines = vec!["Hello, world!".to_string(), " Finished in 0.1s".to_string()]; + assert!(detect_sections("cargo run", &lines).is_none()); + } + + #[test] + fn git_diff_una_seccion_por_archivo() { + let lines = vec![ + "diff --git a/src/foo.rs b/src/foo.rs".to_string(), + "index 111..222 100644".to_string(), + "@@ -1,3 +1,4 @@".to_string(), + "+nueva línea".to_string(), + "diff --git a/README.md b/README.md".to_string(), + "@@ -10,2 +10,2 @@".to_string(), + "-vieja".to_string(), + "+nueva".to_string(), + ]; + let secs = detect_sections("git diff", &lines).expect("detect"); + assert_eq!(secs.len(), 2); + assert_eq!(secs[0].title, "src/foo.rs"); + assert_eq!(secs[1].title, "README.md"); + } + + #[test] + fn git_log_oneline_es_tabla_y_full_secciones() { + let oneline = vec![ + "a1b2c3d arregla el parser".to_string(), + "9f8e7d6 agrega tests".to_string(), + ]; + let secs = detect_sections("git log --oneline", &oneline).expect("detect"); + match &secs[0].kind { + SectionKind::Table { columns, rows } => { + assert_eq!(columns, &["hash", "asunto"]); + assert_eq!(rows.len(), 2); + assert_eq!(rows[0][1], "arregla el parser"); + } + _ => panic!("oneline esperaba tabla"), + } + let full = vec![ + "commit a1b2c3d4e5f6a7b8".to_string(), + "Author: Sergio ".to_string(), + " arregla el parser".to_string(), + "commit 0011223344556677".to_string(), + " otro commit".to_string(), + ]; + let secs = detect_sections("git log", &full).expect("detect"); + assert_eq!(secs.len(), 2); + assert!(secs[0].title.starts_with("commit a1b2c3d4")); + } + + #[test] + fn env_es_tabla_y_no_secciona_salida_libre() { + let lines = vec![ + "PATH=/usr/bin:/bin".to_string(), + "HOME=/home/u".to_string(), + "SHELL=/bin/zsh".to_string(), + ]; + let secs = detect_sections("env", &lines).expect("detect"); + match &secs[0].kind { + SectionKind::Table { columns, rows } => { + assert_eq!(columns, &["variable", "valor"]); + assert_eq!(rows.len(), 3); + assert_eq!(rows[0], vec!["PATH", "/usr/bin:/bin"]); + } + _ => panic!("esperaba tabla"), + } + // `env FOO=bar prog` cuya salida es texto libre → no se secciona. + let libre = vec![ + "FOO=bar".to_string(), + "esto es salida del programa".to_string(), + "otra línea cualquiera de log".to_string(), + "y otra más".to_string(), + ]; + assert!(detect_sections("env", &libre).is_none()); + } + + impl Section { + fn as_lines_for_test(&self) -> Option> { + self.kind.as_lines().map(|v| v.to_vec()) + } + } +} + +/// Detector del **log de claude code** (lo que la cosecha del scrollback del +/// PTY dejó en el block). Gramática real del transcript del CLI: +/// +/// - `● Nombre(args…)` — **llamada a herramienta** (identificador pegado al +/// paréntesis): sección titulada, PLEGADA por default. Su resultado llega +/// como `⎿ …` + líneas indentadas: caen dentro. +/// - `● prosa…` — **mensaje del asistente**: VISIBLE, incluidas sus líneas de +/// continuación indentadas (el wrap del párrafo). Esta es la distinción +/// clave: lo indentado pertenece a lo que ABRIÓ (prosa→visible, +/// herramienta→plegado). +/// - `❯ texto` — **turno del usuario**: panel CONTENEDOR (`Group`) que llega +/// hasta el próximo turno. El input es la cabecera; la respuesta entera +/// (mensajes ▸, tools ●) vive DENTRO como subsecciones — el mismo cuadre +/// que el grid vivo (`view::tui::segmentar_grid_claude`), así la estructura +/// no se aplana cuando el run termina y el scrollback se cosecha. +/// - Spinners/status (`✻ Pensando…`, `esc to interrupt`, contadores) y el +/// chrome de cajas (`╭─╮ │ ╰─╯`) se filtran. +pub fn detect_claude(lines: &[String]) -> Option> { + // Formato real del transcript (claude v2.1, medido en metal): + // filas 0..N banner de bienvenida (logo, version, "Using …") + // ❯ hola turno del USUARIO (prefijo ❯ / >) + // ● ¡Hola!… mensaje del asistente + // ● Bash(...) llamada a herramienta + ⎿ / indentado + // ─────── reglas + ❯ vacío + "manual mode on" → caja de input (ruido) + // ✻ Crunched spinner de estado (ruido) + if lines.is_empty() { + return None; + } + + fn titulo_herramienta(tl: &str) -> Option { + let resto = tl.trim_start_matches(['●', '⏺']).trim_start(); + let ident_fin = resto + .char_indices() + .find(|(_, c)| !(c.is_alphanumeric() || *c == '_' || *c == '-')) + .map(|(i, _)| i)?; + if ident_fin == 0 || !resto[ident_fin..].starts_with('(') { + return None; + } + Some(format!("● {}", resto.trim_end())) + } + // Ruido: reglas horizontales, el prompt vacío, spinner, hints. + fn es_ruido(tl: &str) -> bool { + if tl.is_empty() { + return false; + } + // Regla horizontal: pura línea de ─ (o box drawing). + if tl.chars().all(|c| "─━═╌ ".contains(c)) { + return true; + } + // Prompt de input vacío: ❯ / > solo, o "manual mode on…". + let t = tl.trim(); + if t == "❯" || t == ">" || t.starts_with("⏸") || t.contains("manual mode") + || t.contains("esc to interrupt") || t.contains("? for shortcuts") + || t.contains("Enter to confirm") || t.contains("Esc to cancel") + { + return true; + } + // Spinner de "pensando": claude ROTA verbos (Effecting…, Architecting…, + // Crunched for 3s, Pondering…) con un glifo que también cambia. Cada + // verbo queda "quieto" más que un par de drains → se colaba al log. + // Se filtran por forma, no por glifo fijo: + // - empieza con un glifo de spinner (set amplio, PERO no ● ⏺ que son + // mensajes/herramientas reales), + // - o termina en «…» (verbo activo), + // - o matchea «Palabra for Ns» / «Palabra… (Ns…». + const SPIN_GLIFOS: &str = "✻✳✽✢✶✺✦✧⋆∗*·◐◓◑◒⠋⠙⠹⠸⠼⠴⠦⠧⠇⠏"; + let sin_glifo = t.trim_start_matches(|c| SPIN_GLIFOS.contains(c)).trim_start(); + let tenia_glifo = sin_glifo.len() < t.len(); + if tenia_glifo && !sin_glifo.is_empty() { + return true; + } + let cuerpo = if tenia_glifo { sin_glifo } else { t }; + if cuerpo.ends_with('…') || cuerpo.ends_with("...") { + return true; + } + // «Verbo for Ns» (Crunched for 3s) o «Verbo… (5s · …». + let mut it = cuerpo.split_whitespace(); + if let (Some(w1), Some(w2)) = (it.next(), it.next()) { + if w2 == "for" { + if let Some(w3) = it.next() { + if w3.trim_end_matches('s').chars().all(|c| c.is_ascii_digit()) + && w1.chars().all(|c| c.is_alphabetic()) + { + return true; + } + } + } + } + false + } + // Turno del usuario: ❯ texto o > texto (con contenido). + fn turno_usuario(tl: &str) -> Option { + let t = tl.trim_start(); + for p in ['❯', '>'] { + if let Some(resto) = t.strip_prefix(p) { + let msg = resto.trim(); + if msg.is_empty() { + return None; + } + // «❯ 1. Yes…» es el CURSOR de un menú (opción numerada), NO un + // turno tipeado. Un turno real es texto libre. + let es_opcion_menu = msg + .split_once('.') + .is_some_and(|(n, _)| !n.is_empty() && n.chars().all(|c| c.is_ascii_digit())); + if es_opcion_menu { + return None; + } + return Some(msg.to_string()); + } + } + None + } + + #[derive(PartialEq)] + enum Abierto { Nada, Msg, Tool } + let mut out: Vec
= Vec::new(); + // Turno ABIERTO del usuario: (título ❯, input completo, hijas acumuladas). + // Mientras hay uno abierto, las secciones que se cierran caen ADENTRO. + let mut turno: Option<(String, String, Vec
)> = None; + let mut bienvenida: Vec = Vec::new(); + let mut prosa: Vec = Vec::new(); + let mut tool: Option<(String, Vec)> = None; + let mut abierto = Abierto::Nada; + let mut vio_interaccion = false; + + fn cerrar_prosa(out: &mut Vec
, prosa: &mut Vec, prefijo: &str) { + while prosa.last().is_some_and(|l| l.trim().is_empty()) { prosa.pop(); } + while prosa.first().is_some_and(|l| l.trim().is_empty()) { prosa.remove(0); } + if prosa.is_empty() { return; } + let lineas = std::mem::take(prosa); + let t = lineas[0].trim(); + let corto: String = t.chars().take(60).collect(); + let titulo = format!("{prefijo} {corto}{}", if t.chars().count() > 60 { "…" } else { "" }); + let hijos = prosa_con_tablas(lineas); + let kind = if hijos.len() == 1 && hijos[0].title.is_empty() { + hijos.into_iter().next().unwrap().kind + } else { SectionKind::Group(hijos) }; + out.push(Section { title: titulo, kind }); + } + fn cerrar_tool(out: &mut Vec
, tool: &mut Option<(String, Vec)>) { + if let Some((titulo, mut cuerpo)) = tool.take() { + while cuerpo.last().is_some_and(|l| l.trim().is_empty()) { cuerpo.pop(); } + let hijos = prosa_con_tablas(cuerpo); + let kind = if hijos.len() == 1 && hijos[0].title.is_empty() { + hijos.into_iter().next().unwrap().kind + } else { SectionKind::Group(hijos) }; + out.push(Section { title: titulo, kind }); + } + } + // Cierra el turno como panel PRINCIPAL contenedor. El input vive SÓLO en el + // título (el header sticky lo muestra completo, multilínea si hace falta) y + // NUNCA se repite en el cuerpo. El cuerpo son las respuestas (hijos); `Group` + // aunque esté vacío = sólo el header del turno. + fn cerrar_turno(out: &mut Vec
, turno: &mut Option<(String, String, Vec
)>) { + if let Some((titulo, _msg, hijos)) = turno.take() { + out.push(Section { title: titulo, kind: SectionKind::Group(hijos) }); + } + } + + // Filas de continuación del input del usuario que todavía podemos absorber. + // El `❯` sólo marca la PRIMERA fila: cuando el mensaje es largo, la TUI lo + // envuelve y las filas siguientes no llevan prefijo. Sin esto el header del + // turno mostraba un renglón y el resto del input se derramaba al cuerpo como + // si fuera prosa del asistente. Tope alineado con el clamp del header (10 + // líneas): más allá no se pintaría igual. + const MAX_FILAS_TURNO: usize = 10; + let mut absorbiendo = 0usize; + + for linea in lines { + let tl = linea.trim(); + if es_ruido(tl) { + absorbiendo = 0; + continue; + } + // ¿Seguimos dentro del input del usuario? Corta con una fila vacía o con + // el primer marcador de respuesta. Es deliberadamente conservador: un + // input con párrafos separados por una línea en blanco se absorbe sólo + // hasta el corte, y se prefiere eso a tragarse la respuesta de claude. + if absorbiendo > 0 { + let sigue = !tl.is_empty() + && !tl.starts_with('●') + && !tl.starts_with('⏺') + && !tl.starts_with('⎿'); + match turno.as_mut().filter(|_| sigue) { + Some((titulo, msg, _)) => { + titulo.push('\n'); + titulo.push_str(tl); + msg.push('\n'); + msg.push_str(tl); + absorbiendo -= 1; + continue; + } + None => absorbiendo = 0, + } + } + // Turno del usuario (❯ texto): cierra lo abierto (dentro del turno + // anterior si lo hay), cierra ese turno, y abre el contenedor nuevo. + if let Some(msg) = turno_usuario(tl) { + { + let dest = match turno.as_mut() { Some((_, _, h)) => h, None => &mut out }; + cerrar_tool(dest, &mut tool); + cerrar_prosa(dest, &mut prosa, "▸"); + } + cerrar_turno(&mut out, &mut turno); + vio_interaccion = true; + // Título = el INPUT COMPLETO (sin truncar): el header del panel + // principal lo muestra entero. El ❯ marca que es un turno del usuario + // (lo usa el render para pintarlo como principal/sticky sin indentar). + let titulo = format!("❯ {msg}"); + turno = Some((titulo, msg, Vec::new())); + abierto = Abierto::Nada; + absorbiendo = MAX_FILAS_TURNO - 1; // la fila del ❯ ya cuenta + continue; + } + if tl.starts_with('●') || tl.starts_with('⏺') { + let dest = match turno.as_mut() { Some((_, _, h)) => h, None => &mut out }; + cerrar_tool(dest, &mut tool); + vio_interaccion = true; + if let Some(titulo) = titulo_herramienta(tl) { + cerrar_prosa(dest, &mut prosa, "▸"); + tool = Some((titulo, Vec::new())); + abierto = Abierto::Tool; + } else { + cerrar_prosa(dest, &mut prosa, "▸"); + prosa.push(tl.trim_start_matches(['●', '⏺']).trim_start().to_string()); + abierto = Abierto::Msg; + } + continue; + } + let indentada = linea.starts_with(" ") || tl.starts_with('⎿'); + match abierto { + Abierto::Tool if indentada || tl.is_empty() => { + if let Some((_, c)) = tool.as_mut() { + c.push(tl.trim_start_matches('⎿').trim_start().to_string()); + } + } + Abierto::Msg if indentada || tl.is_empty() => { + if !tl.is_empty() { + // Preservar la indentación RELATIVA (listas, code blocks): se + // quita el sangrado BASE (~2 espacios con que claude alinea la + // continuación bajo el ●) y se conserva el resto — así una + // lista o un bloque de código no se aplanan. + let cuerpo = linea.strip_prefix(" ").unwrap_or(&linea).trim_end(); + prosa.push(cuerpo.to_string()); + } else if prosa.last().is_some_and(|l| !l.trim().is_empty()) { + // Preservar el SALTO DE PÁRRAFO (una línea en blanco) para que + // el texto no salga "todo pegado". Se colapsan blancos + // consecutivos (sólo se agrega si el anterior no era blanco); + // `cerrar_prosa` recorta los blancos de los extremos. + prosa.push(String::new()); + } + } + _ if !vio_interaccion => { + // Antes de cualquier interacción: BANNER de bienvenida. + bienvenida.push(linea.clone()); + } + _ => { + let dest = match turno.as_mut() { Some((_, _, h)) => h, None => &mut out }; + if abierto == Abierto::Tool { cerrar_tool(dest, &mut tool); } + if !tl.is_empty() { prosa.push(tl.to_string()); abierto = Abierto::Msg; } + else { cerrar_prosa(dest, &mut prosa, "▸"); abierto = Abierto::Nada; } + } + } + } + { + let dest = match turno.as_mut() { Some((_, _, h)) => h, None => &mut out }; + cerrar_tool(dest, &mut tool); + cerrar_prosa(dest, &mut prosa, "▸"); + } + cerrar_turno(&mut out, &mut turno); + + while bienvenida.last().is_some_and(|l| l.trim().is_empty()) { bienvenida.pop(); } + if !bienvenida.is_empty() { + out.insert(0, Section { + title: "▚ Bienvenida de Claude".to_string(), + kind: SectionKind::Lines(bienvenida), + }); + } + if out.is_empty() { return None; } + Some(out) +} + +#[cfg(test)] +mod tests_claude { + use super::*; + + fn lineas(v: &[&str]) -> Vec { + v.iter().map(|s| s.to_string()).collect() + } + + /// Un input largo lo envuelve la TUI en varias filas y sólo la primera + /// lleva `❯`. El turno tiene que quedarse con TODAS: si no, el header + /// muestra un renglón y el resto del input se derrama al cuerpo como si + /// fuera prosa del asistente. + #[test] + fn el_turno_absorbe_las_filas_de_continuacion_del_input() { + let secs = detect_claude(&lineas(&[ + "❯ pon un espacio alrededor de cada header, creo que con media", + " linea basta, y que los iconos se escondan antes", + "", + "⏺ Listo, ahí va.", + ])) + .expect("detecta"); + let turno = secs.iter().find(|s| s.title.starts_with('❯')).expect("turno"); + assert!(turno.title.contains("media"), "falta la primera fila"); + assert!(turno.title.contains("linea basta"), "falta la continuación"); + assert!(turno.title.contains("iconos se escondan"), "falta el final del input"); + assert_eq!(turno.title.lines().count(), 2, "dos filas, dos renglones"); + // Y la respuesta NO se coló dentro del título. + assert!(!turno.title.contains("Listo"), "se tragó la respuesta"); + } + + /// El corte es conservador: una fila vacía o un marcador de respuesta + /// terminan la absorción. Vale perder un input con párrafos antes que + /// tragarse lo que contestó claude. + #[test] + fn la_absorcion_corta_en_el_marcador_de_respuesta() { + let secs = detect_claude(&lineas(&[ + "❯ dale", + "⏺ Voy.", + " y esto es cuerpo de la respuesta", + ])) + .expect("detecta"); + let turno = secs.iter().find(|s| s.title.starts_with('❯')).expect("turno"); + assert_eq!(turno.title, "❯ dale"); + } + + /// Todos los títulos del árbol (DFS), incluidos los de las subsecciones + /// de los turnos-contenedor. + fn titulos(secs: &[Section]) -> Vec { + let mut out = Vec::new(); + fn rec(s: &Section, out: &mut Vec) { + out.push(s.title.clone()); + if let SectionKind::Group(hijos) = &s.kind { + for h in hijos { + rec(h, out); + } + } + } + for s in secs { + rec(s, &mut out); + } + out + } + + #[test] + fn mensajes_y_tools_como_paneles() { + let secs = detect_claude(&lineas(&[ + "Claude Code v2.1", + "Welcome back", + "● Voy a listar los archivos del directorio", + " para ver qué hay.", + "", + "● Bash(ls -la)", + " ⎿ total 24", + " drwxr-xr-x foo", + "", + "● Listo: hay 3 archivos.", + ])) + .expect("detecta"); + // [0] bienvenida (banner antes del 1er ●), plegada. + assert!(secs[0].title.starts_with('▚')); + assert!(crate::view::section_default_collapsed(&secs[0].title)); + // Un panel ▸ por mensaje del asistente (expandido) y un ● por tool + // (plegado). + let msgs: Vec<_> = secs.iter().filter(|s| s.title.starts_with('▸')).collect(); + assert_eq!(msgs.len(), 2, "dos mensajes del asistente"); + assert!(!crate::view::section_default_collapsed(&msgs[0].title), "mensaje expandido"); + let tools: Vec<_> = secs.iter().filter(|s| s.title.starts_with("● Bash")).collect(); + assert_eq!(tools.len(), 1); + assert!(crate::view::section_default_collapsed(&tools[0].title), "tool plegada"); + } + + #[test] + fn formato_real_de_claude() { + // Exactamente el screen medido en metal (2026-07-15). + let secs = detect_claude(&lineas(&[ + " ▐▛███▜▌ Claude Code v2.1.211", + "▝▜█████▛▘ Fable 5 · Claude Max", + " ▘▘ ▝▝ /home/sergio", + "", + " ▎ Using Fable 5 (from .claude/settings.json) · /model", + "", + "❯ hola", + "", + "● ¡Hola! ¿En qué te puedo ayudar hoy?", + "", + "✻ Crunched for 3s", + "", + "────────────────────────", + "❯", + "────────────────────────", + " ⏸ manual mode on · ? for shortcuts", + ])) + .expect("detecta"); + // Panel de bienvenida (banner completo, sin partir), plegado. + assert!(secs[0].title.starts_with('▚'), "bienvenida primero: {:?}", secs[0].title); + assert!(crate::view::section_default_collapsed(&secs[0].title)); + assert_eq!(secs[0].kind.count(), 5, "banner entero (5 filas con contenido)"); + // Turno del usuario ❯ hola, expandido — panel CONTENEDOR de la respuesta. + let usr = secs.iter().find(|s| s.title.starts_with('❯')).expect("turno usuario"); + assert!(usr.title.contains("hola")); + assert!(!crate::view::section_default_collapsed(&usr.title)); + let SectionKind::Group(hijas) = &usr.kind else { panic!("turno contenedor") }; + // Respuesta del asistente ▸, DENTRO del turno, expandida. + let resp = hijas.iter().find(|s| s.title.contains("¡Hola!")).expect("respuesta"); + assert!(resp.title.starts_with('▸')); + // El ruido (reglas, ❯ vacío, spinner, manual mode) NO produjo paneles. + let ts = titulos(&secs); + assert!(!ts.iter().any(|t| t.contains("manual mode"))); + assert!(!ts.iter().any(|t| t.contains("Crunched"))); + } + + #[test] + fn spinners_de_pensando_no_pasan_al_log() { + // Los verbos rotantes del spinner (con o sin glifo) son ruido: NO + // deben separar el turno de la respuesta ni verse como paneles. + let secs = detect_claude(&lineas(&[ + "❯ hola", + "✻ Effecting… (3s · esc to interrupt)", + "Architecting…", + "⋆ Crunched for 5s", + "● ¡Hola! ¿En qué te ayudo?", + ])) + .expect("detecta"); + let ts = titulos(&secs); + assert!(!ts.iter().any(|t| t.contains("Effecting"))); + assert!(!ts.iter().any(|t| t.contains("Architecting"))); + assert!(!ts.iter().any(|t| t.contains("Crunched"))); + // Turno del usuario con la respuesta ADENTRO — sin spinner en medio. + assert!(secs.iter().any(|s| s.title.starts_with('❯') && s.title.contains("hola"))); + assert!(ts.iter().any(|t| t.contains("¡Hola!"))); + } + + #[test] + fn turno_contenedor_agrupa_la_respuesta() { + // Dos turnos: cada uno contiene SU respuesta (prosa + tools) como + // subsecciones — el mismo cuadre que el grid vivo. El segundo, sin + // respuesta aún, es un Group VACÍO: el input vive sólo en el título + // (header principal), NUNCA repetido en el cuerpo. + let secs = detect_claude(&lineas(&[ + "❯ lista los archivos", + "● Voy a listar el directorio.", + "● Bash(ls -la)", + " ⎿ total 24", + "● Listo: hay 3 archivos.", + "❯ gracias", + ])) + .expect("detecta"); + assert_eq!(secs.len(), 2, "dos turnos top-level: {:?}", titulos(&secs)); + let SectionKind::Group(hijas) = &secs[0].kind else { panic!("turno 1 contenedor") }; + assert_eq!(hijas.len(), 3, "prosa + tool + prosa: {:?}", titulos(hijas)); + assert!(hijas[0].title.starts_with('▸')); + assert!(hijas[1].title.starts_with("● Bash")); + assert!(hijas[2].title.starts_with('▸')); + // El turno sin respuesta = Group VACÍO; el input («gracias») va SÓLO en + // el título (header principal), no repetido en el cuerpo. + assert!(secs[1].title.contains("gracias")); + assert!( + matches!(&secs[1].kind, SectionKind::Group(v) if v.is_empty()), + "input sólo en el header, no en el cuerpo: {:?}", + secs[1].kind.count() + ); + } + + #[test] + fn ruido_y_chrome_se_filtran() { + // Sin ● (solo banner + ruido), la bienvenida sigue siendo un panel. + let secs = detect_claude(&lineas(&[ + "Claude Code v2.1", + "✻ Pensando… (esc to interrupt)", + "╭──────────╮", + "│ > │", + "╰──────────╯", + ])); + // Sólo banner "Claude Code v2.1" → un panel de bienvenida. + assert!(secs.is_some()); + assert!(secs.unwrap()[0].title.starts_with('▚')); + } +} + +/// Parte un bloque de prosa en secciones alternando texto y **tablas +/// markdown desplanadas** (`| a | b |` + separador `|---|---|` → Table +/// gráfica sortable). Sin tabla adentro, devuelve la prosa tal cual. +fn prosa_con_tablas(lineas: Vec) -> Vec
{ + fn celdas(l: &str) -> Option> { + let t = l.trim(); + if !t.starts_with('|') || !t.ends_with('|') || t.len() < 2 { + return None; + } + Some( + t[1..t.len() - 1] + .split('|') + .map(|c| c.trim().to_string()) + .collect(), + ) + } + fn es_separador(l: &str) -> bool { + celdas(l).is_some_and(|cs| { + !cs.is_empty() + && cs.iter().all(|c| { + !c.is_empty() && c.chars().all(|ch| ch == '-' || ch == ':') + }) + }) + } + + let mut out: Vec
= Vec::new(); + let mut texto: Vec = Vec::new(); + let mut i = 0; + while i < lineas.len() { + // ¿Arranca una tabla aquí? header | separador | filas… + if let Some(cols) = celdas(&lineas[i]) { + if i + 1 < lineas.len() && es_separador(&lineas[i + 1]) { + if !texto.is_empty() { + out.push(Section { + title: String::new(), + kind: SectionKind::Lines(std::mem::take(&mut texto)), + }); + } + let mut rows: Vec> = Vec::new(); + let mut j = i + 2; + while j < lineas.len() { + match celdas(&lineas[j]) { + Some(mut fila) => { + fila.resize(cols.len(), String::new()); + rows.push(fila); + j += 1; + } + None => break, + } + } + out.push(Section { + title: format!("▤ tabla ({}×{})", rows.len(), cols.len()), + kind: SectionKind::Table { columns: cols, rows }, + }); + i = j; + continue; + } + } + texto.push(lineas[i].clone()); + i += 1; + } + if !texto.is_empty() { + out.push(Section { + title: String::new(), + kind: SectionKind::Lines(texto), + }); + } + out +} + +#[cfg(test)] +mod tests_tablas { + use super::*; + + #[test] + fn tabla_markdown_se_desplana() { + let secs = prosa_con_tablas( + ["mira:", "| a | b |", "| --- | --- |", "| 1 | 2 |", "| 3 | 4 |", "fin"] + .iter() + .map(|s| s.to_string()) + .collect(), + ); + assert_eq!(secs.len(), 3); + assert!(matches!(&secs[1].kind, SectionKind::Table { columns, rows } + if columns == &["a", "b"] && rows.len() == 2 && rows[1] == ["3", "4"])); + assert!(secs[1].title.starts_with('▤')); + } +} diff --git a/02_ruway/shuma/sandbox/shuma-module-shell/src/shell_source.rs b/02_ruway/shuma/sandbox/shuma-module-shell/src/shell_source.rs new file mode 100644 index 0000000..946e1ee --- /dev/null +++ b/02_ruway/shuma/sandbox/shuma-module-shell/src/shell_source.rs @@ -0,0 +1,162 @@ +use super::*; + +/// Fuente de candidatos del shell — implementa +/// [`shuma_line::CompletionSource`]: +/// +/// - `commands()`: escanea `$PATH` la primera vez y cachea el resultado. +/// - `paths(prefix)`: listado del dir derivado del `prefix`, resolviendo +/// relativos contra `cwd`. +#[derive(Debug)] +pub struct ShellSource { + cwd: PathBuf, + /// Si la sesión es un contenedor unshare/bwrap, el path al rootfs en disco. + /// Con esto el preview/completado mira los binarios y archivos de ADENTRO + /// (escaneando el rootfs en el host) en vez del PATH/FS del host — antes el + /// ghost marcaba como existentes comandos del host que no están en el + /// contenedor (y viceversa). + root: Option, + commands: std::sync::OnceLock>, +} + +impl ShellSource { + pub fn new(cwd: &std::path::Path) -> Self { + Self { + cwd: cwd.to_path_buf(), + root: None, + commands: std::sync::OnceLock::new(), + } + } + + /// Variante para sesiones de contenedor: `root` es el rootfs en disco. + pub fn new_in_rootfs(cwd: &std::path::Path, root: PathBuf) -> Self { + Self { + cwd: cwd.to_path_buf(), + root: Some(root), + commands: std::sync::OnceLock::new(), + } + } + + /// Traduce un path INTERIOR del contenedor (`/etc`, `/root/foo`) al path + /// real en el host bajo el rootfs. Sin root, devuelve el path tal cual. + fn host_path(&self, interior: &std::path::Path) -> PathBuf { + match &self.root { + Some(root) => { + let rel = interior.strip_prefix("/").unwrap_or(interior); + root.join(rel) + } + None => interior.to_path_buf(), + } + } +} + +impl ShellSource { + /// `true` si `name` es un ejecutable del PATH escaneado — el dato que el + /// clasificador de intención ([`crate::intent`]) usa para decidir shell-vs-IA + /// sin prefijos. El corpus viene ordenado (binary-search); se cachea al + /// primer uso, así que es barato en el submit. + pub fn es_comando(&self, name: &str) -> bool { + // Por REFERENCIA (sin clonar el corpus): esto corre por tecla en el + // preview de intención, no sólo en el submit. El corpus viene ordenado. + self.commands_ref() + .binary_search_by(|c| c.as_str().cmp(name)) + .is_ok() + } + + /// El corpus de comandos del PATH, por referencia: escanea la primera vez y + /// cachea en el `OnceLock`. El `commands()` del trait lo clona para su API. + fn commands_ref(&self) -> &Vec { + self.commands.get_or_init(|| { + // Dirs de binarios a escanear. Container: los del rootfs en disco. + // Host: los del PATH del proceso. + let dirs: Vec = match &self.root { + Some(root) => ["usr/bin", "bin", "usr/local/bin", "sbin", "usr/sbin"] + .iter() + .map(|d| root.join(d)) + .collect(), + None => std::env::split_paths(&std::env::var_os("PATH").unwrap_or_default()) + .collect(), + }; + let mut out: Vec = Vec::new(); + for dir in dirs { + if let Ok(rd) = std::fs::read_dir(&dir) { + for ent in rd.flatten() { + if let Some(name) = ent.file_name().to_str() { + out.push(name.to_string()); + } + } + } + } + out.sort(); + out.dedup(); + out + }) + } +} + +impl shuma_line::CompletionSource for ShellSource { + fn commands(&self) -> Vec { + self.commands_ref().clone() + } + fn paths(&self, prefix: &str) -> Vec { + let (dir_part, file_part) = match prefix.rfind('/') { + Some(i) => (&prefix[..=i], &prefix[i + 1..]), + None => ("", prefix), + }; + let dir: PathBuf = if dir_part.is_empty() { + self.cwd.clone() + } else if dir_part.starts_with('/') { + PathBuf::from(dir_part) + } else if let Some(stripped) = dir_part.strip_prefix("~/") { + if let Ok(home) = std::env::var("HOME") { + PathBuf::from(home).join(stripped) + } else { + self.cwd.join(dir_part) + } + } else { + self.cwd.join(dir_part) + }; + // En un contenedor, `dir` es un path INTERIOR — lo listamos desde el + // rootfs real en el host. (`host_path` es no-op sin rootfs.) + let Ok(rd) = std::fs::read_dir(self.host_path(&dir)) else { + return Vec::new(); + }; + let mut out: Vec = Vec::new(); + for ent in rd.flatten() { + let name = match ent.file_name().to_str() { + Some(n) => n.to_string(), + None => continue, + }; + if !name.starts_with(file_part) { + continue; + } + // Ocultos: sólo aparecen si el prefix los pidió explícito. + if name.starts_with('.') && !file_part.starts_with('.') { + continue; + } + let mut full = format!("{dir_part}{name}"); + if ent.file_type().map(|t| t.is_dir()).unwrap_or(false) { + full.push('/'); + } + out.push(full); + } + out.sort(); + out + } +} + +/// Construye el `ShellSource` adecuado para `source`: un contenedor +/// unshare/bwrap mira los binarios/archivos del rootfs en disco (preview y +/// completado correctos adentro); cualquier otro source mira el host. +pub(crate) fn completion_source_for( + source: &Source, + cwd: &std::path::Path, +) -> Arc { + match source { + Source::Container { engine, name, .. } + if matches!(engine.as_str(), "unshare" | "bwrap") => + { + Arc::new(ShellSource::new_in_rootfs(cwd, PathBuf::from(name))) + } + _ => Arc::new(ShellSource::new(cwd)), + } +} diff --git a/02_ruway/shuma/sandbox/shuma-module-shell/src/tests/grupo_ajuste_blando.rs b/02_ruway/shuma/sandbox/shuma-module-shell/src/tests/grupo_ajuste_blando.rs new file mode 100644 index 0000000..c29c5f9 --- /dev/null +++ b/02_ruway/shuma/sandbox/shuma-module-shell/src/tests/grupo_ajuste_blando.rs @@ -0,0 +1,205 @@ +//! Crecimiento de la caja del input: cuántas filas pide según el texto. +//! +//! El **ajuste blando** (dónde corta cada renglón) ya no vive acá: lo hace el +//! motor compartido de `llimphi-widget-text-input`, y sus casos —corte por +//! palabra, corte duro, saltos duros, espacio final colgando— se prueban ahí. +//! Lo que queda del lado de shuma es la **política**: el anticipo (abrir la fila +//! antes de que la palabra salte, para que el crecimiento no caiga encima del +//! reflujo) y el trinquete (borrar no encoge la caja hasta vaciarla). + +use llimphi_widget_text_input::Metricas; + +/// Un `State` con el ancho del input ya medido, como si el pintor hubiera +/// corrido un cuadro con `cols` columnas de texto útil. +fn estado_con_cols(texto: &str, cols: usize) -> crate::State { + let mut s = crate::State::new(crate::Source::Local); + let m = crate::view::metricas_input(&s); + s.input.set_metricas(&m); + let char_w = s.input.ed().ancho_caracter(); + s.input + .ed() + .fijar_ancho_caja(char_w * cols as f32 + m.pad_x + m.pad_r); + s.input.set_text(texto); + s +} + +#[test] +fn la_metrica_del_input_es_mono() { + // Si el input dejara de ser monoespaciado, las columnas del prompt no + // alinearían con el texto de la conversación (que sí lo es). + let s = crate::State::new(crate::Source::Local); + let m = crate::view::metricas_input(&s); + assert_eq!( + m.font_family.as_deref(), + Some(llimphi_ui::llimphi_text::MONOSPACE), + "el input del shell tiene que ser mono" + ); + assert_eq!(m, Metricas { ..m.clone() }); +} + +#[test] +fn la_caja_se_abre_ANTES_de_que_la_palabra_salte() { + // 40 columnas, 35 caracteres tipeados: el texto todavía entra en una fila, + // pero le quedan 5 — menos que el anticipo, así que la caja ya pide dos. + let s = estado_con_cols(&"a".repeat(35), 40); + assert_eq!(crate::view::input_filas_visuales(&s), 2); + // Con lugar de sobra, una sola. + let s = estado_con_cols(&"a".repeat(10), 40); + assert_eq!(crate::view::input_filas_visuales(&s), 1); +} + +#[test] +fn el_trinquete_no_deja_encoger_hasta_vaciar() { + let s = estado_con_cols(&"palabra ".repeat(12), 40); + let alto = crate::view::input_filas_visuales(&s); + assert!(alto >= 3, "el texto largo abre varias filas, dio {alto}"); + // Borrar casi todo NO encoge: la caja se queda donde estaba. + let mut s2 = s; + s2.input.set_text("hola"); + assert_eq!( + crate::view::input_filas_visuales(&s2), + alto, + "borrar no encoge la caja" + ); + // Vaciar sí la devuelve a una fila. + s2.input.set_text(""); + assert_eq!(crate::view::input_filas_visuales(&s2), 1); + // Y desde ahí vuelve a crecer normal. + s2.input.set_text("hola"); + assert_eq!(crate::view::input_filas_visuales(&s2), 1); +} + +#[test] +fn sin_ancho_medido_la_caja_pide_una_sola_fila() { + // Antes del primer cuadro el motor no envuelve (no sabe su ancho): la caja + // no puede inventar un alto grande, o parpadearía al primer render. + let mut s = crate::State::new(crate::Source::Local); + s.input.set_text("una linea larguisima que no deberia partirse todavia"); + assert_eq!(crate::view::input_filas_visuales(&s), 1); +} + +#[test] +fn el_alto_en_px_acompana_a_las_filas() { + let s = estado_con_cols("hola", 40); + let una = crate::view::input_alto_px(&s); + let s2 = estado_con_cols(&"palabra ".repeat(12), 40); + let varias = crate::view::input_alto_px(&s2); + assert!( + varias > una, + "más filas tiene que pedir más alto ({varias} vs {una})" + ); + assert_eq!( + crate::view::input_alto_extra_px(&s), + 0.0, + "una sola fila no suma alto extra a la franja del host" + ); +} + +// ── El gate del modo consola ────────────────────────────────────────────── +// +// Con un programa PTY inline vivo (claude), el input de shuma ES el prompt de +// ese programa: el tipeo se edita acá y Enter manda la línea. Pero los atajos +// de control van al programa… y eso se llevaba puesta la navegación por +// palabra, que es edición pura. + +use llimphi_ui::{Key, KeyEvent, KeyState, Modifiers, NamedKey}; + +fn tecla(key: Key, ctrl: bool, shift: bool) -> KeyEvent { + KeyEvent { + key, + state: KeyState::Pressed, + text: None, + modifiers: Modifiers { ctrl, shift, ..Default::default() }, + repeat: false, + } +} + +#[test] +fn el_salto_por_palabra_es_edicion_y_no_se_va_al_programa() { + for (nombre, key) in [ + ("←", Key::Named(NamedKey::ArrowLeft)), + ("→", Key::Named(NamedKey::ArrowRight)), + ("Home", Key::Named(NamedKey::Home)), + ("End", Key::Named(NamedKey::End)), + ("Backspace", Key::Named(NamedKey::Backspace)), + ("Delete", Key::Named(NamedKey::Delete)), + ] { + assert!( + crate::es_edicion_de_linea(&tecla(key.clone(), true, false)), + "Ctrl+{nombre} es edición de línea" + ); + assert!( + crate::es_edicion_de_linea(&tecla(key, true, true)), + "Ctrl+Shift+{nombre} también (salto por palabra CON selección)" + ); + } +} + +#[test] +fn los_atajos_del_programa_siguen_yendo_al_programa() { + // Interrumpir, suspender y las flechas verticales (menús/historial de + // claude) no son edición: si los atrapáramos, romperíamos el programa. + for (nombre, key) in [ + ("c", Key::Character("c".into())), + ("z", Key::Character("z".into())), + ("↑", Key::Named(NamedKey::ArrowUp)), + ("↓", Key::Named(NamedKey::ArrowDown)), + ] { + assert!( + !crate::es_edicion_de_linea(&tecla(key, true, false)), + "Ctrl+{nombre} pertenece al programa, no al input" + ); + } + // Sin Ctrl tampoco: una flecha pelada ya se quedaba en el input por otra vía. + assert!(!crate::es_edicion_de_linea(&tecla( + Key::Named(NamedKey::ArrowLeft), + false, + true + ))); +} + +// ── El portapapeles no se lo come el modo consola ─────────────────── + +/// `Ctrl+Shift+C/V/X` son atajos del **terminal**, no de la aplicación: en +/// cualquier terminal moderna copian, pegan y cortan, y por eso existen con +/// Shift (`Ctrl+C` pelado sigue siendo SIGINT). El gate del modo consola los +/// mandaba al programa junto con todo lo que lleva Ctrl, y adentro de claude no +/// había forma de copiar ni pegar — el handler de copiado estaba intacto unas +/// líneas más abajo y no se alcanzaba nunca. +#[test] +fn el_portapapeles_se_queda_en_shuma_aunque_corra_un_pty() { + for (nombre, ch) in [("C", "c"), ("V", "v"), ("X", "x")] { + assert!( + crate::es_portapapeles(&tecla(Key::Character(ch.into()), true, true)), + "Ctrl+Shift+{nombre} es del terminal, no del programa" + ); + // Mayúscula o minúscula, según venga el layout. + assert!(crate::es_portapapeles(&tecla( + Key::Character(ch.to_uppercase().into()), + true, + true + ))); + } +} + +#[test] +fn ctrl_c_pelado_sigue_siendo_del_programa() { + // La distinción es exactamente el Shift: sin él, Ctrl+C interrumpe. + assert!(!crate::es_portapapeles(&tecla( + Key::Character("c".into()), + true, + false + ))); + // Y sin Ctrl no es nada. + assert!(!crate::es_portapapeles(&tecla( + Key::Character("c".into()), + false, + true + ))); + // Una letra cualquiera con Ctrl+Shift tampoco: la lista es corta a propósito. + assert!(!crate::es_portapapeles(&tecla( + Key::Character("k".into()), + true, + true + ))); +} diff --git a/02_ruway/shuma/sandbox/shuma-module-shell/src/tests/grupo_bloques_io.rs b/02_ruway/shuma/sandbox/shuma-module-shell/src/tests/grupo_bloques_io.rs new file mode 100644 index 0000000..5186afb --- /dev/null +++ b/02_ruway/shuma/sandbox/shuma-module-shell/src/tests/grupo_bloques_io.rs @@ -0,0 +1,149 @@ +use super::super::*; +use super::*; + + + #[test] + fn write_vuelca_el_bloque_a_un_archivo() { + let dir = tempfile::tempdir().unwrap(); + let file = dir.path().join("salida.txt"); + let mut s = State::new(Source::Local); + s.cwd = PathBuf::from("/"); + // Bloque 3 con dos líneas de stdout. + for t in ["linea uno", "linea dos"] { + let mut l = OutputLine::stdout(t); + l.block = 3; + s.output.push(l); + } + s.input.set_text(&format!(":write %c3 {}", file.display())); + s = update(s, Msg::Key(ev(Key::Named(NamedKey::Enter), None))); + let written = std::fs::read_to_string(&file).expect("archivo escrito"); + assert_eq!(written, "linea uno\nlinea dos\n"); + assert!(s.output.iter().any(|l| l.text.contains("bytes →"))); + } + + #[test] + fn write_sin_ref_usa_el_ultimo_bloque() { + let dir = tempfile::tempdir().unwrap(); + let file = dir.path().join("ultimo.txt"); + let mut s = State::new(Source::Local); + s.cwd = PathBuf::from("/"); + let mut a = OutputLine::stdout("viejo"); + a.block = 1; + s.output.push(a); + let mut b = OutputLine::stdout("reciente"); + b.block = 2; + s.output.push(b); + s.input.set_text(&format!(":write {}", file.display())); + s = update(s, Msg::Key(ev(Key::Named(NamedKey::Enter), None))); + assert_eq!(std::fs::read_to_string(&file).unwrap(), "reciente\n"); + } + + #[test] + fn write_sin_archivo_avisa() { + let mut s = State::new(Source::Local); + let mut l = OutputLine::stdout("algo"); + l.block = 5; + s.output.push(l); + s.input.set_text(":write %c5"); + s = update(s, Msg::Key(ev(Key::Named(NamedKey::Enter), None))); + assert!(s.output.iter().any(|l| l.text.contains("falta el archivo"))); + } + + #[test] + fn write_bloque_sin_stdout_avisa() { + let dir = tempfile::tempdir().unwrap(); + let file = dir.path().join("x.txt"); + let mut s = State::new(Source::Local); + s.input.set_text(&format!(":write %c99 {}", file.display())); + s = update(s, Msg::Key(ev(Key::Named(NamedKey::Enter), None))); + assert!(s.output.iter().any(|l| l.text.contains("no tiene salida"))); + assert!(!file.exists(), "no debe crear el archivo si no hay datos"); + } + + #[test] + fn persist_status_no_miente_sobre_pty_persistente() { + // E4 entregó la persistencia PTY (`:spawn`/`:attach`): el status no debe + // decir que está "pendiente"; debe apuntar a `:spawn`. + let mut s = State::new(Source::Local); + s.input.set_text(":persist"); + s = update(s, Msg::Key(ev(Key::Named(NamedKey::Enter), None))); + let texts: Vec = s.output.iter().map(|l| l.text.clone()).collect(); + assert!(!texts.iter().any(|t| t.contains("pendiente")), "{texts:?}"); + assert!(texts.iter().any(|t| t.contains(":spawn"))); + } + + #[test] + fn diff_compara_dos_bloques() { + let mut s = State::new(Source::Local); + // Bloque 1: a/b/c · Bloque 2: a/B/c/d → -b +B +d. + for t in ["a", "b", "c"] { + let mut l = OutputLine::stdout(t); + l.block = 1; + s.output.push(l); + } + for t in ["a", "B", "c", "d"] { + let mut l = OutputLine::stdout(t); + l.block = 2; + s.output.push(l); + } + s.input.set_text(":diff %c1 %c2"); + s = update(s, Msg::Key(ev(Key::Named(NamedKey::Enter), None))); + let texts: Vec = s.output.iter().map(|l| l.text.clone()).collect(); + assert!(texts.iter().any(|t| t == "- b"), "{texts:?}"); + assert!(texts.iter().any(|t| t == "+ B")); + assert!(texts.iter().any(|t| t == "+ d")); + // Resumen: 2 agregadas (B, d), 1 quitada (b). + assert!(texts.iter().any(|t| t.contains("2+ / 1-"))); + // Contexto: las líneas sin cambios (a, c) aparecen con prefijo " ". + assert!(texts.iter().any(|t| t == " a"), "falta contexto: {texts:?}"); + assert!(texts.iter().any(|t| t == " c")); + } + + #[test] + fn diff_identicos_lo_dice() { + let mut s = State::new(Source::Local); + for b in [1u64, 2] { + for t in ["x", "y"] { + let mut l = OutputLine::stdout(t); + l.block = b; + s.output.push(l); + } + } + s.input.set_text(":diff %c1 %c2"); + s = update(s, Msg::Key(ev(Key::Named(NamedKey::Enter), None))); + assert!(s.output.iter().any(|l| l.text.contains("idénticos"))); + } + + #[test] + fn diff_sin_dos_refs_avisa() { + let mut s = State::new(Source::Local); + s.input.set_text(":diff %c1"); + s = update(s, Msg::Key(ev(Key::Named(NamedKey::Enter), None))); + assert!(s.output.iter().any(|l| l.text.contains("uso: :diff"))); + } + + #[test] + fn yank_copia_el_bloque_y_avisa() { + // El write al clipboard es best-effort (no-op headless); probamos el + // resolver + el aviso con el conteo correcto. + let mut s = State::new(Source::Local); + for t in ["uno", "dos"] { + let mut l = OutputLine::stdout(t); + l.block = 4; + s.output.push(l); + } + s.input.set_text(":yank %c4"); + s = update(s, Msg::Key(ev(Key::Named(NamedKey::Enter), None))); + assert!(s + .output + .iter() + .any(|l| l.text.contains("2 líneas") && l.text.contains("clipboard"))); + } + + #[test] + fn yank_sin_salida_avisa() { + let mut s = State::new(Source::Local); + s.input.set_text(":yank"); + s = update(s, Msg::Key(ev(Key::Named(NamedKey::Enter), None))); + assert!(s.output.iter().any(|l| l.text.contains("no hay salida"))); + } diff --git a/02_ruway/shuma/sandbox/shuma-module-shell/src/tests/grupo_bloques_layout.rs b/02_ruway/shuma/sandbox/shuma-module-shell/src/tests/grupo_bloques_layout.rs new file mode 100644 index 0000000..08c156e --- /dev/null +++ b/02_ruway/shuma/sandbox/shuma-module-shell/src/tests/grupo_bloques_layout.rs @@ -0,0 +1,111 @@ +use super::super::*; +use super::*; + + + #[test] + fn push_output_groups_lines_into_command_blocks() { + let mut s = State::new(Source::Local); + s.push_output(OutputLine::prompt("$ ls")); + s.push_output(OutputLine::stdout("a.txt")); + s.push_output(OutputLine::stdout("b.txt")); + s.push_output(OutputLine::notice("✔ exit 0")); + let b = s.output[0].block; + assert!(b > 0, "el prompt debe abrir un bloque > 0"); + assert!( + s.output.iter().all(|l| l.block == b), + "comando + salida + exit comparten bloque: {:?}", + s.output.iter().map(|l| l.block).collect::>() + ); + // Un segundo prompt abre un bloque nuevo y monotónico. + s.push_output(OutputLine::prompt("$ pwd")); + assert!( + s.output.last().unwrap().block > b, + "el segundo comando abre un bloque nuevo" + ); + } + + #[test] + fn push_in_block_keeps_async_output_out_of_foreground_card() { + // El bug de "output mezclado": un job async drenando en su bloque + // NO debe contaminar el bloque del comando de foreground, aunque + // `current_block` apunte a este último. + let mut s = State::new(Source::Local); + s.push_output(OutputLine::prompt("$ fg")); // abre bloque fg + let fg_block = s.current_block; + let job_block = s.open_block(); // bloque propio del job (current sigue en fg) + s.push_in_block(job_block, OutputLine::stdout("salida del job")); + s.push_output(OutputLine::stdout("salida del fg")); + let bg = s + .output + .iter() + .find(|l| l.text == "salida del job") + .unwrap() + .block; + let fg = s + .output + .iter() + .find(|l| l.text == "salida del fg") + .unwrap() + .block; + assert_eq!(bg, job_block); + assert_eq!(fg, fg_block); + assert_ne!(bg, fg, "job y foreground en cards distintas"); + } + + #[test] + fn body_lines_excludes_prompt_stage_and_status() { + let mut s = State::new(Source::Local); + s.push_output(OutputLine::prompt("$ echo hola | cat")); + let blk = s.current_block; + s.push_output(OutputLine::stage_stdout(0, "intermedia")); + s.push_output(OutputLine::stdout("hola")); + s.push_output(OutputLine::stderr("ups")); + s.push_output(OutputLine::notice("✔ exit 0")); + // Cuerpo = stdout/stderr/notice no-status, sin el prompt ni la etapa. + assert_eq!(body_lines_for_block(&s, blk), vec!["hola", "ups"]); + } + + #[test] + fn finished_command_stays_expanded_then_recedes_on_next() { + let mut s = State::new(Source::Local); + s.cwd = PathBuf::from("/"); + s.input.set_text("seq 1 20"); + s = update(s, Msg::Key(ev(Key::Named(NamedKey::Enter), None))); + let blk = s.current_block; + s = drain_until_idle(s); + // Recién terminado: sigue EXPANDIDO (se ve completo). + assert!(!s.collapsed.contains(&blk), "el comando recién hecho queda expandido"); + // Al correr uno nuevo, el anterior recede (se pliega). + s.input.set_text("echo otra"); + s = update(s, Msg::Key(ev(Key::Named(NamedKey::Enter), None))); + assert!(s.collapsed.contains(&blk), "el anterior se pliega al nacer uno nuevo"); + let nuevo = s.current_block; + assert!(!s.collapsed.contains(&nuevo), "el nuevo nace expandido"); + } + + #[test] + fn command_without_output_does_not_recede() { + // Un comando sin cuerpo (no produjo salida) no se pliega al pasar al + // siguiente — no hay nada que esconder, y se mostrará distinto. + let mut s = State::new(Source::Local); + s.cwd = PathBuf::from("/"); + s.input.set_text("true"); // exit 0, sin stdout + s = update(s, Msg::Key(ev(Key::Named(NamedKey::Enter), None))); + let blk = s.current_block; + s = drain_until_idle(s); + s.input.set_text("echo x"); + s = update(s, Msg::Key(ev(Key::Named(NamedKey::Enter), None))); + assert!(!s.collapsed.contains(&blk), "un comando sin salida no recede"); + } + + #[test] + fn word_range_picks_the_word_under_the_column() { + // "foo bar_baz qux" — col dentro de "bar_baz" selecciona toda la + // palabra (incluye `_`); sobre el espacio no selecciona. La usa el + // doble-click de la superficie de terminal. + let t = "foo bar_baz qux"; + assert_eq!(word_range_at(t, 5), (4, 11)); // dentro de bar_baz + assert_eq!(word_range_at(t, 0), (0, 3)); // foo + assert_eq!(word_range_at(t, 3), (0, 3)); // justo después de foo + assert_eq!(word_range_at(t, 11), (4, 11)); // justo después de bar_baz + } diff --git a/02_ruway/shuma/sandbox/shuma-module-shell/src/tests/grupo_builtins.rs b/02_ruway/shuma/sandbox/shuma-module-shell/src/tests/grupo_builtins.rs new file mode 100644 index 0000000..d99fecf --- /dev/null +++ b/02_ruway/shuma/sandbox/shuma-module-shell/src/tests/grupo_builtins.rs @@ -0,0 +1,110 @@ +use super::super::*; +use super::*; + + + #[test] + fn id_is_stable() { + assert_eq!(ID, "shell"); + } + + #[test] + fn placeholder_state_constructs() { + let s = State::new(Source::Local); + assert!(s.output.is_empty()); + assert!(s.cwd.is_absolute() || s.cwd == PathBuf::from("/")); + } + + #[test] + fn pwd_builtin_writes_cwd() { + let mut s = State::new(Source::Local); + s.input.set_text("pwd"); + s = update(s, Msg::Key(ev(Key::Named(NamedKey::Enter), None))); + assert!(s.output.iter().any(|l| l.text.starts_with("$ pwd"))); + assert!(s.output.iter().any(|l| l.kind == OutputKind::Stdout)); + } + + #[test] + fn clear_builtin_empties_output() { + let mut s = State::new(Source::Local); + s.input.set_text("pwd"); + s = update(s, Msg::Key(ev(Key::Named(NamedKey::Enter), None))); + assert!(!s.output.is_empty()); + s.input.set_text("clear"); + s = update(s, Msg::Key(ev(Key::Named(NamedKey::Enter), None))); + assert!(s.output.is_empty()); + } + + #[test] + fn export_sets_session_env_var() { + let mut s = State::new(Source::Local); + s.input.set_text("export SHUMA_TEST_EXPORT_A=hola"); + s = update(s, Msg::Key(ev(Key::Named(NamedKey::Enter), None))); + assert_eq!(std::env::var("SHUMA_TEST_EXPORT_A").as_deref(), Ok("hola")); + assert!(s.output.iter().any(|l| l.text.contains("export SHUMA_TEST_EXPORT_A=hola"))); + std::env::remove_var("SHUMA_TEST_EXPORT_A"); + } + + #[test] + fn export_expands_existing_vars() { + std::env::set_var("SHUMA_TEST_EXPORT_B", "base"); + let mut s = State::new(Source::Local); + s.input.set_text("export SHUMA_TEST_EXPORT_B=$SHUMA_TEST_EXPORT_B:extra"); + s = update(s, Msg::Key(ev(Key::Named(NamedKey::Enter), None))); + assert_eq!(std::env::var("SHUMA_TEST_EXPORT_B").as_deref(), Ok("base:extra")); + std::env::remove_var("SHUMA_TEST_EXPORT_B"); + } + + #[test] + fn bare_assignment_persists_to_session() { + // `NAME=valor` sin comando se aplica a la sesión (no a un bash efímero). + let mut s = State::new(Source::Local); + s.input.set_text("SHUMA_TEST_EXPORT_C=mundo"); + s = update(s, Msg::Key(ev(Key::Named(NamedKey::Enter), None))); + assert_eq!(std::env::var("SHUMA_TEST_EXPORT_C").as_deref(), Ok("mundo")); + std::env::remove_var("SHUMA_TEST_EXPORT_C"); + } + + #[test] + fn unset_removes_session_env_var() { + std::env::set_var("SHUMA_TEST_EXPORT_D", "x"); + let mut s = State::new(Source::Local); + s.input.set_text("unset SHUMA_TEST_EXPORT_D"); + s = update(s, Msg::Key(ev(Key::Named(NamedKey::Enter), None))); + assert!(std::env::var_os("SHUMA_TEST_EXPORT_D").is_none()); + } + + #[test] + fn assignment_with_command_is_not_intercepted() { + // `FOO=bar cmd` NO debe persistir FOO en la sesión: es env de un solo + // comando, va a bash. Verificamos que NO se setea en el proceso. + use crate::update::es_asignacion_pura; + assert!(es_asignacion_pura("PATH=/a:/b")); + assert!(es_asignacion_pura("A=1 B=2")); + assert!(!es_asignacion_pura("A=1 echo hola")); + assert!(!es_asignacion_pura("echo hola")); + assert!(!es_asignacion_pura("")); + } + + #[test] + fn clear_msg_empties_output() { + let mut s = State::new(Source::Local); + s.output.push(OutputLine::stdout("hola")); + s = update(s, Msg::Clear); + assert!(s.output.is_empty()); + } + + #[test] + fn cd_to_root_changes_cwd() { + let mut s = State::new(Source::Local); + s.input.set_text("cd /"); + s = update(s, Msg::Key(ev(Key::Named(NamedKey::Enter), None))); + assert_eq!(s.cwd, PathBuf::from("/")); + } + + #[test] + fn cd_to_nonexistent_logs_error() { + let mut s = State::new(Source::Local); + s.input.set_text("cd /nope/this/does/not/exist"); + s = update(s, Msg::Key(ev(Key::Named(NamedKey::Enter), None))); + assert!(s.output.iter().any(|l| l.text.starts_with("cd:"))); + } diff --git a/02_ruway/shuma/sandbox/shuma-module-shell/src/tests/grupo_completion_history.rs b/02_ruway/shuma/sandbox/shuma-module-shell/src/tests/grupo_completion_history.rs new file mode 100644 index 0000000..a633fa1 --- /dev/null +++ b/02_ruway/shuma/sandbox/shuma-module-shell/src/tests/grupo_completion_history.rs @@ -0,0 +1,87 @@ +use super::super::*; +use super::*; +use llimphi_ui::Modifiers; + + + #[test] + fn tab_completion_inserts_unique_candidate() { + // Si el prefijo tiene un único match, Tab debe completarlo. + let mut s = State::new(Source::Local); + s.input.set_text("ec"); + // Forzar un source determinístico para no depender de $PATH. + struct Fixed; + impl shuma_line::CompletionSource for Fixed { + fn commands(&self) -> Vec { + vec!["echo".into()] + } + fn paths(&self, _: &str) -> Vec { + vec![] + } + } + s.completion_source = Arc::new(ShellSource::new(&s.cwd)); + // Bypassear: aplicamos completion manualmente con el Fixed source, + // ya que apply_completion_msg usa s.completion_source. + let comp = s.input.complete(&Fixed); + let candidate = comp.candidates.first().cloned().unwrap_or_default(); + s.input.apply_completion(&comp, &candidate); + assert_eq!(s.input.text(), "echo"); + } + + #[test] + fn arrow_up_walks_history_backwards() { + let mut s = State::new(Source::Local); + s.cwd = PathBuf::from("/"); + // Historial aislado: si no, entradas de tests paralelos se cuelan y el + // ArrowUp camina sobre comandos ajenos. + s.history = Arc::new(Mutex::new( + shuma_history::History::open(std::path::PathBuf::from("/dev/null")).unwrap(), + )); + // Insertar entradas a mano vía History (no via run_submitted, que + // dispararía procesos reales). + { + let mut h = s.history.lock().unwrap(); + let _ = h.append(shuma_history::Entry::new("uno", "/", 1)); + let _ = h.append(shuma_history::Entry::new("dos", "/", 2)); + } + s = update(s, Msg::Key(ev(Key::Named(NamedKey::ArrowUp), None))); + assert_eq!(s.input.text(), "dos"); + s = update(s, Msg::Key(ev(Key::Named(NamedKey::ArrowUp), None))); + assert_eq!(s.input.text(), "uno"); + s = update(s, Msg::Key(ev(Key::Named(NamedKey::ArrowDown), None))); + assert_eq!(s.input.text(), "dos"); + } + + #[test] + fn ctrl_r_opens_search_overlay() { + let mut s = State::new(Source::Local); + let ctrl_r = KeyEvent { + key: Key::Character("r".into()), + state: KeyState::Pressed, + text: Some("r".into()), + modifiers: Modifiers { + ctrl: true, + ..Default::default() + }, + repeat: false, + }; + s = update(s, Msg::Key(ctrl_r)); + assert!(s.history_search.is_some()); + } + + #[test] + fn ghost_extends_from_history_when_prefix_matches() { + let mut s = State::new(Source::Local); + // Historial aislado: evita que un `cargo …` ajeno (de otro test + // paralelo) gane el match del ghost y cambie el sufijo esperado. + s.history = Arc::new(Mutex::new( + shuma_history::History::open(std::path::PathBuf::from("/dev/null")).unwrap(), + )); + { + let mut h = s.history.lock().unwrap(); + let _ = h.append(shuma_history::Entry::new("cargo build --release", "/", 1)); + } + s.input.set_text("cargo bu"); + let g = current_ghost(&s); + // Devuelve el sufijo que falta para llegar a la línea histórica. + assert_eq!(g.as_deref(), Some("ild --release")); + } diff --git a/02_ruway/shuma/sandbox/shuma-module-shell/src/tests/grupo_completion_layers.rs b/02_ruway/shuma/sandbox/shuma-module-shell/src/tests/grupo_completion_layers.rs new file mode 100644 index 0000000..42228f1 --- /dev/null +++ b/02_ruway/shuma/sandbox/shuma-module-shell/src/tests/grupo_completion_layers.rs @@ -0,0 +1,474 @@ +use super::super::*; +use super::*; +use llimphi_ui::Modifiers; + + + fn fake_completion(cands: &[&str], start: usize, end: usize) -> shuma_line::Completion { + shuma_line::Completion { + kind: shuma_line::CompletionKind::Command, + candidates: cands.iter().map(|s| s.to_string()).collect(), + replace_start: start, + replace_end: end, + } + } + + #[test] + fn completion_tab_accepts_highlighted() { + // Con popup vivo, Tab acepta el candidato resaltado (no cicla). + let mut s = State::new(Source::Local); + s.input.set_text("ca"); + s.completion = Some(fake_completion(&["cargo", "cat", "cal"], 0, 2)); + s.completion_index = 1; // "cat" + s = update(s, Msg::Key(ev(Key::Named(NamedKey::Tab), None))); + assert_eq!(s.input.text(), "cat", "Tab aplica el resaltado"); + assert!(s.completion.is_none(), "y cierra el popup"); + } + + #[test] + fn layered_completion_offers_full_history_lines() { + // Tier 2: una línea completa del historial que extiende lo tipeado. + use crate::update::build_extra_suggestions; + let mut s = State::new(Source::Local); + let cwd = s.cwd.to_string_lossy().to_string(); + { + let mut h = s.history.lock().unwrap(); + h.append(shuma_history::Entry::new("cargo build --release", &cwd, 1)) + .unwrap(); + } + s.input.set_text("cargo b"); + let extra = build_extra_suggestions(&s); + assert!( + extra.iter().any(|e| e.insert == "cargo build --release" + && matches!(e.kind, crate::types::SugKind::Line)), + "la línea completa del historial aparece como sugerencia de tier 2" + ); + } + + #[test] + fn app_candidate_appears_and_launches() { + // Tier 0: una app cuyo nombre empieza con lo tipeado aparece con su + // ícono, y aceptarla la LANZA (deja app_launch), no inserta texto. + use crate::types::{LaunchableApp, SugKind}; + let mut s = State::new(Source::Local); + s.apps = vec![ + LaunchableApp::new("✶ Pluma", "pluma-app-llimphi").con_icono(Some("firefox".into())), + LaunchableApp::new("Media Tube", "media-tube"), + ]; + // Escribir "plu" abre el popup con Pluma como candidato-app (aunque no + // haya binario del PATH que complete). + for c in "plu".chars() { + let mut buf = [0u8; 4]; + s = update(s, Msg::Key(ev(Key::Character(c.to_string().into()), Some(c.encode_utf8(&mut buf))))); + } + let sug = s + .completion_extra + .iter() + .find(|e| e.kind == SugKind::App) + .expect("Pluma aparece como candidato-app"); + assert_eq!(sug.display, "Pluma", "muestra el nombre limpio, sin el glifo viejo"); + assert_eq!(sug.icon.as_deref(), Some("firefox"), "transporta el hint de ícono"); + // El candidato-app está resaltado por default (tier 0 primero) sin que + // haya una elección deliberada todavía (`completion_navegado` falso). + // Como "plu" es prefijo propio de «Pluma», Enter YA lo lanzaría (ver + // `completion_enter_launches_app_default_when_completing`); aquí sólo + // verificamos la ruta explícita por flechas. + assert!(highlighted_is_app(&s)); + assert!( + !s.completion_navegado, + "sin flechas/Tab no hay elección deliberada" + ); + // Navegar hasta él (bajar y volver = misma fila, pero ya ELEGIDA) → + // ahora Enter sí lo lanza. + s = update(s, Msg::Key(ev(Key::Named(NamedKey::ArrowDown), None))); + s = update(s, Msg::Key(ev(Key::Named(NamedKey::ArrowUp), None))); + assert!(highlighted_is_app(&s)); + assert!(s.completion_navegado, "navegar marca la elección"); + s = update(s, Msg::Key(ev(Key::Named(NamedKey::Enter), None))); + assert_eq!(s.take_app_launch().as_deref(), Some("pluma-app-llimphi")); + assert!(s.input.text().is_empty(), "lanzar limpia la línea"); + assert!(s.completion.is_none(), "y cierra el popup"); + } + + #[test] + fn layered_completion_offers_command_groups() { + // Tier 3: un grupo guardado cuyo primer comando empieza con el texto. + use crate::update::build_extra_suggestions; + let mut s = State::new(Source::Local); + s.groups.push(crate::types::CommandGroup { + name: "deploy".into(), + lines: vec!["cargo build".into(), "scp x y".into()], + }); + s.input.set_text("cargo"); + let extra = build_extra_suggestions(&s); + let g = extra + .iter() + .find(|e| matches!(e.kind, crate::types::SugKind::Group)); + assert!(g.is_some(), "el grupo aparece como sugerencia de tier 3"); + assert_eq!(g.unwrap().insert, "cargo build && scp x y"); + } + + #[test] + fn accepting_a_group_suggestion_inserts_the_whole_sequence() { + // Aceptar una sugerencia de grupo reemplaza TODA la línea por la + // secuencia (su rango propio 0..len), no sólo el token. + let mut s = State::new(Source::Local); + s.input.set_text("cargo"); + s.completion = Some(fake_completion(&[], 0, 0)); // popup abierto, sin tokens + s.completion_extra = vec![crate::types::Suggestion { + display: "⊞ deploy".into(), + insert: "cargo build && scp x y".into(), + replace_start: 0, + replace_end: 5, + kind: crate::types::SugKind::Group, + icon: None, + }]; + s.completion_index = 0; // el primer (y único) extra + s = crate::update::accept_completion(s); + assert_eq!(s.input.text(), "cargo build && scp x y"); + assert!(s.completion.is_none(), "y cierra el popup"); + } + + #[test] + fn ctrl_a_selects_whole_input_line() { + let mut s = State::new(Source::Local); + s.input.set_text("git status"); + let ctrl_a = KeyEvent { + key: Key::Character("a".into()), + state: KeyState::Pressed, + text: Some("a".into()), + modifiers: Modifiers { ctrl: true, ..Default::default() }, + repeat: false, + }; + s = update(s, Msg::Key(ctrl_a)); + assert_eq!(s.input.selected_text().as_deref(), Some("git status")); + } + + #[test] + fn shift_arrow_extends_input_selection() { + let mut s = State::new(Source::Local); + s.input.set_text("abc"); + // Shift+Left desde el final selecciona el último char. + let shift_left = KeyEvent { + key: Key::Named(NamedKey::ArrowLeft), + state: KeyState::Pressed, + text: None, + modifiers: Modifiers { shift: true, ..Default::default() }, + repeat: false, + }; + s = update(s, Msg::Key(shift_left)); + assert_eq!(s.input.selected_text().as_deref(), Some("c")); + } + + #[test] + fn rank_completion_by_usage_orders_by_history() { + let mut s = State::new(Source::Local); + // Historial aislado (el real del usuario contaminaría el ranking). + s.history = Arc::new(Mutex::new( + shuma_history::History::open(std::path::PathBuf::from("/dev/null")).unwrap(), + )); + { + let mut h = s.history.lock().unwrap(); + // Líneas distintas (el dedup colapsa repetidas consecutivas). + let _ = h.append(shuma_history::Entry::new("cat a", "/", 0)); + let _ = h.append(shuma_history::Entry::new("cargo build", "/", 1)); + let _ = h.append(shuma_history::Entry::new("cat b", "/", 2)); + let _ = h.append(shuma_history::Entry::new("cat c", "/", 3)); + } + let mut comp = fake_completion(&["cargo", "cat", "cal"], 0, 2); + rank_completion_by_usage(&s, &mut comp); + assert_eq!(comp.candidates[0], "cat", "el más usado primero"); + assert_eq!(comp.candidates[1], "cargo"); + assert_eq!(comp.candidates[2], "cal", "sin uso, al final"); + } + + #[test] + fn pattern_detection_window_excludes_old_and_keeps_recent() { + // `refresh_patterns` corre en CADA submit; sobre un historial grande + // `detect_patterns` era O(n) con constante alta (~150 ms con 5 k + // entradas) y bloqueaba el `update`. Se acota a la ventana reciente. + // Aquí verificamos la SEMÁNTICA: un patrón que sólo vive en lo viejo + // (más allá de la ventana) NO se detecta; uno reciente sí. + let mut s = State::new(Source::Local); + s.history = Arc::new(Mutex::new( + shuma_history::History::open(std::path::PathBuf::from("/dev/null")).unwrap(), + )); + { + let mut h = s.history.lock().unwrap(); + // Patrón viejo (2 ocurrencias) al principio del todo. + for _ in 0..2 { + let _ = h.append(shuma_history::Entry::new("viejocmd", "/", 0)); + let _ = h.append(shuma_history::Entry::new("viejodos", "/", 0)); + } + // Relleno único que empuja lo viejo fuera de la ventana (cada línea + // distinta: ni dedup ni patrón espurio). + for i in 0..1600u32 { + let _ = h.append(shuma_history::Entry::new(format!("relleno{i}"), "/", 0)); + } + // Patrón reciente (2 ocurrencias) al final. + for _ in 0..2 { + let _ = h.append(shuma_history::Entry::new("nuevocmd", "/", 0)); + let _ = h.append(shuma_history::Entry::new("nuevodos", "/", 0)); + } + } + refresh_patterns(&mut s); + let firmas: Vec<&Vec> = s.patterns.iter().map(|p| &p.signature).collect(); + assert!( + firmas.iter().any(|f| f.contains(&"nuevocmd".to_string())), + "el patrón reciente debe detectarse: {firmas:?}" + ); + assert!( + !firmas.iter().any(|f| f.contains(&"viejocmd".to_string())), + "el patrón fuera de la ventana NO debe detectarse: {firmas:?}" + ); + } + + #[test] + fn el_corpus_ya_no_se_acota_por_antiguedad() { + // L4 / Fase 1 de SDD-HISTORIAL: ANTES el corpus del ghost se acotaba a las + // 2.000 entradas más recientes, y un comando muy usado desaparecía del + // autocompletado por haber tecleado mucho después (medido en el historial + // real: el último uso del comando más frecuente estaba en la 28.883 de + // 32.851). Ahora el corpus es deduplicado y cubre todo el historial, así + // que la coincidencia vieja SIGUE ghosteando. + let mut s = State::new(Source::Local); + s.history = Arc::new(Mutex::new( + shuma_history::History::open(std::path::PathBuf::from("/dev/null")).unwrap(), + )); + s.cwd = PathBuf::from("/"); + { + let mut h = s.history.lock().unwrap(); + // Única coincidencia, al principio del todo. + let _ = h.append(shuma_history::Entry::new("zzfantasma --bandera", "/", 0)); + // Relleno que la habría empujado fuera de la ventana vieja (2.000). + for i in 0..2200u32 { + let _ = h.append(shuma_history::Entry::new(format!("relleno{i}"), "/", 0)); + } + } + s.input.set_text("zzfantasma"); + assert_eq!( + current_ghost(&s).as_deref(), + Some(" --bandera"), + "el corpus deduplicado tiene que alcanzar más allá de la ventana vieja" + ); + } + + #[test] + fn completion_arrows_cycle_both_ways() { + let mut s = State::new(Source::Local); + s.completion = Some(fake_completion(&["a", "b", "c"], 0, 0)); + s.completion_index = 0; + s = update(s, Msg::Key(ev(Key::Named(NamedKey::ArrowUp), None))); + assert_eq!(s.completion_index, 2, "↑ desde 0 va al último"); + s = update(s, Msg::Key(ev(Key::Named(NamedKey::ArrowDown), None))); + assert_eq!(s.completion_index, 0); + } + + #[test] + fn completion_enter_submits_not_accepts() { + // Con popup vivo, Enter ejecuta el comando como está (no acepta el + // resaltado): el popup es sugerencia, no modal. + let mut s = State::new(Source::Local); + s.cwd = PathBuf::from("/"); + s.input.set_text("ca"); + s.completion = Some(fake_completion(&["cargo", "cat"], 0, 2)); + s.completion_index = 1; // "cat" + s = update(s, Msg::Key(ev(Key::Named(NamedKey::Enter), None))); + assert!(s.completion.is_none(), "Enter cierra el popup"); + assert!( + s.input.text().is_empty(), + "ejecutó (limpió el input) en vez de aplicar 'cat'" + ); + s = drain_until_idle(s); + } + + #[test] + fn completion_enter_accepts_when_navigated() { + // Tras NAVEGAR el popup (elección deliberada), Enter ACEPTA el resaltado + // —lo inserta— en vez de ejecutar lo tipeado. Es la contraparte del test + // de arriba: "marcado (fuerte) ⟺ Enter lo aplica". + let mut s = State::new(Source::Local); + s.cwd = PathBuf::from("/"); + s.input.set_text("ca"); + s.completion = Some(fake_completion(&["cargo", "cat"], 0, 2)); + s.completion_index = 0; + // ↓ mueve el resaltado a "cat" y marca la elección deliberada. + s = update(s, Msg::Key(ev(Key::Named(NamedKey::ArrowDown), None))); + assert!(s.completion_navegado, "navegar marca la elección"); + assert_eq!(s.completion_index, 1); + s = update(s, Msg::Key(ev(Key::Named(NamedKey::Enter), None))); + assert_eq!(s.input.text(), "cat", "Enter aplicó el resaltado tras navegar"); + assert!(s.completion.is_none(), "y cerró el popup"); + } + + #[test] + fn tab_completes_the_visible_ghost() { + // Lo que se ve tenue tras el cursor (el ghost) es lo que Tab completa: + // Tab ya no abre un menú ajeno ni queda en un Tab muerto. + let mut s = State::new(Source::Local); + s.cwd = PathBuf::from("/"); + // Historial aislado con la línea que el ghost predice. + s.history = Arc::new(Mutex::new( + shuma_history::History::open(std::path::PathBuf::from("/dev/null")).unwrap(), + )); + { + let mut h = s.history.lock().unwrap(); + h.append(shuma_history::Entry::new("git push origin main", "/", 1)) + .unwrap(); + } + s.input.set_text("git pu"); + assert_eq!( + current_ghost(&s).as_deref(), + Some("sh origin main"), + "el ghost muestra la continuación" + ); + s = update(s, Msg::Key(ev(Key::Named(NamedKey::Tab), None))); + assert_eq!( + s.input.text(), + "git push origin main", + "Tab completó lo que mostraba el ghost" + ); + assert!(s.completion.is_none(), "y no dejó un menú abierto"); + } + + #[test] + fn popup_default_preselects_the_ghost_row() { + // "según el historial, más cerca un comando o un desktop": una app + // también matchea el prefijo y, por tier 0, iría arriba de todo — pero + // si el historial predice un comando, ESE es el default marcado. + use crate::types::LaunchableApp; + use crate::update::{completion_rows, CompRow}; + let mut s = State::new(Source::Local); + s.cwd = PathBuf::from("/"); + s.history = Arc::new(Mutex::new( + shuma_history::History::open(std::path::PathBuf::from("/dev/null")).unwrap(), + )); + { + let mut h = s.history.lock().unwrap(); + h.append(shuma_history::Entry::new("cosmos-cli build", "/", 1)) + .unwrap(); + } + s.apps = vec![LaunchableApp::new("Cosmos", "cosmos-app")]; + for c in "co".chars() { + let mut buf = [0u8; 4]; + s = update( + s, + Msg::Key(ev(Key::Character(c.to_string().into()), Some(c.encode_utf8(&mut buf)))), + ); + } + assert_eq!( + current_ghost(&s).as_deref(), + Some("smos-cli build"), + "el ghost predice la línea del historial" + ); + let sel = completion_rows(&s)[s.completion_index]; + let insert = match sel { + CompRow::Extra(i) => s.completion_extra[i].insert.clone(), + CompRow::Token(i) => s.completion.as_ref().unwrap().candidates[i].clone(), + }; + assert_eq!( + insert, "cosmos-cli build", + "el default marcado sigue al ghost (historial), no a la app tier 0" + ); + assert!(!s.completion_navegado, "sigue siendo sólo el default sugerido"); + } + + #[test] + fn completion_enter_launches_app_default_when_completing() { + // El bug reportado: escribir "nah" resalta la app «nahual», pero Enter + // ejecutaba "nah" tal cual y había que forzar la aceptación con las + // flechas. Ahora, como lo tipeado es prefijo propio del nombre de la + // app resaltada por default, Enter la LANZA sin ese paso extra. + use crate::types::LaunchableApp; + let mut s = State::new(Source::Local); + s.cwd = PathBuf::from("/"); + s.apps = vec![LaunchableApp::new("nahual", "nahual")]; + for c in "nah".chars() { + let mut buf = [0u8; 4]; + s = update( + s, + Msg::Key(ev(Key::Character(c.to_string().into()), Some(c.encode_utf8(&mut buf)))), + ); + } + assert!(highlighted_is_app(&s), "la app queda resaltada por default"); + assert!(!s.completion_navegado, "sin haber tocado las flechas"); + s = update(s, Msg::Key(ev(Key::Named(NamedKey::Enter), None))); + assert_eq!( + s.take_app_launch().as_deref(), + Some("nahual"), + "Enter lanza la app resaltada sin el paso extra de las flechas" + ); + assert!(s.input.text().is_empty(), "lanzar limpia la línea"); + assert!(s.completion.is_none(), "y cierra el popup"); + } + + #[test] + fn completion_enter_does_not_hijack_full_command() { + // La contraparte: si ya escribiste el comando ENTERO ("zznotrealcmd"), la app con + // ese mismo nombre no lo secuestra — lo tipeado no es prefijo PROPIO, así + // que Enter ejecuta el comando en vez de lanzar el .desktop. Estado + // armado a mano (una app resaltada con match EXACTO) para no depender de + // qué binarios `vim` haya en el PATH de la máquina de tests. + let mut s = State::new(Source::Local); + s.cwd = PathBuf::from("/"); + s.input.set_text("zznotrealcmd"); + s.completion = Some(fake_completion(&[], 0, 0)); // popup abierto, sin tokens + s.completion_extra = vec![crate::types::Suggestion { + display: "zznotrealcmd".into(), + insert: "zznotrealcmd".into(), + replace_start: 0, + replace_end: 11, + kind: crate::types::SugKind::App, + icon: None, + }]; + s.completion_index = 0; + assert!(highlighted_is_app(&s), "la app resaltada, pero es match exacto"); + s = update(s, Msg::Key(ev(Key::Named(NamedKey::Enter), None))); + assert!( + s.take_app_launch().is_none(), + "Enter NO lanza la app: ejecuta el comando tipeado tal cual" + ); + assert!(s.completion.is_none(), "Enter cierra el popup"); + assert!(s.input.text().is_empty(), "y ejecutó (limpió el input)"); + s = drain_until_idle(s); + } + + #[test] + fn completion_escape_closes_without_change() { + let mut s = State::new(Source::Local); + s.input.set_text("ca"); + s.completion = Some(fake_completion(&["cargo", "cat"], 0, 2)); + s = update(s, Msg::Key(ev(Key::Named(NamedKey::Escape), None))); + assert!(s.completion.is_none()); + assert_eq!(s.input.text(), "ca", "Esc no toca el texto"); + } + + #[test] + fn typing_processes_key_and_refreshes_completion() { + // Tipear procesa la tecla y refresca el popup en vivo (puede reabrir + // con nuevos candidatos según el entorno; lo determinístico es que la + // tecla entró al input). + let mut s = State::new(Source::Local); + s.input.set_text("ca"); + s.completion = Some(fake_completion(&["cargo", "cat"], 0, 2)); + let key = KeyEvent { + key: Key::Character("r".into()), + state: KeyState::Pressed, + text: Some("r".into()), + modifiers: Modifiers::default(), + repeat: false, + }; + s = update(s, Msg::Key(key)); + assert_eq!(s.input.text(), "car", "la tecla se procesa normal"); + } + + #[test] + fn toggle_stage_flips_expanded_set() { + let mut s = State::new(Source::Local); + s = update(s, Msg::ToggleStage { block: 2, stage: 0 }); + assert!(s.expanded_stages.contains(&(2, 0)), "primer toggle despliega"); + s = update(s, Msg::ToggleStage { block: 2, stage: 0 }); + assert!( + !s.expanded_stages.contains(&(2, 0)), + "segundo toggle repliega" + ); + } diff --git a/02_ruway/shuma/sandbox/shuma-module-shell/src/tests/grupo_config_alias.rs b/02_ruway/shuma/sandbox/shuma-module-shell/src/tests/grupo_config_alias.rs new file mode 100644 index 0000000..8cad4af --- /dev/null +++ b/02_ruway/shuma/sandbox/shuma-module-shell/src/tests/grupo_config_alias.rs @@ -0,0 +1,106 @@ +use super::super::*; +use super::*; + + + #[test] + fn limit_builtin_sets_capture_bytes() { + let mut s = State::new(Source::Local); + s.input.set_text(":limit 5"); + s = update(s, Msg::Key(ev(Key::Named(NamedKey::Enter), None))); + assert_eq!(s.capture_limit_bytes, 5 * 1024 * 1024); + assert!(!s.is_running(), "`:limit` no spawnea proceso"); + // `:limit 0` quita el tope. + s.input.set_text(":limit 0"); + s = update(s, Msg::Key(ev(Key::Named(NamedKey::Enter), None))); + assert_eq!(s.capture_limit_bytes, 0); + } + + #[test] + fn spill_builtin_toggles_flag() { + let mut s = State::new(Source::Local); + s.input.set_text(":spill on"); + s = update(s, Msg::Key(ev(Key::Named(NamedKey::Enter), None))); + assert!(s.spill); + s.input.set_text(":spill off"); + s = update(s, Msg::Key(ev(Key::Named(NamedKey::Enter), None))); + assert!(!s.spill); + } + + #[test] + fn sanitize_paste_drops_single_trailing_newline() { + // Pegar "ls -la\n" no debe dejar una línea vacía colgando. + assert_eq!(sanitize_paste("ls -la\n"), "ls -la"); + } + + #[test] + fn sanitize_paste_preserves_interior_newlines() { + // El input es multilínea: pegar un script conserva sus saltos + // (no se colapsa a `;` como el shell GPUI). + assert_eq!(sanitize_paste("ls\npwd\n"), "ls\npwd"); + } + + #[test] + fn sanitize_paste_normalizes_crlf() { + assert_eq!(sanitize_paste("a\r\nb"), "a\nb"); + assert_eq!(sanitize_paste("a\rb"), "a\nb"); + } + + #[test] + fn sanitize_paste_strips_control_chars_and_tabs() { + // ESC (\x1b) y BEL (\x07) se descartan; tab → espacio; los saltos + // de línea sobreviven. + assert_eq!(sanitize_paste("ls\t-la\x1b[X\x07"), "ls -la[X"); + } + + #[test] + fn sanitize_paste_keeps_plain_text() { + assert_eq!(sanitize_paste("echo hola mundo"), "echo hola mundo"); + } + + #[test] + fn alias_from_config_expands_before_run() { + // Un alias del `.shumarc` reemplaza la primera palabra; lo tipeado + // queda en el historial, lo resuelto es lo que se ejecuta. + let mut s = State::new(Source::Local); + s.cwd = PathBuf::from("/"); + s.config + .aliases + .insert("saluda".into(), "echo hola_alias".into()); + s.input.set_text("saluda"); + s = update(s, Msg::Key(ev(Key::Named(NamedKey::Enter), None))); + assert!(s.is_running(), "el alias resuelto debe arrancar un run"); + s = drain_until_idle(s); + let combined: Vec = s.output.iter().map(|l| l.text.clone()).collect(); + assert!( + combined.iter().any(|t| t == "hola_alias"), + "esperaba stdout del alias resuelto en {combined:?}" + ); + // El prompt muestra lo tipeado, no lo resuelto. + assert!(combined.iter().any(|t| t == "$ saluda")); + } + + #[test] + fn alias_can_resolve_to_a_builtin() { + // `alias raiz='cd /'` debe disparar el builtin cd sobre la línea ya + // expandida. + let mut s = State::new(Source::Local); + s.config.aliases.insert("raiz".into(), "cd /".into()); + s.input.set_text("raiz"); + s = update(s, Msg::Key(ev(Key::Named(NamedKey::Enter), None))); + assert_eq!(s.cwd, PathBuf::from("/")); + assert!(!s.is_running(), "cd no spawnea proceso"); + } + + #[test] + fn alias_never_hijacks_meta_command() { + // Un alias declarado con el nombre de un meta-comando no debe + // secuestrarlo: `:limit` sigue siendo el builtin del shell. + let mut s = State::new(Source::Local); + s.config + .aliases + .insert(":limit".into(), "echo secuestrado".into()); + s.input.set_text(":limit 7"); + s = update(s, Msg::Key(ev(Key::Named(NamedKey::Enter), None))); + assert_eq!(s.capture_limit_bytes, 7 * 1024 * 1024); + assert!(!s.is_running(), "el meta no debe ejecutar el alias"); + } diff --git a/02_ruway/shuma/sandbox/shuma-module-shell/src/tests/grupo_cosecha_claude.rs b/02_ruway/shuma/sandbox/shuma-module-shell/src/tests/grupo_cosecha_claude.rs new file mode 100644 index 0000000..b3de2c9 --- /dev/null +++ b/02_ruway/shuma/sandbox/shuma-module-shell/src/tests/grupo_cosecha_claude.rs @@ -0,0 +1,112 @@ +//! Cosecha del log de un PTY skin-claude por ESTABILIDAD POR LÍNEA: +//! determinística, con un transcript SIMULADO (claude real no pinta headless +//! a 24×80 sin auth). Verifica que las líneas selladas (que dejan de cambiar) +//! se cosechan y las que parpadean (caja de input, spinner) quedan de cola. + +use crate::types::TuiSession; + +/// Un `TuiSession` skin-claude alimentado con `texto` (`\n` → `\r\n`). +fn claude_con(texto: &str, rows: u16, cols: u16) -> TuiSession { + let mut tui = TuiSession::new("claude", rows, cols); + tui.process_bytes(texto.replace('\n', "\r\n").as_bytes()); + tui +} + +/// Reposiciona el cursor al home y reescribe el screen entero — como hace Ink +/// al re-renderizar. `\x1b[H` = cursor home, `\x1b[J` = clear hasta el final. +fn repintar(tui: &mut TuiSession, texto: &str) { + let seq = format!("\x1b[H\x1b[J{}", texto.replace('\n', "\r\n")); + tui.process_bytes(seq.as_bytes()); +} + +fn drenar(tui: &mut TuiSession, n: u8) -> Vec { + let mut out = Vec::new(); + for _ in 0..n { + for (l, _) in tui.pre_cosechar_asentado() { + out.push(l); + } + } + out +} + +#[test] +fn lo_sellado_se_cosecha_lo_que_parpadea_queda() { + // Pantalla con conversación estable ARRIBA y una caja de input que + // PARPADEA abajo (el cursor: "▏" ↔ " "). Tras estabilizar, lo de arriba se + // cosecha; la caja parpadeante NO. + let cuerpo = "Bienvenida de Claude\n● hola\n● ¡Hola! ¿En qué te ayudo?\n\n"; + let mut tui = TuiSession::new("claude", 24, 80); + + let mut cosechado = Vec::new(); + for i in 0..8 { + // La última línea (caja de input) alterna su cursor cada drain. + let cursor = if i % 2 == 0 { "▏" } else { " " }; + repintar(&mut tui, &format!("{cuerpo}│ > {cursor} │\n")); + for (l, _) in tui.pre_cosechar_asentado() { + cosechado.push(l); + } + } + assert!( + cosechado.iter().any(|l| l.contains("¡Hola! ¿En qué te ayudo?")), + "la respuesta sellada SÍ se cosecha (era el bug 'no subió')" + ); + assert!( + cosechado.iter().any(|l| l.contains("Bienvenida de Claude")), + "la bienvenida sellada se cosecha" + ); + assert!( + !cosechado.iter().any(|l| l.contains('>')), + "la caja de input que parpadea NO se cosecha (queda de cola)" + ); +} + +#[test] +fn exportacion_monotona_no_reexporta_en_bucle() { + // Una línea vieja que cambia LEVEMENTE (timestamp) no debe re-disparar la + // exportación de todo (era el 'loop infinito'). + let mut tui = TuiSession::new("claude", 24, 80); + let mut total = 0usize; + for i in 0..10 { + // "hace Ns" muta arriba; el resto sellado. Caja parpadea abajo. + let cursor = if i % 2 == 0 { "▏" } else { " " }; + repintar( + &mut tui, + &format!("● respondido hace {i}s\n● contenido fijo\n│ > {cursor} │\n"), + ); + total += tui.pre_cosechar_asentado().len(); + } + // Sin bucle: mucho menos que 10×3 líneas re-exportadas. + assert!(total < 12, "exportación acotada, no en bucle (fueron {total})"); +} + +#[test] +fn menu_modal_no_se_cosecha() { + // El picker de resume (`claude -r`) es un menú interactivo: sus filas se + // aquietan entre navegaciones y la estabilidad las sellaba como historia + // falsa que cambiaba con las flechas (foto del 17-jul). Con el menú a la + // vista, la pre-cosecha se PAUSA entera. + let picker = "Resume session (11 of 50)\n⌕ Search...\n❯ 1. hace 2h · main\n 2. hace 5h · main\nSpace to preview · Esc to cancel\n"; + let mut tui = claude_con(picker, 24, 80); + let cosechado = drenar(&mut tui, 8); + assert!( + cosechado.is_empty(), + "el menú modal no genera cosecha (salió: {cosechado:?})" + ); + // Al elegir una sesión el menú se va y la conversación real SÍ fluye. + for _ in 0..6 { + repintar(&mut tui, "● ¡Hola de nuevo!\n\n│ > ▏ │\n"); + // el repintado idéntico sella; el drain exporta + let _ = &tui; + } + let mut post = Vec::new(); + for _ in 0..6 { + repintar(&mut tui, "● ¡Hola de nuevo!\n\n│ > ▏ │\n"); + for (l, _) in tui.pre_cosechar_asentado() { + post.push(l); + } + } + assert!( + post.iter().any(|l| l.contains("¡Hola de nuevo!")), + "cerrado el menú, la conversación vuelve a cosecharse (salió: {post:?})" + ); +} diff --git a/02_ruway/shuma/sandbox/shuma-module-shell/src/tests/grupo_decorations_graph.rs b/02_ruway/shuma/sandbox/shuma-module-shell/src/tests/grupo_decorations_graph.rs new file mode 100644 index 0000000..c394e63 --- /dev/null +++ b/02_ruway/shuma/sandbox/shuma-module-shell/src/tests/grupo_decorations_graph.rs @@ -0,0 +1,130 @@ +use super::super::*; +use super::*; +use llimphi_ui::Modifiers; + + + #[test] + fn open_decoration_cd_into_a_directory() { + let mut s = State::new(Source::Local); + let target = std::env::temp_dir(); + let kind = shuma_line::DecorationKind::Path { + abs: target.clone(), + is_dir: true, + is_executable: false, + is_symlink: false, + }; + s = update(s, Msg::OpenDecoration(kind)); + // cwd cambia al directorio target (no comparamos canónico — el + // open_decoration acepta el path tal cual viene si es dir). + assert_eq!(s.cwd, target); + } + + #[test] + fn open_decoration_git_sha_prefills_input() { + let mut s = State::new(Source::Local); + let kind = shuma_line::DecorationKind::GitSha("abcdef0123456".into()); + s = update(s, Msg::OpenDecoration(kind)); + assert_eq!(s.input.text(), "git show abcdef0123456"); + } + + #[test] + fn open_decoration_path_executable_prefills_input() { + let mut s = State::new(Source::Local); + let kind = shuma_line::DecorationKind::Path { + abs: PathBuf::from("/usr/bin/ls"), + is_dir: false, + is_executable: true, + is_symlink: false, + }; + s = update(s, Msg::OpenDecoration(kind)); + assert_eq!(s.input.text(), "/usr/bin/ls"); + } + + #[test] + fn dispatch_maps_clear() { + assert!(matches!(dispatch("shell.clear"), Some(Msg::Clear))); + assert!(matches!(dispatch("shell.cancel"), Some(Msg::Cancel))); + assert!(dispatch("desconocido").is_none()); + } + + #[test] + fn contributions_expose_clear_and_cancel_shortcuts() { + let s = State::new(Source::Local); + let c = contributions(&s); + assert!(c.monitors.is_empty()); + let labels: Vec<&str> = c.shortcuts.iter().map(|s| s.label.as_str()).collect(); + assert!(labels.contains(&"Clear"), "{labels:?}"); + assert!(labels.contains(&"Cancel"), "{labels:?}"); + } + + #[test] + fn typing_appends_to_input() { + let mut s = State::new(Source::Local); + // El widget text-input usa apply_key con KeyEvent que incluye texto. + let key = KeyEvent { + key: Key::Character("h".into()), + state: KeyState::Pressed, + text: Some("h".into()), + modifiers: Modifiers::default(), + repeat: false, + }; + s = update(s, Msg::Key(key)); + assert_eq!(s.input.text(), "h"); + } + + #[test] + fn external_command_records_intention_in_graph() { + let mut s = State::new(Source::Local); + s.cwd = PathBuf::from("/"); + assert!(s.intent_graph().is_empty(), "grafo arranca vacío"); + s.input.set_text("echo lienzo"); + s = update(s, Msg::Key(ev(Key::Named(NamedKey::Enter), None))); + assert_eq!( + s.intent_graph().len(), + 1, + "Enter debe registrar el `%c1` en el grafo" + ); + assert_eq!(s.intent_graph().commands()[0].intention, "echo lienzo"); + s = drain_until_idle(s); + let node = &s.intent_graph().commands()[0]; + assert_eq!(node.status, shuma_intent::NodeStatus::Ok); + assert!( + node.output_bytes >= 7, + "esperaba ≥7 bytes (len de 'lienzo\\n'), recibí {}", + node.output_bytes + ); + } + + #[test] + fn failed_command_records_failed_status() { + let mut s = State::new(Source::Local); + s.cwd = PathBuf::from("/"); + s.input.set_text("false"); + s = update(s, Msg::Key(ev(Key::Named(NamedKey::Enter), None))); + s = drain_until_idle(s); + assert_eq!(s.intent_graph().len(), 1); + assert_eq!( + s.intent_graph().commands()[0].status, + shuma_intent::NodeStatus::Failed + ); + } + + #[test] + fn builtin_does_not_register_in_graph() { + let mut s = State::new(Source::Local); + s.input.set_text("pwd"); + s = update(s, Msg::Key(ev(Key::Named(NamedKey::Enter), None))); + assert!( + s.intent_graph().is_empty(), + "builtins no entran al grafo de intenciones" + ); + } + + #[test] + fn insert_at_cursor_appends_into_input() { + let mut s = State::new(Source::Local); + // `set_text` deja el cursor al final, así que `insert` extiende. + s.input.set_text("sort "); + s = update(s, Msg::InsertAtCursor("%p1".into())); + assert_eq!(s.input.text(), "sort %p1"); + } diff --git a/02_ruway/shuma/sandbox/shuma-module-shell/src/tests/grupo_external_rules.rs b/02_ruway/shuma/sandbox/shuma-module-shell/src/tests/grupo_external_rules.rs new file mode 100644 index 0000000..a7f62be --- /dev/null +++ b/02_ruway/shuma/sandbox/shuma-module-shell/src/tests/grupo_external_rules.rs @@ -0,0 +1,142 @@ +use super::super::*; +use super::*; + + + #[test] + fn external_command_captures_stdout() { + let mut s = State::new(Source::Local); + s.cwd = PathBuf::from("/"); + s.input.set_text("echo hola_mundo"); + s = update(s, Msg::Key(ev(Key::Named(NamedKey::Enter), None))); + assert!(s.is_running(), "Enter debe arrancar el run"); + s = drain_until_idle(s); + let combined: Vec = s.output.iter().map(|l| l.text.clone()).collect(); + assert!( + combined.iter().any(|t| t == "hola_mundo"), + "esperaba stdout 'hola_mundo' en {combined:?}" + ); + assert!(combined.iter().any(|t| t == "✔ exit 0")); + } + + #[test] + fn external_command_failure_writes_exit_nonzero() { + let mut s = State::new(Source::Local); + s.cwd = PathBuf::from("/"); + s.input.set_text("false"); + s = update(s, Msg::Key(ev(Key::Named(NamedKey::Enter), None))); + s = drain_until_idle(s); + assert!(s.output.iter().any(|l| l.text.starts_with("✘ exit"))); + } + + #[test] + fn rule_on_exit_nonzero_corre_el_comando_una_vez() { + let mut s = State::new(Source::Local); + s.cwd = PathBuf::from("/"); + s.config.rules.on_exit_nonzero = Some(":jobs".into()); + s.input.set_text("false"); + s = update(s, Msg::Key(ev(Key::Named(NamedKey::Enter), None))); + s = drain_until_idle(s); + // El builtin de la regla (`:jobs`) corrió al fallar `false`… + let veces = s + .output + .iter() + .filter(|l| l.text.contains("sin jobs en background")) + .count(); + // …y sólo una vez (la guarda de re-entrada evita el re-disparo). + assert_eq!(veces, 1, "la regla on_exit_nonzero debe correr exactamente una vez"); + } + + #[test] + fn rule_on_enter_cwd_corre_el_comando() { + let tmp = std::fs::canonicalize(std::env::temp_dir()).unwrap(); + let mut s = State::new(Source::Local); + s.cwd = PathBuf::from("/"); + s.config + .rules + .on_enter_cwd + .insert(tmp.display().to_string(), ":jobs".into()); + s.input.set_text(&format!("cd {}", tmp.display())); + s = update(s, Msg::Key(ev(Key::Named(NamedKey::Enter), None))); + s = drain_until_idle(s); + assert!( + s.output.iter().any(|l| l.text.contains("sin jobs en background")), + "la regla on_enter_cwd debe correr al entrar al directorio" + ); + } + + #[test] + fn ask_builtin_arma_request_y_host_la_toma_una_vez() { + let mut s = State::new(Source::Local); + s.cwd = PathBuf::from("/"); + s.input.set_text(":? listar archivos por tamaño"); + s = update(s, Msg::Key(ev(Key::Named(NamedKey::Enter), None))); + // El builtin armó la petición (Command) y avisó. + let req = s.llm_request.clone().expect("hay petición"); + assert!(matches!(req.kind, crate::LlmKind::Command)); + assert!(req.prompt.contains("listar archivos")); + assert!(s.output.iter().any(|l| l.text.contains("🜲"))); + // El host la toma una sola vez (queda en vuelo). + assert!(s.take_llm_request().is_some()); + assert!(s.llm_inflight); + assert!(s.take_llm_request().is_none()); + } + + #[test] + fn hacer_builtin_arma_request_con_el_catalogo_atipay() { + let mut s = State::new(Source::Local); + s.cwd = PathBuf::from("/"); + s.input.set_text(":haz ve al escritorio 3"); + s = update(s, Msg::Key(ev(Key::Named(NamedKey::Enter), None))); + let req = s.llm_request.clone().expect("hay petición"); + // Es Atipay (responde JSON con el id) y el system prompt trae el catálogo + // por id, incluida la fuente Sistema. + assert!(matches!(req.kind, crate::LlmKind::Atipay)); + assert!(req.prompt.contains("escritorio 3")); + assert!(req.system.contains("mirada.workspace")); + assert!(req.system.contains("sistema.apagar")); + } + + #[test] + fn atipay_result_resuelve_json_a_la_linea_y_no_ejecuta() { + let mut s = State::new(Source::Local); + s.llm_inflight = true; + s = update( + s, + Msg::LlmResult { + kind: crate::LlmKind::Atipay, + ok: true, + text: "{\"id\":\"sistema.apagar\"}".into(), + }, + ); + // atipay armó el comando exacto; va al input, NO se ejecutó. + assert_eq!(s.input.text(), "systemctl poweroff"); + assert!(!s.is_running()); + // Avisó del peligro disruptivo. + assert!(s.output.iter().any(|l| l.text.contains("DISRUPTIVO"))); + } + + #[test] + fn atipay_result_nada_no_toca_el_input() { + let mut s = State::new(Source::Local); + s.llm_inflight = true; + s = update(s, Msg::LlmResult { kind: crate::LlmKind::Atipay, ok: true, text: "nada".into() }); + assert_eq!(s.input.text(), ""); + } + + #[test] + fn llm_result_command_va_al_input_sin_ejecutar() { + let mut s = State::new(Source::Local); + s.llm_inflight = true; + s = update( + s, + Msg::LlmResult { + kind: crate::LlmKind::Command, + ok: true, + text: "`ls -la --sort=size`".into(), + }, + ); + // Backticks limpiados, en el input, NO ejecutado. + assert_eq!(s.input.text(), "ls -la --sort=size"); + assert!(!s.is_running()); + assert!(!s.llm_inflight); + } diff --git a/02_ruway/shuma/sandbox/shuma-module-shell/src/tests/grupo_find.rs b/02_ruway/shuma/sandbox/shuma-module-shell/src/tests/grupo_find.rs new file mode 100644 index 0000000..ae79177 --- /dev/null +++ b/02_ruway/shuma/sandbox/shuma-module-shell/src/tests/grupo_find.rs @@ -0,0 +1,125 @@ +use super::super::*; +use super::*; + + + /// Layout sintético con texto que el find puede matchear. + fn synth_surf_layout_with(lines: &[&str]) -> SurfLayout { + let metrics = llimphi_widget_terminal::TermMetrics { + font_size: 12.0, + line_height: 16.0, + char_width: 8.0, + }; + let mut store = llimphi_widget_terminal::Scrollback::new(0); + for l in lines { + store.push_line(l); + } + let len = store.len(); + SurfLayout { + items_geo: vec![llimphi_widget_terminal::ItemGeo::Lines(0, len)], + scroll_y: 0.0, + viewport_h: 200.0, + metrics, + gutter_w: 30.0, + store: Arc::new(store), + block_ranges: Vec::new(), + } + } + + #[test] + fn find_open_inicializa_estado_vacio() { + let mut s = State::new(Source::Local); + s = update(s, Msg::FindOpen); + let f = s.find.expect("find abierto"); + assert!(f.query.is_empty()); + assert!(f.matches.is_empty()); + assert!(f.current.is_none()); + assert!(!f.case_insensitive); + } + + #[test] + fn find_char_recomputa_y_resalta_el_primer_match() { + let mut s = State::new(Source::Local); + *s.surf_layout.lock().unwrap() = Some(synth_surf_layout_with(&[ + "foo bar baz", + "qux foo quux", + "nada que ver", + ])); + s = update(s, Msg::FindOpen); + s = update(s, Msg::FindChar('f')); + s = update(s, Msg::FindChar('o')); + s = update(s, Msg::FindChar('o')); + let f = s.find.as_ref().expect("find abierto"); + assert_eq!(f.matches.len(), 2); + assert_eq!(f.current, Some(0)); + // La selección debe reflejar el primer match (línea 0, col 0..3). + let sel = s.surf_selection.expect("highlight"); + assert_eq!(sel.anchor, llimphi_widget_terminal::Point::new(0, 0)); + assert_eq!(sel.head, llimphi_widget_terminal::Point::new(0, 3)); + } + + #[test] + fn find_next_y_prev_son_ciclicos() { + let mut s = State::new(Source::Local); + *s.surf_layout.lock().unwrap() = + Some(synth_surf_layout_with(&["aa", "aa", "aa"])); + s = update(s, Msg::FindOpen); + s = update(s, Msg::FindChar('a')); + // 6 matches (2 por línea, no superpuestos). + assert_eq!(s.find.as_ref().unwrap().matches.len(), 6); + s = update(s, Msg::FindNext); + assert_eq!(s.find.as_ref().unwrap().current, Some(1)); + // Prev desde 0 envuelve al último (5). + s = update(s, Msg::FindPrev); + s = update(s, Msg::FindPrev); + assert_eq!(s.find.as_ref().unwrap().current, Some(5)); + } + + #[test] + fn find_toggle_case_re_busca_con_la_nueva_politica() { + let mut s = State::new(Source::Local); + *s.surf_layout.lock().unwrap() = Some(synth_surf_layout_with(&[ + "Hola", "HOLA", "hola", + ])); + s = update(s, Msg::FindOpen); + s = update(s, Msg::FindChar('h')); + s = update(s, Msg::FindChar('o')); + s = update(s, Msg::FindChar('l')); + s = update(s, Msg::FindChar('a')); + // Case sensitive: sólo matchea "hola" (línea 2). + assert_eq!(s.find.as_ref().unwrap().matches.len(), 1); + s = update(s, Msg::FindToggleCase); + // Case insensitive: matchea las 3. + assert_eq!(s.find.as_ref().unwrap().matches.len(), 3); + } + + #[test] + fn find_close_limpia_estado_y_selection_del_match() { + let mut s = State::new(Source::Local); + *s.surf_layout.lock().unwrap() = Some(synth_surf_layout_with(&["foo"])); + s = update(s, Msg::FindOpen); + s = update(s, Msg::FindChar('f')); + s = update(s, Msg::FindChar('o')); + s = update(s, Msg::FindChar('o')); + assert!(s.surf_selection.is_some()); + s = update(s, Msg::FindClose); + assert!(s.find.is_none()); + assert!(s.surf_selection.is_none(), "Esc no deja selección residual del match"); + } + + #[test] + fn find_backspace_re_busca_con_la_query_acortada() { + let mut s = State::new(Source::Local); + *s.surf_layout.lock().unwrap() = + Some(synth_surf_layout_with(&["foo", "foobar"])); + s = update(s, Msg::FindOpen); + for c in "foobar".chars() { + s = update(s, Msg::FindChar(c)); + } + assert_eq!(s.find.as_ref().unwrap().matches.len(), 1); // "foobar" matchea sólo línea 1 + s = update(s, Msg::FindBackspace); + s = update(s, Msg::FindBackspace); + s = update(s, Msg::FindBackspace); + // Query = "foo" → 2 matches. + assert_eq!(s.find.as_ref().unwrap().query, "foo"); + assert_eq!(s.find.as_ref().unwrap().matches.len(), 2); + } diff --git a/02_ruway/shuma/sandbox/shuma-module-shell/src/tests/grupo_groups_reprocess.rs b/02_ruway/shuma/sandbox/shuma-module-shell/src/tests/grupo_groups_reprocess.rs new file mode 100644 index 0000000..49e07dc --- /dev/null +++ b/02_ruway/shuma/sandbox/shuma-module-shell/src/tests/grupo_groups_reprocess.rs @@ -0,0 +1,92 @@ +use super::super::*; +use super::*; + + + #[test] + fn save_group_captures_recent_commands() { + let mut s = State::new(Source::Local); + s.cwd = PathBuf::from("/"); + // Dos comandos reales (no meta) + un :save. + for line in ["echo uno", "echo dos"] { + s.input.set_text(line); + s = update(s, Msg::Key(ev(Key::Named(NamedKey::Enter), None))); + s = drain_until_idle(s); + } + s.input.set_text(":save build"); + s = update(s, Msg::Key(ev(Key::Named(NamedKey::Enter), None))); + assert_eq!(s.groups.len(), 1); + assert_eq!(s.groups[0].name, "build"); + assert_eq!(s.groups[0].lines, vec!["echo uno", "echo dos"]); + // El anchor avanzó: un segundo :save sin comandos nuevos no agrupa. + s.input.set_text(":save vacio"); + s = update(s, Msg::Key(ev(Key::Named(NamedKey::Enter), None))); + assert_eq!(s.groups.len(), 1, "no se crea grupo vacío"); + } + + #[test] + fn run_group_msg_executes_group() { + let mut s = State::new(Source::Local); + s.cwd = PathBuf::from("/"); + s.groups.push(CommandGroup { + name: "g".into(), + lines: vec!["echo desde_panel".into()], + }); + s = update(s, Msg::RunGroup(0)); + s = drain_until_idle(s); + assert!(s.output.iter().any(|l| l.text == "desde_panel")); + // Índice fuera de rango: no-op. + s = update(s, Msg::RunGroup(9)); + assert!(!s.is_running()); + } + + #[test] + fn fkey_runs_saved_group() { + let mut s = State::new(Source::Local); + s.cwd = PathBuf::from("/"); + // F1 sin grupos: no hace nada. + s = update(s, Msg::Key(ev(Key::Named(NamedKey::F1), None))); + assert!(!s.is_running()); + // Guardamos un grupo de un comando y lo corremos con F1. + s.groups.push(CommandGroup { + name: "g".into(), + lines: vec!["echo desde_f1".into()], + }); + s = update(s, Msg::Key(ev(Key::Named(NamedKey::F1), None))); + s = drain_until_idle(s); + assert!(s.output.iter().any(|l| l.text == "desde_f1")); + } + + #[test] + fn reprocess_feeds_block_stdout_as_stdin() { + // Corre `printf "b\\na\\nc\\n"`, arma reprocess sobre su bloque, y + // corre `sort`: debe recibir esa salida por stdin y ordenarla. + let mut s = State::new(Source::Local); + s.cwd = PathBuf::from("/"); + s.input.set_text("printf 'b\\na\\nc\\n'"); + s = update(s, Msg::Key(ev(Key::Named(NamedKey::Enter), None))); + s = drain_until_idle(s); + let src_block = s.output.iter().find(|l| l.text == "b").unwrap().block; + s = update(s, Msg::SetReprocess(src_block)); + assert_eq!(s.reprocess_source, Some(src_block)); + s.input.set_text("sort"); + s = update(s, Msg::Key(ev(Key::Named(NamedKey::Enter), None))); + assert!(s.reprocess_source.is_none(), "el submit consume el reprocess"); + s = drain_until_idle(s); + // La salida de `sort` (en su propio bloque) está ordenada: a,b,c. + let sorted: Vec = s + .output + .iter() + .filter(|l| l.block != src_block && l.kind == OutputKind::Stdout) + .map(|l| l.text.clone()) + .collect(); + assert_eq!(sorted, vec!["a", "b", "c"], "sort recibió el stdin reprocesado"); + } + + #[test] + fn set_reprocess_toggles_off_same_block() { + let mut s = State::new(Source::Local); + s = update(s, Msg::SetReprocess(3)); + assert_eq!(s.reprocess_source, Some(3)); + s = update(s, Msg::SetReprocess(3)); + assert_eq!(s.reprocess_source, None, "re-armar el mismo bloque desarma"); + } diff --git a/02_ruway/shuma/sandbox/shuma-module-shell/src/tests/grupo_ia.rs b/02_ruway/shuma/sandbox/shuma-module-shell/src/tests/grupo_ia.rs new file mode 100644 index 0000000..8a3b86d --- /dev/null +++ b/02_ruway/shuma/sandbox/shuma-module-shell/src/tests/grupo_ia.rs @@ -0,0 +1,227 @@ +use super::super::*; +use super::*; + + + #[test] + fn explica_arma_request_text_con_la_salida_del_bloque() { + let mut s = State::new(Source::Local); + s.cwd = PathBuf::from("/"); + // Sembramos un bloque 7 con stdout. + let mut l = OutputLine::stdout("error: algo falló"); + l.block = 7; + s.output.push(l); + s.input.set_text(":explica %c7"); + s = update(s, Msg::Key(ev(Key::Named(NamedKey::Enter), None))); + let req = s.llm_request.clone().expect("hay petición"); + assert!(matches!(req.kind, crate::LlmKind::Text)); + assert!(req.prompt.contains("algo falló")); + assert!(req.prompt.contains("%c7")); + // La respuesta abrirá su propio bloque referenciable. + assert!(s.llm_block_label.as_deref().unwrap_or("").contains("%c7")); + } + + #[test] + fn explica_tambien_ve_stderr_y_salida_de_ia() { + // `gather_block_text` recoge stdout + stderr + IA, no sólo stdout: una + // explicación de un build fallido necesita los errores (que van a stderr). + let mut s = State::new(Source::Local); + s.cwd = PathBuf::from("/"); + let mut err = OutputLine::stderr("error[E0308]: mismatched types"); + err.block = 7; + s.output.push(err); + s.input.set_text(":explica %c7"); + s = update(s, Msg::Key(ev(Key::Named(NamedKey::Enter), None))); + let req = s.llm_request.clone().expect("hay petición pese a ser stderr"); + assert!(req.prompt.contains("E0308")); + } + + #[test] + fn filtra_arma_request_y_etiqueta_el_bloque() { + let mut s = State::new(Source::Local); + s.cwd = PathBuf::from("/"); + for t in ["info: ok", "ERROR: boom", "info: listo"] { + let mut l = OutputLine::stdout(t); + l.block = 4; + s.output.push(l); + } + s.input.set_text(":filtra %c4 sólo los errores"); + s = update(s, Msg::Key(ev(Key::Named(NamedKey::Enter), None))); + let req = s.llm_request.clone().expect("hay petición de filtro"); + assert!(matches!(req.kind, crate::LlmKind::Text)); + // La instrucción y la salida viajan en el prompt. + assert!(req.prompt.contains("sólo los errores")); + assert!(req.prompt.contains("ERROR: boom")); + // El bloque de respuesta queda etiquetado con la instrucción + la fuente. + let label = s.llm_block_label.as_deref().unwrap_or(""); + assert!(label.contains("filtra") && label.contains("%c4"), "{label}"); + } + + #[test] + fn filtra_sin_instruccion_avisa() { + let mut s = State::new(Source::Local); + let mut l = OutputLine::stdout("algo"); + l.block = 2; + s.output.push(l); + s.input.set_text(":filtra %c2"); + s = update(s, Msg::Key(ev(Key::Named(NamedKey::Enter), None))); + assert!(s.output.iter().any(|l| l.text.contains("falta la instrucción"))); + assert!(s.llm_request.is_none()); + } + + #[test] + fn respuesta_de_ia_aterriza_en_su_bloque_y_es_redirigible() { + // Simulamos el ciclo: el builtin dejó un label pendiente; llega la + // respuesta del LLM → abre bloque propio con líneas `Ai`. Después esa + // salida de IA debe ser recogible por los redireccionadores (`:yank`). + let mut s = State::new(Source::Local); + s.llm_inflight = true; + s.llm_block_label = Some("🜲 :explica %c1".to_string()); + s = update( + s, + Msg::LlmResult { + kind: crate::LlmKind::Text, + ok: true, + text: "Resumen: todo bien.\nNo hay errores.".into(), + }, + ); + // Abrió un bloque nuevo (Prompt) con dos líneas Ai. + let ai_block = s + .output + .iter() + .find(|l| l.kind == OutputKind::Prompt && l.text.contains("explica")) + .map(|l| l.block) + .expect("se abrió un bloque para la respuesta"); + let ai_lines: Vec<&OutputLine> = s + .output + .iter() + .filter(|l| l.block == ai_block && l.kind == OutputKind::Ai) + .collect(); + assert_eq!(ai_lines.len(), 2); + assert!(!s.llm_inflight); + assert!(s.llm_block_label.is_none(), "el label se consumió"); + // La salida de IA es redirigible: `:yank` de ese bloque la recoge. + s.input.set_text(&format!(":yank %c{ai_block}")); + s = update(s, Msg::Key(ev(Key::Named(NamedKey::Enter), None))); + assert!(s + .output + .iter() + .any(|l| l.text.contains("2 líneas") && l.text.contains("clipboard"))); + } + + #[test] + fn compara_coteja_dos_bloques_estilo_pluma() { + // El cotejo de pluma empareja líneas parecidas (no diff exacto): ancla + // las idénticas, marca la editada como par (similar/divergente) y detecta + // agregada/eliminada. + let mut s = State::new(Source::Local); + for (b, lines) in [ + (1u64, &["edita esto loco", "alfa beta", "gamma delta", "solo izquierda"][..]), + (2u64, &["edita esto", "alfa beta", "nuevo intermedio", "gamma delta"][..]), + ] { + for t in lines { + let mut l = OutputLine::stdout(*t); + l.block = b; + s.output.push(l); + } + } + s.input.set_text(":compara %c1 %c2"); + s = update(s, Msg::Key(ev(Key::Named(NamedKey::Enter), None))); + let texts: Vec = s.output.iter().map(|l| l.text.clone()).collect(); + // Abrió bloque de cotejo + resumen con conteos. + assert!(texts.iter().any(|t| t.contains("≡ :compara %c1 ↔ %c2")), "{texts:?}"); + assert!(texts.iter().any(|t| t.contains("idénticas") && t.contains("similares")), "{texts:?}"); + // Las 4 clases: idéntica anclada (≡), similar (≈) por edición, agregada + // (+) y eliminada (-) — el ancla idéntica desplazada (`gamma delta`) + // hace que el cotejo prefiera abrir huecos a emparejar diagonalmente. + assert!(texts.iter().any(|t| t.starts_with("≡") && t.contains("alfa beta")), "{texts:?}"); + assert!(texts.iter().any(|t| t.starts_with("≈") && t.contains("edita esto")), "{texts:?}"); + assert!(texts.iter().any(|t| t.starts_with("+") && t.contains("nuevo intermedio")), "{texts:?}"); + assert!(texts.iter().any(|t| t.starts_with("-") && t.contains("solo izquierda")), "{texts:?}"); + } + + #[test] + fn cotejo_rows_clasifica_identica_y_agregada() { + use crate::update::{cotejo_rows}; + let izq = vec!["uno dos tres".to_string()]; + let der = vec!["uno dos tres".to_string(), "cuatro cinco".to_string()]; + let (conteos, rows) = cotejo_rows(&izq, &der); + assert_eq!(conteos.identicas, 1); + assert_eq!(conteos.agregadas, 1); + assert_eq!(rows.len(), 2); + assert_eq!(rows[0].clase, pluma_cotejo::ClaseCambio::Identica); + } + + #[test] + fn etapa_del_tee_es_redirigible_y_filtrable() { + // Un pipe con captura por etapa: las líneas intermedias (stage=Some(k)) + // antes sólo se miraban. Ahora `%cN.K` las direcciona. + let mut s = State::new(Source::Local); + s.cwd = PathBuf::from("/"); + let mut head = OutputLine::stdout("línea final del pipe"); + head.block = 5; + s.output.push(head); + for t in ["intermedio A", "intermedio B"] { + let mut l = OutputLine::stage_stdout(1, t); + l.block = 5; + s.output.push(l); + } + // `:yank %c5.1` recoge SÓLO las líneas de la etapa 1, no el cuerpo. + s.input.set_text(":yank %c5.1"); + s = update(s, Msg::Key(ev(Key::Named(NamedKey::Enter), None))); + assert!( + s.output.iter().any(|l| l.text.contains("%c5.1") && l.text.contains("2 líneas")), + "yank de etapa no recogió 2 líneas: {:?}", + s.output.iter().map(|l| &l.text).collect::>() + ); + // `:filtra %c5.1` arma el prompt con el texto de la etapa, no del cuerpo. + s.input.set_text(":filtra %c5.1 dame sólo A"); + s = update(s, Msg::Key(ev(Key::Named(NamedKey::Enter), None))); + let req = s.llm_request.clone().expect("filtra sobre la etapa"); + assert!(req.prompt.contains("intermedio A")); + assert!(!req.prompt.contains("línea final del pipe"), "no debe traer el cuerpo"); + } + + #[test] + fn predice_lista_comandos_por_frecuencia_y_cwd() { + let mut s = State::new(Source::Local); + s.cwd = PathBuf::from("/repo"); + // Historial aislado (in-memory) para no leer el disco real. + s.history = std::sync::Arc::new(std::sync::Mutex::new( + shuma_history::History::open(PathBuf::from("/dev/null")).unwrap(), + )); + { + let mut h = s.history.lock().unwrap(); + for t in 0..4 { + let _ = h.append(shuma_history::Entry::new("cargo build", "/repo", 2 * t)); + let _ = h.append(shuma_history::Entry::new("git status", "/otro", 2 * t + 1)); + } + } + s.input.set_text(":predice"); + s = update(s, Msg::Key(ev(Key::Named(NamedKey::Enter), None))); + let texts: Vec = s.output.iter().map(|l| l.text.clone()).collect(); + assert!(texts.iter().any(|t| t.contains("comandos probables")), "{texts:?}"); + // El de cwd aparece con la marca ◆ y el conteo "aquí". + assert!( + texts.iter().any(|t| t.contains("◆") && t.contains("cargo build") && t.contains("aquí")), + "{texts:?}" + ); + } + + #[test] + fn filtra_encadena_sobre_salida_de_ia() { + // Una respuesta de IA en un bloque debe poder volver a filtrarse: el + // `:filtra` sobre ese bloque arma su prompt con el texto de IA. + let mut s = State::new(Source::Local); + s.cwd = PathBuf::from("/"); + let mut a = OutputLine::ai("línea de IA uno"); + a.block = 9; + s.output.push(a); + let mut b = OutputLine::ai("línea de IA dos"); + b.block = 9; + s.output.push(b); + s.input.set_text(":filtra %c9 deja sólo la primera"); + s = update(s, Msg::Key(ev(Key::Named(NamedKey::Enter), None))); + let req = s.llm_request.clone().expect("filtra encadena sobre IA"); + assert!(req.prompt.contains("línea de IA uno")); + assert!(req.prompt.contains("deja sólo la primera")); + } diff --git a/02_ruway/shuma/sandbox/shuma-module-shell/src/tests/grupo_jobs_input.rs b/02_ruway/shuma/sandbox/shuma-module-shell/src/tests/grupo_jobs_input.rs new file mode 100644 index 0000000..216fe18 --- /dev/null +++ b/02_ruway/shuma/sandbox/shuma-module-shell/src/tests/grupo_jobs_input.rs @@ -0,0 +1,254 @@ +use super::super::*; +use super::*; +use llimphi_ui::Modifiers; + + + #[test] + fn source_daemon_failure_surfaces_as_notice() { + // Sin daemon corriendo, start_run con Source::Daemon debe + // dejar un notice rojo y no enredarse — el shell sigue vivo. + let mut s = State::new(Source::Daemon { + socket: Some(PathBuf::from("/tmp/shuma-no-existe-test.sock")), + label: None, + }); + let _ = std::fs::remove_file("/tmp/shuma-no-existe-test.sock"); + s.input.set_text("echo hola"); + s = update(s, Msg::Key(ev(Key::Named(NamedKey::Enter), None))); + assert!(s.output.iter().any(|l| l.text.starts_with("✘ daemon:"))); + assert!(!s.is_running(), "no debe quedar un run vivo si falló"); + } + + #[test] + fn ampersand_suffix_starts_background_job() { + let mut s = State::new(Source::Local); + s.cwd = PathBuf::from("/"); + s.input.set_text("sleep 5 &"); + s = update(s, Msg::Key(ev(Key::Named(NamedKey::Enter), None))); + assert!(!s.is_running(), "& no debe dejar un foreground vivo"); + assert_eq!(s.bg_jobs.len(), 1); + // El header de la card del job: `[0] $ sleep 5 &`. + assert!(s + .output + .iter() + .any(|l| l.text.contains("[0]") && l.text.contains("sleep 5"))); + // Cancelar el job así no queda sleep colgado en el host. + s = update(s, Msg::Key(ev(Key::Named(NamedKey::Enter), None))); + s.input.set_text(":term 0"); + s = update(s, Msg::Key(ev(Key::Named(NamedKey::Enter), None))); + assert!(s + .output + .iter() + .any(|l| l.text.contains("[0] SIGTERM enviado"))); + } + + #[test] + fn kill_builtin_signals_background_job() { + // `:kill N` manda SIGKILL al job N (paralelo a `:term`). + let mut s = State::new(Source::Local); + s.cwd = PathBuf::from("/"); + s.input.set_text("sleep 5 &"); + s = update(s, Msg::Key(ev(Key::Named(NamedKey::Enter), None))); + assert_eq!(s.bg_jobs.len(), 1); + s.input.set_text(":kill 0"); + s = update(s, Msg::Key(ev(Key::Named(NamedKey::Enter), None))); + assert!(s + .output + .iter() + .any(|l| l.text.contains("[0] SIGKILL enviado"))); + } + + #[test] + fn input_focus_dirige_el_enter_y_no_pliega_a_los_vivos() { + // Modelo de input paralelo: arrancar un comando lo foca; la línea + // puede re-focarse para arrancar otro en paralelo; el vivo NO se + // pliega; y el foco se puede alternar a cualquier job vivo. + let mut s = State::new(Source::Local); + s.cwd = PathBuf::from("/"); + + // Foreground vivo → queda focado para recibir stdin. + s.input.set_text("sleep 30"); + s = update(s, Msg::Key(ev(Key::Named(NamedKey::Enter), None))); + assert!(s.is_running()); + let block_a = s.current_block; + assert_eq!( + s.input_focus, + Some(block_a), + "el comando recién arrancado recibe el foco del input" + ); + + // Volver a la línea (click/hover sobre el input) → arranca comandos. + s = update(s, Msg::FocusInput); + assert_eq!(s.input_focus, None); + + // Con un foreground vivo, el nuevo comando corre en paralelo (bg job) + // y se lleva el foco; el viejo NO se pliega (sigue activo). + s.input.set_text("sleep 30"); + s = update(s, Msg::Key(ev(Key::Named(NamedKey::Enter), None))); + assert_eq!(s.bg_jobs.len(), 1, "el segundo corre en paralelo"); + let block_b = s.bg_jobs[0].lock().unwrap().block; + assert_eq!(s.input_focus, Some(block_b)); + assert!( + !s.collapsed.contains(&block_a), + "una ejecución viva no se pliega al arrancar otra" + ); + + // Alternar el foco al primer job vivo (click/hover sobre su card). + s = update(s, Msg::FocusJob(block_a)); + assert_eq!(s.input_focus, Some(block_a)); + + // Focar un bloque sin job vivo no roba el foco a la línea. + s = update(s, Msg::FocusInput); + s = update(s, Msg::FocusJob(99_999)); + assert_eq!(s.input_focus, None, "no se foca un bloque sin job vivo"); + + // Limpieza: matar los jobs para no dejar sleeps colgados. + s.input.set_text(":kill 0"); + s = update(s, Msg::Key(ev(Key::Named(NamedKey::Enter), None))); + if let Some(arc) = s.running.take() { + if let Some(k) = arc.lock().unwrap().killer.as_ref() { + k.kill(); + } + } + } + + #[test] + fn jobs_builtin_lists_background_jobs() { + let mut s = State::new(Source::Local); + s.cwd = PathBuf::from("/"); + s.input.set_text("sleep 5 &"); + s = update(s, Msg::Key(ev(Key::Named(NamedKey::Enter), None))); + s.input.set_text(":jobs"); + s = update(s, Msg::Key(ev(Key::Named(NamedKey::Enter), None))); + assert!(s + .output + .iter() + .any(|l| l.text.contains("[0]") && l.text.contains("sleep"))); + s.input.set_text(":term 0"); + s = update(s, Msg::Key(ev(Key::Named(NamedKey::Enter), None))); + } + + #[test] + fn jobs_builtin_empty_shows_notice() { + let mut s = State::new(Source::Local); + s.input.set_text(":jobs"); + s = update(s, Msg::Key(ev(Key::Named(NamedKey::Enter), None))); + assert!(s.output.iter().any(|l| l.text.contains("sin jobs"))); + } + + #[test] + fn enter_with_open_quote_inserts_newline_instead_of_submit() { + let mut s = State::new(Source::Local); + s.input.set_text("echo 'hola"); + s = update(s, Msg::Key(ev(Key::Named(NamedKey::Enter), None))); + // No debe haber arrancado un run — Enter agregó \n. + assert!(!s.is_running()); + assert_eq!(s.input.text(), "echo 'hola\n"); + } + + #[test] + fn shift_enter_always_inserts_newline() { + let mut s = State::new(Source::Local); + s.input.set_text("ls"); // texto completo, sin continuation pendiente + let shift_enter = KeyEvent { + key: Key::Named(NamedKey::Enter), + state: KeyState::Pressed, + text: None, + modifiers: Modifiers { + shift: true, + ..Default::default() + }, + repeat: false, + }; + s = update(s, Msg::Key(shift_enter)); + assert!(!s.is_running(), "shift+enter no debe ejecutar"); + assert_eq!(s.input.text(), "ls\n"); + } + + #[test] + fn paste_key_event_is_recognized() { + // Ctrl-V con texto en clipboard se procesa como paste (no + // termina llamando apply_key con el carácter 'v'). Sin display + // server (CI), read_clipboard devuelve None y el state no + // cambia. Pero verificamos que la rama de paste se toma. + let mut s = State::new(Source::Local); + s.input.set_text("hola"); + let ctrl_v = KeyEvent { + key: Key::Character("v".into()), + state: KeyState::Pressed, + text: Some("v".into()), + modifiers: Modifiers { + ctrl: true, + ..Default::default() + }, + repeat: false, + }; + s = update(s, Msg::Key(ctrl_v)); + // El input no debe llevar una 'v' al final: la rama paste se tragó la + // tecla. Lo que SÍ inserte depende de si la máquina tiene algo en el + // portapapeles — antes esto asertaba `== "hola"` y fallaba en cualquier + // escritorio con el clipboard poblado, que es la mitad de los días. + let texto = s.input.text(); + assert!(texto.starts_with("hola"), "no debe borrar lo que había: {texto:?}"); + assert!( + !texto.ends_with('v'), + "Ctrl+V no se tipea como la letra v: {texto:?}" + ); + } + + #[test] + fn ansi_idx_palette_matches_expected_basics() { + // Idx 0 = negro, 15 = blanco, 196 = rojo claro del cubo. + let black = ansi_idx_to_color(0); + assert_eq!(black.components[0], 0.0); + let white = ansi_idx_to_color(15); + assert!(white.components[0] > 0.99); + } + + #[test] + fn arrow_right_at_end_accepts_ghost() { + let mut s = State::new(Source::Local); + // Historial aislado: un `cargo …` ajeno cambiaría el ghost aceptado. + s.history = Arc::new(Mutex::new( + shuma_history::History::open(std::path::PathBuf::from("/dev/null")).unwrap(), + )); + { + let mut h = s.history.lock().unwrap(); + let _ = h.append(shuma_history::Entry::new("cargo build --release", "/", 1)); + } + s.input.set_text("cargo bu"); + s = update(s, Msg::Key(ev(Key::Named(NamedKey::ArrowRight), None))); + assert_eq!(s.input.text(), "cargo build --release"); + } + + #[test] + fn toggle_mic_deja_intent_y_dictado_inserta() { + let mut s = State::new(Source::Local); + // Apagado → tocar el mic pide encender (una sola vez). + s = update(s, Msg::ToggleMic); + assert_eq!(s.tomar_mic_intent(), Some(true)); + assert_eq!(s.tomar_mic_intent(), None); + // Con la escucha activa, tocar el mic pide apagar. + s.fijar_escucha(shuma_voz_ui::EstadoEscucha::Oyendo); + s = update(s, Msg::ToggleMic); + assert_eq!(s.tomar_mic_intent(), Some(false)); + // El dictado (STT) entra por el mismo camino que el texto tipeado. + s.input.set_text(""); + s = update(s, Msg::InsertAtCursor("git status".into())); + assert_eq!(s.input.text(), "git status"); + } + + #[test] + fn blur_input_apaga_el_foco_y_focus_lo_reenciende() { + // El bug: el foco (cue visual) se quedaba pegado porque nada lo apagaba. + // BlurInput lo apaga; FocusInput lo re-enciende (idempotencia del par). + let mut s = State::new(Source::Local); + s = update(s, Msg::FocusInput); + assert!(s.focused, "FocusInput enciende el cue de foco"); + + s = update(s, Msg::BlurInput); + assert!(!s.focused, "BlurInput apaga el cue de foco (ya no se queda pegado)"); + assert!(s.input_focus.is_none(), "BlurInput también suelta el stdin de un job"); + + s = update(s, Msg::FocusInput); + assert!(s.focused, "se puede volver a enfocar tras un blur"); + } diff --git a/02_ruway/shuma/sandbox/shuma-module-shell/src/tests/grupo_prediccion.rs b/02_ruway/shuma/sandbox/shuma-module-shell/src/tests/grupo_prediccion.rs new file mode 100644 index 0000000..7cd6b91 --- /dev/null +++ b/02_ruway/shuma/sandbox/shuma-module-shell/src/tests/grupo_prediccion.rs @@ -0,0 +1,89 @@ +use super::super::*; +use super::*; + + + #[test] + fn infer_predicts_next_command_in_a_repeated_sequence() { + // Historial con el patrón `git pull` → `make` repetido dos veces y + // un `git pull` final: el motor debe predecir `make` como + // continuación. cwd `/tmp/...` sin marcadores → sin gating. + let mut s = State::new(Source::Local); + // Historial AISLADO en memoria: `State::new` abre el real del disco y + // varios tests en paralelo lo contaminarían (la minería vería entradas + // ajenas y el patrón no emergería limpio). + s.history = Arc::new(Mutex::new( + shuma_history::History::open(std::path::PathBuf::from("/dev/null")).unwrap(), + )); + let dir = "/tmp/shuma-infer-pred-test"; + { + let mut h = s.history.lock().unwrap(); + for (i, line) in ["git pull", "make", "git pull", "make", "git pull"] + .iter() + .enumerate() + { + let _ = h.append(shuma_history::Entry::new(*line, dir, i as u64)); + } + } + refresh_patterns(&mut s); + assert!(!s.patterns.is_empty(), "debe emerger el patrón git→make"); + // La continuación predicha empieza por `make` (puede seguir con el + // resto del patrón más largo, p. ej. `make && git pull`). + let pred = predicted_sequence(&s).expect("predice una continuación"); + assert!( + pred.starts_with("make"), + "tras `git pull` predice `make…`, fue {pred:?}" + ); + } + + #[test] + fn ghost_uses_prediction_before_history() { + // Con el patrón aprendido, tipear `ma` debe sugerir `ke` (de la + // predicción `make`), aunque el historial no tenga un match mejor. + let mut s = State::new(Source::Local); + // Historial aislado (mismo motivo que `infer_predicts_…`): evita la + // contaminación cruzada entre tests paralelos vía el archivo real. + s.history = Arc::new(Mutex::new( + shuma_history::History::open(std::path::PathBuf::from("/dev/null")).unwrap(), + )); + let dir = "/tmp/shuma-infer-ghost-test"; + { + let mut h = s.history.lock().unwrap(); + for (i, line) in ["git pull", "make", "git pull", "make", "git pull"] + .iter() + .enumerate() + { + let _ = h.append(shuma_history::Entry::new(*line, dir, i as u64)); + } + } + refresh_patterns(&mut s); + s.input.set_text("ma"); + // El ghost arranca con `ke` (sufijo de `make`, de la predicción). Con + // el historial aislado la predicción es el patrón completo (`make && + // git pull`), así que el sufijo puede ser `ke && git pull` — basta con + // que empiece por `ke` para probar que vino de la predicción `make…`. + let ghost = current_ghost(&s).expect("hay ghost de la predicción"); + assert!(ghost.starts_with("ke"), "el ghost debe venir de `make…`, fue {ghost:?}"); + } + + #[test] + fn git_branch_reads_head_ref() { + // `.git/HEAD` con `ref: refs/heads/` → Some(rama). Usamos un + // tmpdir aislado para no depender del repo real. + let base = std::env::temp_dir().join(format!("shuma-gb-{}", std::process::id())); + let git = base.join(".git"); + std::fs::create_dir_all(&git).unwrap(); + std::fs::write(git.join("HEAD"), "ref: refs/heads/feature/x\n").unwrap(); + // Desde un subdirectorio: debe subir hasta encontrar `.git`. + let sub = base.join("sub/dir"); + std::fs::create_dir_all(&sub).unwrap(); + assert_eq!(git_branch(&sub).as_deref(), Some("feature/x")); + let _ = std::fs::remove_dir_all(&base); + } + + #[test] + fn git_branch_none_outside_repo() { + let base = std::env::temp_dir().join(format!("shuma-nogit-{}", std::process::id())); + std::fs::create_dir_all(&base).unwrap(); + assert_eq!(git_branch(&base), None); + let _ = std::fs::remove_dir_all(&base); + } diff --git a/02_ruway/shuma/sandbox/shuma-module-shell/src/tests/grupo_run_async.rs b/02_ruway/shuma/sandbox/shuma-module-shell/src/tests/grupo_run_async.rs new file mode 100644 index 0000000..cafeecc --- /dev/null +++ b/02_ruway/shuma/sandbox/shuma-module-shell/src/tests/grupo_run_async.rs @@ -0,0 +1,116 @@ +use super::super::*; +use super::*; + + + #[test] + fn long_running_command_does_not_block_update() { + // `update(Enter)` debe spawnear sin bloquear: vuelve enseguida con el + // run AÚN vivo, en vez de esperar a que el comando termine (como haría + // `Command::output`). + // + // La prueba semántica (independiente del reloj) es `is_running()` justo + // después: si `update` hubiera corrido el comando a completarse, el + // proceso ya estaría muerto. El reloj es belt-and-suspenders. + // + // Usamos `sleep 1` (no 0.3) a propósito: bajo carga pesada (suite en + // paralelo + builds) el overhead de setup —spawn de thread + fork— puede + // robar varios cientos de ms. Con un sleep largo ese stall sigue siendo + // chico frente al segundo de duración, así `is_running()` no se vuelve + // flaky; y el umbral de 500 ms separa cómodamente "no-bloqueó" (~ms, o + // unos cientos bajo carga) de "bloqueó toda la duración" (~1000 ms). + let mut s = State::new(Source::Local); + s.cwd = PathBuf::from("/"); + s.input.set_text("sleep 1"); + let t0 = std::time::Instant::now(); + s = update(s, Msg::Key(ev(Key::Named(NamedKey::Enter), None))); + let elapsed = t0.elapsed(); + assert!(s.is_running(), "el sleep debe seguir vivo tras Enter"); + assert!( + elapsed.as_millis() < 500, + "update tardó {elapsed:?} — debería volver sin esperar al comando (~1 s)" + ); + s = drain_until_idle(s); + assert!(s.output.iter().any(|l| l.text == "✔ exit 0")); + } + + #[test] + fn second_enter_with_ampersand_starts_bg() { + // Política (2026-06-09): un Enter durante un run vivo SIN `&` + // se interpreta como respuesta al stdin del running (apt Y/n, + // sudo, etc.). Para spawnear bg paralelo, el usuario agrega `&`. + let mut s = State::new(Source::Local); + s.cwd = PathBuf::from("/"); + s.input.set_text("sleep 0.2"); + s = update(s, Msg::Key(ev(Key::Named(NamedKey::Enter), None))); + assert!(s.is_running()); + // Sin `&`: va al stdin del running, no spawnea bg. + s.input.set_text("y"); + s = update(s, Msg::Key(ev(Key::Named(NamedKey::Enter), None))); + assert!(s.bg_jobs.is_empty(), "sin & no debe spawnar bg job"); + // Con `&`: arranca como bg job paralelo. + s.input.set_text("echo segunda &"); + s = update(s, Msg::Key(ev(Key::Named(NamedKey::Enter), None))); + assert!(!s.bg_jobs.is_empty(), "con & arranca bg job"); + s = drain_until_idle(s); + let combined: Vec = s.output.iter().map(|l| l.text.clone()).collect(); + assert!(combined.iter().any(|t| t == "segunda"), "{combined:?}"); + } + + #[test] + fn cancel_terminates_active_run() { + let mut s = State::new(Source::Local); + s.cwd = PathBuf::from("/"); + s.input.set_text("sleep 30"); + s = update(s, Msg::Key(ev(Key::Named(NamedKey::Enter), None))); + assert!(s.is_running()); + // El coordinador de `shuma-exec` puebla `Killer.children` en + // background — un Cancel inmediato podría llegar antes y la + // señal caería en el vacío. Esperar a que aparezca el PID. + let arc = s.running.as_ref().unwrap().clone(); + let deadline = std::time::Instant::now() + std::time::Duration::from_millis(500); + while std::time::Instant::now() < deadline { + let has_pid = arc + .lock() + .unwrap() + .killer + .as_ref() + .map(|k| !k.pids().is_empty()) + .unwrap_or(false); + if has_pid { + break; + } + std::thread::sleep(std::time::Duration::from_millis(10)); + } + assert!( + arc.lock() + .unwrap() + .killer + .as_ref() + .map(|k| !k.pids().is_empty()) + .unwrap_or(false), + "el coordinador no expuso el PID en 500ms" + ); + s = update(s, Msg::Cancel); + s = drain_until_idle(s); + assert!(!s.is_running(), "sleep 30 debe morir al cancelar"); + assert!(s.output.iter().any(|l| l.text.starts_with("⏹ cancel"))); + } + + #[test] + fn empty_submit_does_nothing_but_clears_input() { + let mut s = State::new(Source::Local); + s.input.set_text(" "); + s = update(s, Msg::Key(ev(Key::Named(NamedKey::Enter), None))); + assert!(s.output.is_empty()); + assert!(s.input.text().is_empty()); + } + + #[test] + fn output_buffer_caps_at_max() { + let mut buf: Vec = Vec::new(); + for i in 0..MAX_OUTPUT_LINES + 50 { + push_line(&mut buf, OutputLine::stdout(format!("línea {i}"))); + } + assert_eq!(buf.len(), MAX_OUTPUT_LINES); + assert!(buf[0].text.contains("50")); + } diff --git a/02_ruway/shuma/sandbox/shuma-module-shell/src/tests/grupo_scroll.rs b/02_ruway/shuma/sandbox/shuma-module-shell/src/tests/grupo_scroll.rs new file mode 100644 index 0000000..0b5ea37 --- /dev/null +++ b/02_ruway/shuma/sandbox/shuma-module-shell/src/tests/grupo_scroll.rs @@ -0,0 +1,126 @@ +use super::super::*; +use super::*; + + + #[test] + fn scroll_clamps_between_zero_and_overflow() { + let mut s = State::new(Source::Local); + *s.out_overflow.lock().unwrap() = 100.0; + s = update(s, Msg::Scroll(40.0)); + assert_eq!(s.scroll_px, 40.0); + s = update(s, Msg::Scroll(200.0)); // pasa del tope → clamp a overflow + assert_eq!(s.scroll_px, 100.0); + s = update(s, Msg::Scroll(-500.0)); // de vuelta al fondo + assert_eq!(s.scroll_px, 0.0); + } + + #[test] + fn scroll_setea_anchor_para_estabilidad_bajo_append() { + // Al hacer scroll up, el anchor capta el overflow vigente para + // que appends posteriores no muevan la vista del usuario (Fase 5 + // del SDD-TERMINAL). + let mut s = State::new(Source::Local); + *s.out_overflow.lock().unwrap() = 100.0; + s = update(s, Msg::Scroll(40.0)); + assert_eq!(s.scroll_px, 40.0); + // anchor capturó el overflow al momento del scroll. + assert_eq!(s.surf_scroll_anchor, 100.0); + // Simular un append: el overflow crece pero scroll_px NO cambia. + // La fórmula del view interpretará scroll_y contra el anchor viejo. + *s.out_overflow.lock().unwrap() = 150.0; + // El usuario no scrolleó; scroll_px sigue siendo 40 y anchor 100, + // así que scroll_y intencionado = 100 - 40 = 60 (mismo de antes). + assert_eq!(s.scroll_px, 40.0); + assert_eq!(s.surf_scroll_anchor, 100.0); + // Próximo scroll del usuario re-baseliza al nuevo overflow. + // curr_scroll_y = (100 - 40) = 60. delta=10 → new = 50. + // scroll_px = 150 - 50 = 100. anchor = 150. + s = update(s, Msg::Scroll(10.0)); + assert_eq!(s.scroll_px, 100.0); + assert_eq!(s.surf_scroll_anchor, 150.0); + } + + #[test] + fn scroll_captura_velocidad_para_inercia() { + // El último delta del usuario queda en `surf_scroll_velocity` para + // que el próximo Tick lo aplique con decay (Fase 5.2). + let mut s = State::new(Source::Local); + *s.out_overflow.lock().unwrap() = 100.0; + s = update(s, Msg::Scroll(30.0)); + assert_eq!(s.surf_scroll_velocity, 30.0); + s = update(s, Msg::Scroll(15.0)); + assert_eq!(s.surf_scroll_velocity, 15.0, "se reemplaza por el último"); + } + + #[test] + fn tick_aplica_inercia_y_decae() { + // Con velocidad seteada, Tick scrollea por ella y la reduce por + // fricción 0.82. Eventualmente cae bajo epsilon y se anula. + let mut s = State::new(Source::Local); + *s.out_overflow.lock().unwrap() = 1000.0; + s = update(s, Msg::Scroll(40.0)); + let v0 = s.surf_scroll_velocity; + let px0 = s.scroll_px; + // Primer Tick: scrollea 40 más → scroll_px sube por ese delta; + // velocidad cae por fricción. + s = update(s, Msg::Tick); + assert!(s.scroll_px > px0, "el tick aplica el delta"); + assert!( + s.surf_scroll_velocity.abs() < v0.abs(), + "la velocidad decae" + ); + // Tras ~30 ticks la velocidad ya cayó bajo epsilon (0.5). + for _ in 0..30 { + s = update(s, Msg::Tick); + } + assert_eq!(s.surf_scroll_velocity, 0.0, "termina en 0"); + } + + #[test] + fn inercia_se_detiene_al_tocar_el_fondo() { + // Si la inercia lleva al usuario contra el fondo (re-pin), la + // velocidad se anula inmediatamente (sin "rebote" simulado). + let mut s = State::new(Source::Local); + *s.out_overflow.lock().unwrap() = 100.0; + // Subir un poco para tener margen. + s = update(s, Msg::Scroll(50.0)); + assert!(s.scroll_px > 0.0); + // Inyectar velocidad hacia abajo (negativa = scroll down → bottom). + s.surf_scroll_velocity = -500.0; + s = update(s, Msg::Tick); + assert_eq!(s.scroll_px, 0.0, "alcanzó el fondo"); + assert_eq!(s.surf_scroll_velocity, 0.0, "inercia se detiene en el límite"); + } + + #[test] + fn scroll_re_pin_al_fondo_resetea_anchor() { + // Si el scroll del usuario alcanza el fondo (scroll_y >= overflow), + // re-pin: scroll_px=0 y anchor=0. Próximos appends siguen pegados + // al fondo (UX terminal clásica). + let mut s = State::new(Source::Local); + *s.out_overflow.lock().unwrap() = 100.0; + s = update(s, Msg::Scroll(40.0)); + assert_eq!(s.surf_scroll_anchor, 100.0); + s = update(s, Msg::Scroll(-500.0)); + assert_eq!(s.scroll_px, 0.0); + assert_eq!(s.surf_scroll_anchor, 0.0, "re-pin limpia el anchor"); + } + + #[test] + fn toggle_block_flips_collapsed_set() { + let mut s = State::new(Source::Local); + s = update(s, Msg::ToggleBlock(3)); + assert!(s.collapsed.contains(&3), "primer toggle colapsa"); + s = update(s, Msg::ToggleBlock(3)); + assert!(!s.collapsed.contains(&3), "segundo toggle despliega"); + } + + #[test] + fn clear_output_also_drops_collapsed_set() { + let mut s = State::new(Source::Local); + s.push_output(OutputLine::prompt("$ ls")); + s.collapsed.insert(s.output[0].block); + s.clear_output(); + assert!(s.output.is_empty()); + assert!(s.collapsed.is_empty(), "clear limpia también los colapsos"); + } diff --git a/02_ruway/shuma/sandbox/shuma-module-shell/src/tests/grupo_spec.rs b/02_ruway/shuma/sandbox/shuma-module-shell/src/tests/grupo_spec.rs new file mode 100644 index 0000000..c02ebf2 --- /dev/null +++ b/02_ruway/shuma/sandbox/shuma-module-shell/src/tests/grupo_spec.rs @@ -0,0 +1,144 @@ +use super::super::*; +use super::*; +use llimphi_ui::Modifiers; + + + #[test] + fn build_spec_routes_known_tui_command_to_pty() { + let (spec, tui) = build_spec("vim README.md", "/"); + assert!(matches!(spec.exec, shuma_exec::Exec::Pty { .. })); + assert!(tui.is_some()); + } + + #[test] + fn build_spec_routes_plain_command_to_shell() { + let (spec, tui) = build_spec("ls -la", "/"); + assert!(matches!(spec.exec, shuma_exec::Exec::Shell { .. })); + assert!(tui.is_none()); + } + + #[test] + fn build_spec_routes_simple_pipe_to_direct_with_capture() { + // Un pipe simple corre directo (sin bash) y con captura por etapa. + let (spec, tui) = build_spec("ls -la | grep foo", "/"); + match &spec.exec { + shuma_exec::Exec::Direct { stages } => { + assert_eq!(stages.len(), 2, "dos etapas"); + assert_eq!(stages[0].program, "ls"); + assert_eq!(stages[1].program, "grep"); + } + other => panic!("esperaba Exec::Direct, fue {other:?}"), + } + assert!(spec.capture_stages, "el pipe directo activa el tee"); + assert!(tui.is_none()); + } + + #[test] + fn build_spec_pipe_with_quotes_falls_back_to_shell() { + // `shuma_line::Stage` no recoge StringLit en args, así que un pipe + // con comillas debe ir a `sh -c` o perdería el argumento citado. + let (spec, _) = build_spec("echo 'a | b' | cat", "/"); + assert!(matches!(spec.exec, shuma_exec::Exec::Shell { .. })); + assert!(!spec.capture_stages); + } + + #[test] + fn alt_screen_is_the_hard_tui_signal() { + // `ESC[?1049h` entra a alternate screen (señal dura de TUI + // full-screen); `ESC[?1049l` sale y vuelve a modo líneas. + let mut p = vt100::Parser::new(24, 80, 0); + p.process(b"hola mundo\r\n"); + assert!(!p.screen().alternate_screen(), "arranca en modo líneas"); + p.process(b"\x1b[?1049h"); + assert!(p.screen().alternate_screen(), "1049h = pantalla completa"); + p.process(b"\x1b[?1049l"); + assert!(!p.screen().alternate_screen(), "1049l = vuelve a líneas"); + } + + #[test] + fn screen_to_lines_trims_trailing_blanks() { + let mut p = vt100::Parser::new(24, 80, 0); + p.process(b"primera\r\nsegunda\r\n"); + let lines = screen_to_lines(p.screen()); + // Sólo las dos filas con contenido; las 22 filas vacías de abajo + // se recortan. + assert_eq!(lines, vec!["primera", "segunda"]); + } + + #[test] + fn build_spec_pipe_with_glob_falls_back_to_shell() { + let (spec, _) = build_spec("ls *.rs | cat", "/"); + assert!(matches!(spec.exec, shuma_exec::Exec::Shell { .. })); + } + + #[test] + fn simple_pipe_stages_rejects_single_command() { + // Un único comando no gana nada del modo directo (no hay tubería + // que interceptar) → `None`, cae a `sh -c`. + assert!(simple_pipe_stages("ls -la").is_none()); + } + + #[test] + fn simple_pipe_stages_rejects_trailing_pipe() { + // Etapa sin comando (línea incompleta) → None. + assert!(simple_pipe_stages("ls |").is_none()); + } + + #[test] + fn piped_command_captures_intermediate_stage_output() { + // `echo hola | cat`: stage0 (echo) se captura en vivo como una + // OutputLine con stage=Some(0); la salida final (cat) sale como + // stdout normal (stage None). + let mut s = State::new(Source::Local); + s.cwd = PathBuf::from("/"); + s.input.set_text("echo hola | cat"); + s = update(s, Msg::Key(ev(Key::Named(NamedKey::Enter), None))); + assert!(s.is_running(), "el pipe debe arrancar un run"); + s = drain_until_idle(s); + let stage0: Vec<&OutputLine> = s + .output + .iter() + .filter(|l| l.stage == Some(0)) + .collect(); + assert!( + stage0.iter().any(|l| l.text == "hola"), + "esperaba 'hola' capturado de la etapa 0, output: {:?}", + s.output.iter().map(|l| (l.stage, &l.text)).collect::>() + ); + // La salida final (cat) llega como stdout normal sin stage. + assert!(s + .output + .iter() + .any(|l| l.stage.is_none() && l.text == "hola")); + assert!(s.output.iter().any(|l| l.text == "✔ exit 0")); + } + + #[test] + fn build_spec_tui_prefix_overrides_default() { + // `:tui ls` no es típico, pero el prefix lo fuerza igual. + let (spec, tui) = build_spec(":tui ls", "/"); + assert!(matches!(spec.exec, shuma_exec::Exec::Pty { .. })); + assert!(tui.is_some()); + } + + #[test] + fn key_to_pty_bytes_handles_special_keys() { + let enter = ev(Key::Named(NamedKey::Enter), None); + assert_eq!(key_to_pty_bytes(&enter), b"\r"); + let up = ev(Key::Named(NamedKey::ArrowUp), None); + assert_eq!(key_to_pty_bytes(&up), b"\x1b[A"); + let esc = ev(Key::Named(NamedKey::Escape), None); + assert_eq!(key_to_pty_bytes(&esc), b"\x1b"); + // Ctrl-C → 0x03. + let ctrl_c = KeyEvent { + key: Key::Character("c".into()), + state: KeyState::Pressed, + text: Some("c".into()), + modifiers: Modifiers { + ctrl: true, + ..Default::default() + }, + repeat: false, + }; + assert_eq!(key_to_pty_bytes(&ctrl_c), vec![3u8]); + } diff --git a/02_ruway/shuma/sandbox/shuma-module-shell/src/tests/grupo_spill.rs b/02_ruway/shuma/sandbox/shuma-module-shell/src/tests/grupo_spill.rs new file mode 100644 index 0000000..f145a1f --- /dev/null +++ b/02_ruway/shuma/sandbox/shuma-module-shell/src/tests/grupo_spill.rs @@ -0,0 +1,354 @@ +use super::super::*; +use super::*; + + + #[test] + fn surf_history_acumula_lineas_de_body_entre_frames() { + // El cuerpo `surf_history` persiste a lo largo de la sesión — + // a diferencia del Scrollback per-frame que arma el view. Aquí + // simulamos varios push_output y verificamos que la history + // refleja sólo las líneas de body (no Prompts ni notices). + let mut s = State::new(Source::Local); + s.push_output(OutputLine::prompt("$ ls")); + s.push_output(OutputLine::stdout("uno")); + s.push_output(OutputLine::stderr("err1")); + s.push_output(OutputLine::notice("✔ exit 0")); + s.push_output(OutputLine::stdout("dos")); + let h = s.surf_history.lock().unwrap(); + // Prompts y notices NO van; stdout + stderr SÍ. + assert_eq!(h.len(), 3); + assert_eq!(h.line(0), Some("uno")); + assert_eq!(h.line(1), Some("err1")); + assert_eq!(h.line(2), Some("dos")); + } + + #[test] + fn surf_history_excluye_lineas_de_etapa_de_pipe() { + // Las stage_lines (capturas de tee de etapas intermedias) tampoco + // van a la history (espeja el filtro de `body_lines_for_block`). + let mut s = State::new(Source::Local); + s.push_output(OutputLine::prompt("$ ls | wc")); + // Línea intermedia con stage=Some(_) — no va. + let mut staged = OutputLine::stdout("intermedia"); + staged.stage = Some(0); + s.push_output(staged); + // Línea de body normal — sí va. + s.push_output(OutputLine::stdout("final")); + let h = s.surf_history.lock().unwrap(); + assert_eq!(h.len(), 1); + assert_eq!(h.line(0), Some("final")); + } + + #[test] + fn refresh_spilled_visible_carga_tail_del_archive() { + use llimphi_widget_terminal::{Scrollback, SpillStore}; + // History con cap chico + spill → muchas líneas terminan en disco. + let dir = tempfile::tempdir().expect("tempdir"); + let mut sb = Scrollback::new(20); + let spill = SpillStore::create(dir.path().join("test.spill")).expect("spill"); + sb.enable_spill(spill); + let history = Arc::new(Mutex::new(sb)); + let cache = Arc::new(Mutex::new(SurfSpilledCache::default())); + + // Cache vacío + history vacía → refresh no carga nada. + refresh_surf_spilled_visible(&history, &cache); + assert!(cache.lock().unwrap().lines.is_empty()); + + // Push muchas líneas hasta forzar spill. + for i in 0..50 { + history.lock().unwrap().push_line(&format!("L{i:04}")); + } + let spilled = history.lock().unwrap().spilled_count(); + assert!(spilled > 0, "el cap forzó spill"); + + // Refresh carga las últimas N (clamped a MAX_SPILLED_VISIBLE). + refresh_surf_spilled_visible(&history, &cache); + let c = cache.lock().unwrap(); + let expected_n = spilled.min(MAX_SPILLED_VISIBLE); + assert_eq!(c.lines.len(), expected_n); + assert_eq!(c.cached_at, spilled); + // Última línea del cache = última línea que entró al spill. + let last_spilled_id = spilled as u64 - 1; + let expected_last = format!("L{:04}", last_spilled_id); + assert_eq!(c.lines.last(), Some(&expected_last)); + } + + #[test] + fn scrollback_grep_busca_en_memoria_y_spill() { + // History con cap chico + spill: muchas líneas en disco + algunas + // en memoria. `:scrollback grep ` debe encontrar hits en + // ambas mitades y reportarlos por notice. + let mut s = State::new(Source::Local); + // Forzar enable_spill (la State::new default no lo activa). + let dir = tempfile::tempdir().unwrap(); + let mut sb = llimphi_widget_terminal::Scrollback::new(20); + let spill = llimphi_widget_terminal::SpillStore::create( + dir.path().join("test.spill"), + ) + .unwrap(); + sb.enable_spill(spill); + *s.surf_history.lock().unwrap() = sb; + // Push lines: some "foo", some "bar". Cap chico → muchas spilled. + for i in 0..50 { + let line = if i % 5 == 0 { + format!("foo_line_{i}") + } else { + format!("bar_line_{i}") + }; + s.push_output(OutputLine::stdout(&line)); + } + // Sanity: hay spilleadas. + let total_spilled = s.surf_history.lock().unwrap().spilled_count(); + assert!(total_spilled > 0); + // grep "foo": debe encontrar las 10 ocurrencias (i = 0, 5, 10, ...). + s.input.set_text(":scrollback grep foo"); + s = update(s, Msg::Key(KeyEvent { + key: Key::Named(NamedKey::Enter), + state: KeyState::Pressed, + text: None, + modifiers: llimphi_ui::Modifiers::default(), + repeat: false, + })); + // El último Notice header reporta el total de hits. + let summary = s.output.iter().rev() + .find(|l| l.kind == OutputKind::Notice && l.text.starts_with("grep:")) + .expect("grep summary"); + assert!(summary.text.contains("10 hits"), "summary: {}", summary.text); + } + + #[test] + fn refresh_spilled_visible_no_recarga_si_no_cambio() { + use llimphi_widget_terminal::{Scrollback, SpillStore}; + let dir = tempfile::tempdir().unwrap(); + let mut sb = Scrollback::new(20); + let spill = SpillStore::create(dir.path().join("test.spill")).unwrap(); + sb.enable_spill(spill); + let history = Arc::new(Mutex::new(sb)); + for i in 0..30 { + history.lock().unwrap().push_line(&format!("L{i:04}")); + } + let cache = Arc::new(Mutex::new(SurfSpilledCache::default())); + refresh_surf_spilled_visible(&history, &cache); + let first_count = cache.lock().unwrap().cached_at; + // Sin nuevas pushes el cached_at no debe cambiar tras un segundo refresh. + refresh_surf_spilled_visible(&history, &cache); + assert_eq!(cache.lock().unwrap().cached_at, first_count); + } + + #[test] + fn spill_effective_start_cola_y_paginada() { + // Cola (None): arranca en las últimas MAX_SPILLED_VISIBLE. + assert_eq!( + spill_effective_start(None, 1000), + (1000 - MAX_SPILLED_VISIBLE) as u64 + ); + // Menos historial que la ventana → arranca en 0. + assert_eq!(spill_effective_start(None, 50), 0); + // Paginada Some(id) sobre el piso → respeta el id. + assert_eq!(spill_effective_start(Some(100), 1000), 100); + // Clampea al piso: no más de MAX_SPILLED_LOADED desde el final. + let floor = (5000 - MAX_SPILLED_LOADED) as u64; + assert_eq!(spill_effective_start(Some(0), 5000), floor); + } + + #[test] + fn spill_page_back_decision() { + let row_h = 16.0; + let near = row_h; // < row_h*3 → "cerca del tope" + // Lejos del tope → no pagina. + assert!(spill_page_back(None, 1000, 500.0, row_h).is_none()); + // Cerca del tope con historial por delante → retrocede una página. + let got = spill_page_back(None, 1000, near, row_h).expect("pagina"); + assert_eq!(got, (1000 - MAX_SPILLED_VISIBLE - SPILL_PAGE) as u64); + // En el inicio del archive (effective 0) → nada más que traer. + assert!(spill_page_back(Some(0), 50, near, row_h).is_none()); + // Contra el piso de carga → no pagina más. + let floor = (5000 - MAX_SPILLED_LOADED) as u64; + assert!(spill_page_back(Some(floor), 5000, near, row_h).is_none()); + } + + #[test] + fn refresh_carga_ventana_paginada_mas_vieja() { + use llimphi_widget_terminal::{Scrollback, SpillStore}; + let dir = tempfile::tempdir().unwrap(); + let mut sb = Scrollback::new(10); + let spill = SpillStore::create(dir.path().join("test.spill")).unwrap(); + sb.enable_spill(spill); + let history = Arc::new(Mutex::new(sb)); + for i in 0..400 { + history.lock().unwrap().push_line(&format!("L{i:04}")); + } + let spilled = history.lock().unwrap().spilled_count(); + assert!(spilled > MAX_SPILLED_VISIBLE); + let cache = Arc::new(Mutex::new(SurfSpilledCache::default())); + // Cola (default): carga sólo las últimas MAX_SPILLED_VISIBLE. + refresh_surf_spilled_visible(&history, &cache); + { + let c = cache.lock().unwrap(); + assert_eq!(c.lines.len(), MAX_SPILLED_VISIBLE); + assert_eq!(c.first_id, (spilled - MAX_SPILLED_VISIBLE) as u64); + } + // Paginar al inicio: window_start = Some(0) → carga desde la id 0. + cache.lock().unwrap().window_start = Some(0); + refresh_surf_spilled_visible(&history, &cache); + let c = cache.lock().unwrap(); + assert_eq!(c.first_id, 0, "la ventana arranca en el inicio del archive"); + assert_eq!(c.lines.len(), spilled, "todo el archive (< MAX_SPILLED_LOADED)"); + assert_eq!(c.lines.first(), Some(&"L0000".to_string())); + } + + #[test] + fn scroll_al_tope_pagina_el_archive_y_estabiliza() { + use llimphi_widget_terminal::{Scrollback, SpillStore}; + let mut s = State::new(Source::Local); + let dir = tempfile::tempdir().unwrap(); + let mut sb = Scrollback::new(10); + let spill = SpillStore::create(dir.path().join("t.spill")).unwrap(); + sb.enable_spill(spill); + // Suficientes para que la cola + una página no agoten el archive. + for i in 0..1000 { + sb.push_line(&format!("L{i:04}")); + } + *s.surf_history.lock().unwrap() = sb; + let spilled = s.surf_history.lock().unwrap().spilled_count(); + assert!(spilled > MAX_SPILLED_VISIBLE + SPILL_PAGE); + // Simulamos estar scrolled-up pegados al borde superior: overflow + // grande y scroll_px ~ overflow (scroll_y ≈ 0). + *s.out_overflow.lock().unwrap() = 1000.0; + s.scroll_px = 1000.0; + s.surf_scroll_anchor = 1000.0; + assert!(s.surf_spilled_visible.lock().unwrap().window_start.is_none()); + + // Rueda hacia arriba estando en el tope → pagina el archive. + s = crate::update::apply_scroll_delta(s, 50.0); + let ws = s.surf_spilled_visible.lock().unwrap().window_start; + assert_eq!( + ws, + Some((spilled - MAX_SPILLED_VISIBLE - SPILL_PAGE) as u64), + "retrocedió una página desde la cola" + ); + // El ancla subió (K·row_h) para que la vista no salte al prependear. + assert!(s.surf_scroll_anchor > 1000.0, "ancla compensada"); + + // Volver al fondo resetea la ventana a "cola" liviana. + *s.out_overflow.lock().unwrap() = 1000.0; + s.scroll_px = 0.0; + s = crate::update::apply_scroll_delta(s, -5000.0); // delta abajo fuerte + assert!(s.surf_spilled_visible.lock().unwrap().window_start.is_none()); + } + + #[test] + fn clear_output_tambien_resetea_history() { + let mut s = State::new(Source::Local); + s.push_output(OutputLine::stdout("a")); + s.push_output(OutputLine::stdout("b")); + assert_eq!(s.surf_history.lock().unwrap().len(), 2); + s.clear_output(); + assert_eq!(s.surf_history.lock().unwrap().len(), 0); + assert_eq!(s.surf_history.lock().unwrap().dropped(), 0); + } + + #[test] + fn scrollback_builtin_reporta_estado_en_notice() { + // Sin spill activo (default del Config), `:scrollback` reporta + // sólo líneas en memoria y avisa que el spill no está activo. + let mut s = State::new(Source::Local); + s.push_output(OutputLine::stdout("a")); + s.push_output(OutputLine::stdout("b")); + s.push_output(OutputLine::stdout("c")); + s.input.set_text(":scrollback"); + s = update(s, Msg::Key(KeyEvent { + key: Key::Named(NamedKey::Enter), + state: KeyState::Pressed, + text: None, + modifiers: llimphi_ui::Modifiers::default(), + repeat: false, + })); + // El último notice debe mencionar el conteo. + let last_notice = s.output.iter().rev() + .find(|l| l.kind == OutputKind::Notice) + .expect("notice"); + assert!( + last_notice.text.contains("scrollback") || last_notice.text.contains("spill"), + "notice menciona scrollback/spill: {}", last_notice.text + ); + } + + #[test] + fn output_snapshot_restore_round_trip() { + let mut s = State::new(Source::Local); + s.push_output(OutputLine::prompt("$ echo uno")); + s.push_output(OutputLine::stdout("uno")); + s.push_output(OutputLine::prompt("$ echo dos")); + s.push_output(OutputLine::stdout("dos")); + let snap = s.output_snapshot(1000); + assert_eq!(snap.lines.len(), 4); + assert_eq!(snap.block_seq, s.block_seq); + // JSON round-trip (lo que persiste el chasis). + let json = serde_json::to_string(&snap).expect("serializa"); + let back: OutputSnapshot = serde_json::from_str(&json).expect("parsea"); + + let mut s2 = State::new(Source::Local); + s2.restore_output(back); + // 4 líneas + el notice separador. + assert_eq!(s2.output.len(), 5); + // El bloque viejo no-último queda plegado; el último, abierto. + let primero = snap.lines[0].block; + let ultimo = snap.lines[3].block; + assert!(s2.collapsed.contains(&primero)); + assert!(!s2.collapsed.contains(&ultimo)); + // Los ids no se reciclan: block_seq avanza desde el snapshot. + assert!(s2.block_seq >= snap.block_seq); + // Lo nuevo abre bloque nuevo, no contamina los restaurados. + s2.push_output(OutputLine::prompt("$ echo tres")); + assert!(s2.current_block > ultimo); + } + + #[test] + fn output_snapshot_capea_y_conserva_metadata_de_bloques_presentes() { + let mut s = State::new(Source::Local); + for i in 0..50 { + s.push_output(OutputLine::prompt(format!("$ cmd {i}"))); + s.push_output(OutputLine::stdout(format!("salida {i}"))); + } + let snap = s.output_snapshot(10); + assert_eq!(snap.lines.len(), 10); + // Toda la metadata refiere a bloques presentes en el recorte. + let presentes: std::collections::HashSet = + snap.lines.iter().map(|l| l.block).collect(); + assert!(snap.block_command.keys().all(|b| presentes.contains(b))); + assert!(snap.block_started.keys().all(|b| presentes.contains(b))); + } + + /// Cotejo de un clic: marcar el bloque 1 deja el ancla; marcar el 2 dispara + /// `:compara %c1 %c2` y deja una card de cotejo en el output. + #[test] + fn compare_anchor_un_clic_marca_y_otro_dispara() { + let mut s = State::new(Source::Local); + let mut l1 = OutputLine::stdout("uno"); + l1.block = 1; + s.output.push(l1); + let mut l2 = OutputLine::stdout("dos"); + l2.block = 2; + s.output.push(l2); + + s = update(s, Msg::CompareWith(1)); + assert_eq!(s.compare_anchor, Some(1)); + + s = update(s, Msg::CompareWith(2)); + assert_eq!(s.compare_anchor, None); + assert!( + s.output.iter().any(|l| l.text.contains("≡ :compara %c1 ↔ %c2")), + "esperaba la card de cotejo entre %c1 y %c2" + ); + } + + /// Marcar dos veces el mismo bloque lo desmarca (toggle off). + #[test] + fn compare_anchor_toggle_desmarca() { + let mut s = State::new(Source::Local); + s = update(s, Msg::CompareWith(1)); + assert_eq!(s.compare_anchor, Some(1)); + s = update(s, Msg::CompareWith(1)); + assert_eq!(s.compare_anchor, None); + } diff --git a/02_ruway/shuma/sandbox/shuma-module-shell/src/tests/grupo_surf_select.rs b/02_ruway/shuma/sandbox/shuma-module-shell/src/tests/grupo_surf_select.rs new file mode 100644 index 0000000..cd65cc7 --- /dev/null +++ b/02_ruway/shuma/sandbox/shuma-module-shell/src/tests/grupo_surf_select.rs @@ -0,0 +1,467 @@ +use super::super::*; +use super::*; +use llimphi_ui::{Key, KeyEvent, KeyState, Modifiers, NamedKey}; + + + /// El SurfLayout snapshot que poblaríamos en `output_pane_surface` — + /// versión sintética para tests de la state machine, sin pasar por el + /// render. Cubre 3 líneas mono de 6 chars cada una. + fn synth_surf_layout() -> SurfLayout { + let metrics = llimphi_widget_terminal::TermMetrics { + font_size: 12.0, + line_height: 16.0, + char_width: 8.0, + }; + let mut store = llimphi_widget_terminal::Scrollback::new(0); + store.push_line("abcdef"); + store.push_line("ghijkl"); + store.push_line("mnopqr"); + SurfLayout { + items_geo: vec![llimphi_widget_terminal::ItemGeo::Lines(0, 3)], + scroll_y: 0.0, + viewport_h: 200.0, + metrics, + gutter_w: 30.0, + store: Arc::new(store), + block_ranges: Vec::new(), + } + } + + fn key(k: Key, ctrl: bool, shift: bool) -> KeyEvent { + KeyEvent { + key: k, + state: KeyState::Pressed, + text: None, + modifiers: Modifiers { ctrl, shift, ..Default::default() }, + repeat: false, + } + } + + #[test] + fn copy_mode_entra_mueve_extiende_y_copia_al_primary() { + let mut s = State::new(Source::Local); + *s.surf_layout.lock().unwrap() = Some(synth_surf_layout()); + // Ctrl+Shift+Espacio entra a copy-mode: caret al inicio de la última + // línea (2, 0), sin modo visual. + s = update(s, Msg::Key(key(Key::Character(" ".into()), true, true))); + assert!(s.surf_copy_mode, "entró a copy-mode"); + let sel = s.surf_selection.expect("caret ancla"); + assert_eq!(sel.head, llimphi_widget_terminal::Point::new(2, 0)); + assert!(sel.is_empty(), "arranca colapsado"); + // Shift+→ extiende un char: selecciona "m" de "mnopqr". + s = update(s, Msg::Key(key(Key::Named(NamedKey::ArrowRight), false, true))); + let sel = s.surf_selection.expect("sel viva"); + assert_eq!(sel.anchor, llimphi_widget_terminal::Point::new(2, 0)); + assert_eq!(sel.head, llimphi_widget_terminal::Point::new(2, 1)); + // Sin Shift, → colapsa el caret (no extiende). + s = update(s, Msg::Key(key(Key::Named(NamedKey::ArrowRight), false, false))); + let sel = s.surf_selection.expect("caret"); + assert!(sel.is_empty(), "movimiento sin shift colapsa"); + assert_eq!(sel.head, llimphi_widget_terminal::Point::new(2, 2)); + // Esc sale y limpia la selección. + s = update(s, Msg::Key(key(Key::Named(NamedKey::Escape), false, false))); + assert!(!s.surf_copy_mode, "Esc sale de copy-mode"); + assert!(s.surf_selection.is_none(), "al salir se limpia el highlight"); + } + + #[test] + fn copy_mode_modo_visual_extiende_sin_shift() { + let mut s = State::new(Source::Local); + *s.surf_layout.lock().unwrap() = Some(synth_surf_layout()); + s = update(s, Msg::Key(key(Key::Character(" ".into()), true, true))); + // `v` activa el modo visual: ahora el movimiento pelado extiende. + s = update(s, Msg::Key(key(Key::Character("v".into()), false, false))); + assert!(s.surf_copy_visual); + s = update(s, Msg::Key(key(Key::Character("l".into()), false, false))); + let sel = s.surf_selection.expect("sel"); + assert_eq!(sel.anchor, llimphi_widget_terminal::Point::new(2, 0)); + assert_eq!(sel.head, llimphi_widget_terminal::Point::new(2, 1)); + } + + #[test] + fn surf_select_drag_move_arranca_y_extiende_la_seleccion() { + let mut s = State::new(Source::Local); + *s.surf_layout.lock().unwrap() = Some(synth_surf_layout()); + // Primer Move: anchor en línea 0 col 2 (ax=50 = 30 gutter + 4 + // TEXT_LEFT_PADDING + 2*8 char_w, ay=4). + s = update( + s, + Msg::SurfSelectDrag { + phase: llimphi_ui::DragPhase::Move, + dx: 0.0, + dy: 0.0, + ax: 50.0, + ay: 4.0, + }, + ); + assert!(s.surf_selecting); + let sel = s.surf_selection.expect("anchor set"); + assert_eq!(sel.anchor, llimphi_widget_terminal::Point::new(0, 2)); + assert_eq!(sel.head, llimphi_widget_terminal::Point::new(0, 2)); + // Move siguiente: delta de (+32, +32) → acc = (78, 36) → fila 2, col 6. + s = update( + s, + Msg::SurfSelectDrag { + phase: llimphi_ui::DragPhase::Move, + dx: 32.0, + dy: 32.0, + ax: 50.0, + ay: 4.0, + }, + ); + let sel = s.surf_selection.expect("extended"); + assert_eq!(sel.anchor, llimphi_widget_terminal::Point::new(0, 2)); + assert_eq!(sel.head, llimphi_widget_terminal::Point::new(2, 6)); + } + + #[test] + fn surf_select_drag_end_libera_pero_mantiene_seleccion_para_copy() { + let mut s = State::new(Source::Local); + *s.surf_layout.lock().unwrap() = Some(synth_surf_layout()); + // Drag completo (Press → Move → End) cubriendo varios chars. + s = update(s, Msg::SurfSelectDrag { + phase: llimphi_ui::DragPhase::Move, dx: 0.0, dy: 0.0, ax: 50.0, ay: 4.0, + }); + s = update(s, Msg::SurfSelectDrag { + phase: llimphi_ui::DragPhase::Move, dx: 16.0, dy: 0.0, ax: 50.0, ay: 4.0, + }); + s = update(s, Msg::SurfSelectDrag { + phase: llimphi_ui::DragPhase::End, dx: 0.0, dy: 0.0, ax: 50.0, ay: 4.0, + }); + assert!(!s.surf_selecting, "End libera el flag"); + assert!(s.surf_selection.is_some(), "pero la selección queda para copy"); + } + + #[test] + fn surf_select_drag_end_sin_drag_real_limpia_la_seleccion_colapsada() { + // Un Press+End sin Move intermedio = click corto. La selección queda + // colapsada (anchor == head); el End la limpia para no dejar + // afford visual sin sentido. + let mut s = State::new(Source::Local); + *s.surf_layout.lock().unwrap() = Some(synth_surf_layout()); + s = update(s, Msg::SurfSelectDrag { + phase: llimphi_ui::DragPhase::Move, dx: 0.0, dy: 0.0, ax: 50.0, ay: 4.0, + }); + // Ahora un End sin Mover. + s = update(s, Msg::SurfSelectDrag { + phase: llimphi_ui::DragPhase::End, dx: 0.0, dy: 0.0, ax: 50.0, ay: 4.0, + }); + assert!(s.surf_selection.is_none(), "click sin drag → sin selección"); + } + + #[test] + fn surf_double_click_selecciona_la_palabra_bajo_el_punto() { + // Snapshot con "hola mundo querido" en la primera línea — el + // doble-click en col=6 (sobre 'u' de "mundo") debe seleccionar + // exactamente "mundo" (bytes 5..10). + let mut s = State::new(Source::Local); + let metrics = llimphi_widget_terminal::TermMetrics { + font_size: 12.0, + line_height: 16.0, + char_width: 8.0, + }; + let mut store = llimphi_widget_terminal::Scrollback::new(0); + store.push_line("hola mundo querido"); + *s.surf_layout.lock().unwrap() = Some(SurfLayout { + items_geo: vec![llimphi_widget_terminal::ItemGeo::Lines(0, 1)], + scroll_y: 0.0, + viewport_h: 200.0, + metrics, + gutter_w: 30.0, + store: Arc::new(store), + block_ranges: Vec::new(), + }); + // lx = 30 (gutter) + 6 * 8 (char 6) + 2 = 80. ly = 4 (centro fila 0). + s = update(s, Msg::SurfDoubleClick { lx: 80.0, ly: 4.0, rect_w: 400.0, rect_h: 200.0 }); + let sel = s.surf_selection.expect("selección de palabra"); + assert_eq!(sel.anchor, llimphi_widget_terminal::Point::new(0, 5)); + assert_eq!(sel.head, llimphi_widget_terminal::Point::new(0, 10)); + } + + #[test] + fn dos_double_clicks_seguidos_seleccionan_la_linea_entera() { + // tap-tap = word. tap-tap-tap-tap (dos pares) dentro de 350 ms = + // line (paridad xterm triple-click). El handler usa el timestamp + // ms entre los dos SurfDoubleClick. + let mut s = State::new(Source::Local); + let metrics = llimphi_widget_terminal::TermMetrics { + font_size: 12.0, line_height: 16.0, char_width: 8.0, + }; + let mut store = llimphi_widget_terminal::Scrollback::new(0); + store.push_line("hola mundo querido"); + *s.surf_layout.lock().unwrap() = Some(SurfLayout { + items_geo: vec![llimphi_widget_terminal::ItemGeo::Lines(0, 1)], + scroll_y: 0.0, + viewport_h: 200.0, + metrics, + gutter_w: 30.0, + store: Arc::new(store), + block_ranges: Vec::new(), + }); + // Primer double-click: selecciona "hola" (palabra). + s = update(s, Msg::SurfDoubleClick { lx: 50.0, ly: 4.0, rect_w: 400.0, rect_h: 200.0 }); + // Segundo double-click "inmediato": ahora selecciona toda la línea. + s = update(s, Msg::SurfDoubleClick { lx: 50.0, ly: 4.0, rect_w: 400.0, rect_h: 200.0 }); + let sel = s.surf_selection.expect("line select"); + assert_eq!(sel.anchor.line, 0); + assert_eq!(sel.anchor.col, 0); + assert_eq!(sel.head.col, "hola mundo querido".len()); + } + + #[test] + fn surf_double_click_sobre_separador_no_selecciona() { + // Double-click sobre un espacio o un delimitador no debe + // armar selección (paridad con xterm: si el click cae sobre + // whitespace exactamente, no hay palabra). + let mut s = State::new(Source::Local); + let metrics = llimphi_widget_terminal::TermMetrics { + font_size: 12.0, + line_height: 16.0, + char_width: 8.0, + }; + let mut store = llimphi_widget_terminal::Scrollback::new(0); + store.push_line("hola mundo querido"); + *s.surf_layout.lock().unwrap() = Some(SurfLayout { + items_geo: vec![llimphi_widget_terminal::ItemGeo::Lines(0, 1)], + scroll_y: 0.0, + viewport_h: 200.0, + metrics, + gutter_w: 30.0, + store: Arc::new(store), + block_ranges: Vec::new(), + }); + // Posicionar sobre el espacio entre "hola" y "mundo" (col=4 byte = ' '). + // lx = 30 + 4*8 + 2 = 64. + s = update( + s, + Msg::SurfDoubleClick { lx: 64.0, ly: 4.0, rect_w: 400.0, rect_h: 200.0 }, + ); + // El handler de doble-click absorbe el caso "después de palabra" y + // selecciona la palabra que termina ahí ("hola"). El otro caso + // (espacio en medio de la línea, no después de palabra) deja la + // selección sin tocar. Este test confirma que NO panic-ea. + // Si seleccionó algo, debe ser "hola" (bytes 0..4). + if let Some(sel) = s.surf_selection { + assert_eq!(sel.anchor.line, 0); + assert_eq!(sel.anchor.col, 0); + assert_eq!(sel.head.col, 4); + } + } + + #[test] + fn surf_open_y_dismiss_menu_actualiza_estado() { + let mut s = State::new(Source::Local); + s = update(s, Msg::SurfOpenMenu { x: 100.0, y: 50.0 }); + assert_eq!(s.surf_menu, Some((100.0, 50.0))); + s = update(s, Msg::SurfMenuDismiss); + assert!(s.surf_menu.is_none()); + } + + #[test] + fn surf_menu_pick_seleccionar_todo_arma_rango_full() { + // Item 2 = Seleccionar todo. Pone surf_selection desde (0,0) hasta + // el fin de la última línea del scrollback. + let mut s = State::new(Source::Local); + let metrics = llimphi_widget_terminal::TermMetrics { + font_size: 12.0, line_height: 16.0, char_width: 8.0, + }; + let mut store = llimphi_widget_terminal::Scrollback::new(0); + store.push_line("hola"); + store.push_line("mundo"); + store.push_line("xxx"); + *s.surf_layout.lock().unwrap() = Some(SurfLayout { + items_geo: vec![llimphi_widget_terminal::ItemGeo::Lines(0, 3)], + scroll_y: 0.0, + viewport_h: 200.0, + metrics, + gutter_w: 30.0, + store: Arc::new(store), + block_ranges: Vec::new(), + }); + s = update(s, Msg::SurfOpenMenu { x: 50.0, y: 50.0 }); + // Menú: 0=Copiar · 1=Pegar · 2=Copiar todo · 3=Seleccionar todo. + s = update(s, Msg::SurfMenuPick(3)); + let sel = s.surf_selection.expect("select all"); + assert_eq!(sel.anchor, llimphi_widget_terminal::Point::new(0, 0)); + // Última línea = "xxx" (3 bytes). + assert_eq!(sel.head, llimphi_widget_terminal::Point::new(2, 3)); + assert!(s.surf_menu.is_none(), "el pick cierra el menú"); + } + + #[test] + fn surf_clear_selection_resetea_estado() { + let mut s = State::new(Source::Local); + *s.surf_layout.lock().unwrap() = Some(synth_surf_layout()); + // Arranca un drag. + s = update(s, Msg::SurfSelectDrag { + phase: llimphi_ui::DragPhase::Move, dx: 0.0, dy: 0.0, ax: 50.0, ay: 4.0, + }); + s = update(s, Msg::SurfSelectDrag { + phase: llimphi_ui::DragPhase::Move, dx: 16.0, dy: 0.0, ax: 50.0, ay: 4.0, + }); + assert!(s.surf_selection.is_some()); + s = update(s, Msg::SurfClearSelection); + assert!(s.surf_selection.is_none()); + assert!(!s.surf_selecting); + } + + #[test] + fn comando_a_prependir_por_bloque() { + use std::collections::HashMap; + // Bloques: 7 → líneas [0,3); 42 → líneas [3,5). + let ranges = vec![(0usize, 3usize, 7u64), (3usize, 5usize, 42u64)]; + let mut cmds: HashMap = HashMap::new(); + cmds.insert(7, "$ ls -la".into()); + cmds.insert(42, "$ git status".into()); + + // Selección que arranca en la línea 0 → comando del bloque 7. + assert_eq!( + command_for_selection_start(&ranges, &cmds, 0).as_deref(), + Some("$ ls -la") + ); + // Arranca a mitad del cuerpo del bloque 7 (línea 2) → igual lo prepende. + assert_eq!( + command_for_selection_start(&ranges, &cmds, 2).as_deref(), + Some("$ ls -la") + ); + // Línea 3 → bloque 42. + assert_eq!( + command_for_selection_start(&ranges, &cmds, 3).as_deref(), + Some("$ git status") + ); + // Línea fuera de todo rango → None (no prepende nada). + assert_eq!(command_for_selection_start(&ranges, &cmds, 9), None); + // Bloque sin comando conocido → None. + let ranges2 = vec![(0usize, 2usize, 99u64)]; + assert_eq!(command_for_selection_start(&ranges2, &cmds, 0), None); + } + + #[test] + fn surf_menu_acciones_shell_con_seleccion_incluye_ejecutar() { + use crate::update::{surf_menu_actions, SurfMenuAction::*}; + let mut s = State::new(Source::Local); + // Modo shell (sin PTY vivo) + selección no vacía. + s.surf_selection = Some(llimphi_widget_terminal::SelectionRange { + anchor: llimphi_widget_terminal::Point::new(0, 0), + head: llimphi_widget_terminal::Point::new(0, 4), + }); + assert_eq!( + surf_menu_actions(&s), + vec![Copiar, Ejecutar, EjecutarNuevoTab, Pegar, CopiarTodo, SeleccionarTodo] + ); + // Sin selección: Ejecutar/EjecutarNuevoTab desaparecen. + s.surf_selection = None; + assert_eq!( + surf_menu_actions(&s), + vec![Copiar, Pegar, CopiarTodo, SeleccionarTodo] + ); + } + + #[test] + fn surf_menu_pick_ejecutar_nuevo_tab_deja_intent_para_el_host() { + let mut s = State::new(Source::Local); + let mut store = llimphi_widget_terminal::Scrollback::new(0); + store.push_line("git status"); + *s.surf_layout.lock().unwrap() = Some(SurfLayout { + items_geo: vec![llimphi_widget_terminal::ItemGeo::Lines(0, 1)], + scroll_y: 0.0, + viewport_h: 200.0, + metrics: llimphi_widget_terminal::TermMetrics { + font_size: 12.0, line_height: 16.0, char_width: 8.0, + }, + gutter_w: 30.0, + store: Arc::new(store), + block_ranges: Vec::new(), + }); + // Selección = "git status" (línea 0 completa, 10 bytes). + s.surf_selection = Some(llimphi_widget_terminal::SelectionRange { + anchor: llimphi_widget_terminal::Point::new(0, 0), + head: llimphi_widget_terminal::Point::new(0, 10), + }); + s = update(s, Msg::SurfOpenMenu { x: 10.0, y: 10.0 }); + // Menú con selección en modo shell: + // 0=Copiar · 1=Ejecutar · 2=Ejecutar en nuevo tab · 3=Pegar · … + s = update(s, Msg::SurfMenuPick(2)); + assert_eq!(s.take_new_tab_cmd().as_deref(), Some("git status")); + assert!(s.surf_menu.is_none(), "el pick cierra el menú"); + } + + /// Alto de fila de los tests del HWM: con él, el tope son 6×16 = 96 px. + const RH: f32 = 16.0; + + #[test] + fn hwm_reserva_hueco_solo_pinned_y_resetea() { + // F1 — marca de agua alta monótona: el hueco reservado sólo crece estando + // pegado al fondo; un efímero que se va deja su hueco; el corte natural lo + // recupera. + use crate::view::hwm_gap; + let mut s = State::new(Source::Local); + s.scroll_px = 0.0; // pinned al fondo + // Primer frame con 100px de contenido: sin hueco, HWM sube a 100. + assert_eq!(hwm_gap(&s, 100.0, RH), 0.0); + // Un efímero desaparece → el contenido cae a 60: el HWM (100) reserva 40. + assert_eq!(hwm_gap(&s, 60.0, RH), 40.0); + // Output real crece a 120 → el HWM sube, sin hueco. + assert_eq!(hwm_gap(&s, 120.0, RH), 0.0); + // Cae de nuevo a 60 → serían 60 de hueco, dentro del tope (96): se reserva. + assert_eq!(hwm_gap(&s, 60.0, RH), 60.0); + // Corte natural (clear / comando nuevo): reset devuelve el HWM a 0. + s.scroll_px = 0.0; + s.reset_content_hwm(); + assert_eq!(hwm_gap(&s, 60.0, RH), 0.0); // HWM ahora 60, sin hueco + assert_eq!(hwm_gap(&s, 40.0, RH), 20.0); // cae a 40 → hueco 20 + } + + /// El hueco es para efímeros de unos renglones, **no para pantallas**. Si el + /// contenido encoge más que el tope, eso ya no es un spinner que se fue sino + /// un cambio estructural (una TUI que terminó, el scrollback recortado, un + /// re-wrap por zoom): el HWM se re-basa y no queda vacío. Regresión del + /// «hueco de toda una pantalla abajo de todo» (25-jul). + #[test] + fn el_hueco_no_puede_ser_una_pantalla() { + use crate::view::hwm_gap; + let mut s = State::new(Source::Local); + s.scroll_px = 0.0; + // 2000 px de contenido (una TUI larga)… + assert_eq!(hwm_gap(&s, 2000.0, RH), 0.0); + // …y termina: quedan 200. Encogió 1800, muchísimo más que 6 filas. + assert_eq!( + hwm_gap(&s, 200.0, RH), + 0.0, + "un encogimiento estructural no puede dejar un vacío enorme" + ); + // Y el HWM quedó re-basado: desde acá, un efímero chico sí reserva. + assert_eq!(hwm_gap(&s, 200.0, RH), 0.0); + assert_eq!(hwm_gap(&s, 170.0, RH), 30.0); + // Nunca por encima del tope aunque el HWM venga alto. + let mut s2 = State::new(Source::Local); + s2.scroll_px = 0.0; + assert_eq!(hwm_gap(&s2, 300.0, RH), 0.0); + assert_eq!(hwm_gap(&s2, 205.0, RH), 95.0, "95 < 96, entra justo"); + } + + /// El brinco: con el hueco reservado, el primer paso de rueda NO puede + /// evaporarlo — si el gap volviera a 0 al scrollear, el contenido se acorta + /// de golpe bajo el dedo y la vista salta todo el hueco («subo un punto y + /// brinca más de una página», 25-jul). + #[test] + fn scrollear_no_evapora_el_hueco_reservado() { + use crate::view::hwm_gap; + let mut s = State::new(Source::Local); + s.scroll_px = 0.0; + assert_eq!(hwm_gap(&s, 100.0, RH), 0.0); + let reservado = hwm_gap(&s, 60.0, RH); + assert_eq!(reservado, 40.0); + // El usuario rueda un punto: el hueco sigue siendo el mismo. + s.scroll_px = 10.0; + assert_eq!( + hwm_gap(&s, 60.0, RH), + reservado, + "el hueco se evaporó al scrollear: la vista brincaría" + ); + // Y sigue congelado aunque el contenido respire mientras mira historial. + assert_eq!(hwm_gap(&s, 55.0, RH), reservado); + // Al volver al fondo se recalcula normalmente. + s.scroll_px = 0.0; + assert_eq!(hwm_gap(&s, 100.0, RH), 0.0); + } diff --git a/02_ruway/shuma/sandbox/shuma-module-shell/src/tests/grupo_term_queries.rs b/02_ruway/shuma/sandbox/shuma-module-shell/src/tests/grupo_term_queries.rs new file mode 100644 index 0000000..2bfde4e --- /dev/null +++ b/02_ruway/shuma/sandbox/shuma-module-shell/src/tests/grupo_term_queries.rs @@ -0,0 +1,81 @@ +//! Respuestas a las **queries del terminal** (DA1/DSR/CPR/OSC/…): sin ellas un +//! TUI moderno (claude/Ink, crossterm) se bloquea en el arranque esperando que +//! el "terminal" conteste. El vt100 no responde nada; lo hace [`TuiSession`]. + +use crate::types::TuiSession; + +fn respuestas(tui: &mut TuiSession, bytes: &[u8]) -> Vec> { + tui.process_bytes(bytes) +} + +#[test] +fn da1_la_contesta_la_capa_grafica_una_sola_vez() { + // DA1 la responde el GraphicsScanner (anuncia sixel); el escáner de + // queries NO debe duplicarla. + let mut tui = TuiSession::new("claude", 24, 80); + let r = respuestas(&mut tui, b"\x1b[c"); + assert_eq!(r, vec![b"\x1b[?62;4c".to_vec()]); +} + +#[test] +fn cpr_reporta_posicion_uno_basada() { + let mut tui = TuiSession::new("claude", 24, 80); + // Mover el cursor a fila 3, col 5 (1-based) y luego preguntar. + let r = respuestas(&mut tui, b"\x1b[3;5H\x1b[6n"); + assert_eq!(r, vec![b"\x1b[3;5R".to_vec()]); +} + +#[test] +fn query_partida_entre_chunks_no_se_pierde() { + let mut tui = TuiSession::new("claude", 24, 80); + // El PTY entrega la secuencia cortada a mitad: primero ESC [, luego 6 n. + assert!(respuestas(&mut tui, b"hola \x1b[").is_empty()); + // La respuesta refleja el cursor al FINAL del chunk (tras " mundo": col 12, + // 1-based) — el programa que sondea manda la query al final de su burst. + let r = respuestas(&mut tui, b"6n mundo"); + assert_eq!(r, vec![b"\x1b[1;12R".to_vec()]); +} + +#[test] +fn osc11_contesta_fondo_oscuro() { + let mut tui = TuiSession::new("claude", 24, 80); + let r = respuestas(&mut tui, b"\x1b]11;?\x07"); + assert_eq!(r, vec![b"\x1b]11;rgb:1414/1414/1a1a\x1b\\".to_vec()]); + // Con terminador ST también. + let r = respuestas(&mut tui, b"\x1b]10;?\x1b\\"); + assert_eq!(r, vec![b"\x1b]10;rgb:e6e6/e6e6/e6e6\x1b\\".to_vec()]); +} + +#[test] +fn tamanio_en_celdas_y_pixeles() { + let mut tui = TuiSession::new("htop", 40, 120); + let r = respuestas(&mut tui, b"\x1b[18t\x1b[14t"); + assert_eq!(r[0], b"\x1b[8;40;120t".to_vec()); + assert_eq!(r[1], b"\x1b[4;640;960t".to_vec()); +} + +#[test] +fn decrqm_reporta_no_reconocido() { + let mut tui = TuiSession::new("claude", 24, 80); + let r = respuestas(&mut tui, b"\x1b[?2026$p"); + assert_eq!(r, vec![b"\x1b[?2026;0$y".to_vec()]); +} + +#[test] +fn secuencias_normales_no_generan_respuestas() { + let mut tui = TuiSession::new("vim", 24, 80); + // SGR de colores, movimiento, borrado, texto: nada que contestar. + let r = respuestas(&mut tui, b"\x1b[1;31mrojo\x1b[0m\x1b[2J\x1b[H texto plano \x1b[?1049h"); + assert!(r.is_empty(), "sin queries no hay respuestas: {r:?}"); +} + +#[test] +fn dsr5_y_da2_y_xtversion() { + let mut tui = TuiSession::new("claude", 24, 80); + let r = respuestas(&mut tui, b"\x1b[5n\x1b[>c\x1b[>0q"); + // DA2 la contesta la capa gráfica; DSR5 y XTVERSION el escáner de queries + // (las gráficas llegan primero en el vector de respuestas). + assert!(r.contains(&b"\x1b[>1;95;0c".to_vec()), "falta DA2: {r:?}"); + assert!(r.contains(&b"\x1b[0n".to_vec()), "falta DSR5: {r:?}"); + assert!(r.contains(&b"\x1bP>|shuma 1.0\x1b\\".to_vec()), "falta XTVERSION: {r:?}"); +} diff --git a/02_ruway/shuma/sandbox/shuma-module-shell/src/tests/mod.rs b/02_ruway/shuma/sandbox/shuma-module-shell/src/tests/mod.rs new file mode 100644 index 0000000..7dbb9b9 --- /dev/null +++ b/02_ruway/shuma/sandbox/shuma-module-shell/src/tests/mod.rs @@ -0,0 +1,49 @@ +use super::*; +use llimphi_ui::Modifiers; + + fn ev(key: Key, text: Option<&str>) -> KeyEvent { + KeyEvent { + key, + state: KeyState::Pressed, + text: text.map(|s| s.to_string()), + modifiers: Modifiers::default(), + repeat: false, + } + } + + /// Aplica `Msg::Tick` hasta que el run vivo se cierre (o se acabe el + /// presupuesto). Imita lo que el chasis hace a 100 ms entre ticks. + fn drain_until_idle(mut s: State) -> State { + let deadline = std::time::Instant::now() + std::time::Duration::from_secs(10); + while s.is_running() { + s = update(s, Msg::Tick); + if std::time::Instant::now() > deadline { + panic!("run no terminó en 10s"); + } + std::thread::sleep(std::time::Duration::from_millis(10)); + } + // Un Tick más por si quedó algo en el canal después del Exited. + update(s, Msg::Tick) + } + +mod grupo_builtins; +mod grupo_external_rules; +mod grupo_bloques_io; +mod grupo_ia; +mod grupo_run_async; +mod grupo_completion_history; +mod grupo_spec; +mod grupo_prediccion; +mod grupo_config_alias; +mod grupo_groups_reprocess; +mod grupo_completion_layers; +mod grupo_jobs_input; +mod grupo_decorations_graph; +mod grupo_bloques_layout; +mod grupo_scroll; +mod grupo_surf_select; +mod grupo_find; +mod grupo_spill; +mod grupo_cosecha_claude; +mod grupo_term_queries; +mod grupo_ajuste_blando; diff --git a/02_ruway/shuma/sandbox/shuma-module-shell/src/types.rs b/02_ruway/shuma/sandbox/shuma-module-shell/src/types.rs new file mode 100644 index 0000000..f0300be --- /dev/null +++ b/02_ruway/shuma/sandbox/shuma-module-shell/src/types.rs @@ -0,0 +1,2313 @@ +use super::*; + +/// Tipo de cada línea del buffer — define el color que la `view` usa. +#[derive(Debug, Clone, Copy, PartialEq, Eq, serde::Serialize, serde::Deserialize)] +pub enum OutputKind { + /// El comando tal como lo tipeó el usuario (precede a su output). + Prompt, + /// stdout del comando. + Stdout, + /// stderr del comando. + Stderr, + /// Mensaje del shell mismo (cd, error de spawn, exit status, etc.). + Notice, + /// Respuesta del LLM (`:explica`/`:resume`/`:filtra`/`:haz` cuando produce + /// texto). Se trata como **salida de primera clase**: es parte del cuerpo + /// del bloque, se tiñe distinto y la recogen `gather_block_text` + los + /// redireccionadores (`%cN`, `:write`, `:yank`, `:filtra`) — así una + /// respuesta de IA se puede volver a filtrar, guardar o encadenar. + Ai, +} + +/// Una línea del buffer de output con su tipo (para coloreado) y el +/// bloque de comando al que pertenece. El render agrupa las líneas con +/// el mismo `block` en una *card* desplegable (un `$ cmd` + su salida + +/// su exit status). `block == 0` = líneas sueltas sin comando dueño. +#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)] +pub struct OutputLine { + pub kind: OutputKind, + pub text: String, + /// Bloque de comando. Lo asigna [`State::push_output`] — cada + /// `Prompt` abre uno nuevo (id monotónico) y las siguientes líneas + /// lo heredan. Por defecto `0` (las constructoras no lo conocen). + pub block: u64, + /// Etapa intermedia del pipe que produjo la línea (tee de + /// `shuma-exec`), 0-based. `None` = salida normal (de la última etapa + /// o de un comando suelto). El render guarda estas líneas para el + /// desplegable de su etapa en vez de mezclarlas con el cuerpo. + pub stage: Option, + /// Runs de color REALES `(col_desde, col_hasta, rgba)` — los trae la + /// cosecha del scrollback de un PTY (colores del programa, p. ej. + /// claude). `None` = sin coloreo propio (el render decora por + /// heurística). En columnas de CHAR. + #[serde(default)] + pub runs: Option>, +} + +impl OutputLine { + pub fn prompt(text: impl Into) -> Self { + Self { + kind: OutputKind::Prompt, + text: text.into(), + block: 0, + stage: None, + runs: None, + } + } + pub fn stdout(text: impl Into) -> Self { + Self { + kind: OutputKind::Stdout, + text: text.into(), + block: 0, + stage: None, + runs: None, + } + } + pub fn stderr(text: impl Into) -> Self { + Self { + kind: OutputKind::Stderr, + text: text.into(), + block: 0, + stage: None, + runs: None, + } + } + pub fn notice(text: impl Into) -> Self { + Self { + kind: OutputKind::Notice, + text: text.into(), + block: 0, + stage: None, + runs: None, + } + } + /// Línea de respuesta del LLM (`OutputKind::Ai`) — cuerpo de primera clase, + /// redireccionable y re-filtrable como cualquier stdout. + pub fn ai(text: impl Into) -> Self { + Self { + kind: OutputKind::Ai, + text: text.into(), + block: 0, + stage: None, + runs: None, + } + } + /// Línea de la cosecha del PTY con sus runs de color reales. + pub fn stdout_con_runs(text: impl Into, runs: Vec<(u32, u32, [u8; 4])>) -> Self { + Self { + kind: OutputKind::Stdout, + text: text.into(), + block: 0, + stage: None, + runs: (!runs.is_empty()).then_some(runs), + } + } + + /// Línea capturada de una etapa intermedia del pipe (tee en vivo). Se + /// guarda con su `stage` para el desplegable correspondiente. + pub fn stage_stdout(stage: usize, text: impl Into) -> Self { + Self { + kind: OutputKind::Stdout, + text: text.into(), + block: 0, + stage: Some(stage), + runs: None, + } + } +} + +/// Run vivo: handle de ejecución (local directo o vía daemon), un +/// `Killer` opcional (solo en local — el remoto matamos cerrando el +/// stream) y el comando original (para el notice de cierre). +pub struct ActiveRun { + pub handle: BackendHandle, + /// `Some` cuando el run es local (`shuma-exec::RunHandle.killer()`). + /// `None` cuando es remoto — la cancelación va por `handle.kill()`. + pub killer: Option, + pub command: String, + /// Sesión TUI: emulador vt100 + dims del PTY. `Some` cuando el run + /// arrancó bajo `Exec::Pty` (vim/htop/less/etc.); las teclas van al + /// stdin del PTY y la pantalla se renderiza como grid de celdas. + /// El daemon no soporta PTY remoto todavía — TUIs forzados a local. + pub tui: Option, + /// Bloque de output al que se adjunta TODA la salida de este run — + /// fijo desde el arranque. Sin esto, un comando lento que drena en + /// ticks posteriores se mezclaría con el bloque "actual" (p. ej. un + /// builtin tipeado mientras corre), o un job de fondo se metería en + /// la card del foreground. Cada run vive en su propia card. + pub block: u64, + /// `Some(id)` si este run es una **sesión persistente del daemon** + /// (tipo tmux): cerrar el frontend la desadjunta sin matarla, y el id + /// queda registrado para el auto-reattach al próximo arranque. + pub session: Option, +} + +/// Backend de ejecución abstracto. Local va por `shuma-exec`; Daemon +/// (Unix o TCP) va por `shuma-remote-exec`. La API expuesta al módulo +/// shell (`try_events`, `is_finished`, `kill`, `write_input`, `resize`) +/// es la misma — las operaciones de PTY son no-op en remoto. +pub enum BackendHandle { + Local(RunHandle), + Remote(RemoteRunHandle), +} + +impl BackendHandle { + pub fn try_events(&mut self) -> Vec { + match self { + BackendHandle::Local(h) => h.try_events(), + BackendHandle::Remote(h) => h.try_events(), + } + } + /// Como [`try_events`], pero limitado a `max` eventos por tick. El resto + /// queda en la cola del backend para el próximo llamado. Necesario para + /// no pasmar el render con ráfagas grandes (`ls -alR`, builds verbose). + pub fn try_events_limit(&mut self, max: usize) -> Vec { + match self { + BackendHandle::Local(h) => h.try_events_limit(max), + // El backend remoto todavía drena todo de una; cuando soporte + // límite, encadenamos. Mientras tanto, el limit es un techo + // suave (no rompe nada, solo no rinde igual con un remoto + // que escupe rápido). + BackendHandle::Remote(h) => h.try_events(), + } + } + pub fn is_finished(&self) -> bool { + match self { + BackendHandle::Local(h) => h.is_finished(), + BackendHandle::Remote(h) => h.is_finished(), + } + } + pub fn kill(&self) { + match self { + BackendHandle::Local(h) => h.kill(), + BackendHandle::Remote(h) => h.kill(), + } + } + pub fn write_input(&self, bytes: Vec) -> bool { + match self { + BackendHandle::Local(h) => h.write_input(bytes), + // En PTY remoto, el asa enruta las teclas al daemon; en runs + // remotos no-PTY es no-op (devuelve false). + BackendHandle::Remote(h) => h.write_input(bytes), + } + } + pub fn resize(&self, rows: u16, cols: u16) -> bool { + match self { + BackendHandle::Local(h) => h.resize(rows, cols), + BackendHandle::Remote(h) => h.resize(rows, cols), + } + } +} + +/// Skin de render para un programa bajo PTY. `Generic` pinta la grilla +/// vt100 cruda; los demás reconstruyen la pantalla como un card +/// themeable propio del programa (deja de verse "como por un vidrio"). +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum AppSkin { + /// Grilla de celdas vt100 (htop, less, man, btop, …). + Generic, + /// vim/nvim/vi: el buffer como texto en la paleta del tema. + Vim, + /// claude code: un card grande que engloba la sesión (por ahora cae + /// al genérico hasta que esté el parser de bloques). + Claude, +} + +/// Estado de actividad de una sesión/panel — alimenta el aviso visual +/// (color del LED en el diente y en la tab). Tres signos que el usuario pidió: +/// quieto, con movimiento (algo corriendo / saliendo output) y claude. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum Activity { + /// Sin comando en curso ni novedad — prompt ocioso. + Idle, + /// Hay un comando corriendo (foreground): movimiento. + Busy, + /// La sesión corre `claude` (TUI) — color propio para distinguirla. + Claude, +} + +/// Elige el skin a partir del nombre del programa (acepta un path — +/// toma el basename). +pub fn app_skin_for(program: &str) -> AppSkin { + let base = program.rsplit('/').next().unwrap_or(program); + match base { + "vi" | "vim" | "nvim" | "view" | "nvi" => AppSkin::Vim, + "claude" => AppSkin::Claude, + _ => AppSkin::Generic, + } +} + +/// Una imagen de protocolo de terminal (kitty/sixel) ya decodificada a un +/// `peniko::Image`, con su anclaje en celdas de la grilla. `cols`/`rows` son +/// las celdas que el protocolo pidió (kitty `c=`/`r=`); `0` = derivar del +/// tamaño en píxeles al pintar. Vive en [`TuiSession::images`] mientras el PTY +/// corre y se hornea en [`State::block_images`] al cerrar el comando, para que +/// sobreviva en el scrollback. +#[derive(Clone)] +pub struct TermImage { + pub image: llimphi_image::Image, + pub col: u16, + pub row: u16, + pub cols: u16, + pub rows: u16, + pub px_w: u32, + pub px_h: u32, +} + +/// Una **query del terminal** detectada en el stream del PTY: el programa la +/// emite y se queda ESPERANDO la respuesta por stdin. El vt100 las parsea pero +/// no contesta ninguna — sin respuestas, un TUI moderno (claude/Ink, crossterm) +/// se bloquea en el arranque interrogando a un terminal mudo. +/// Nota: DA1 (`CSI c`) y DA2 (`CSI > c`) NO están aquí — ya las contesta el +/// `GraphicsScanner` de `llimphi-term-graphics` (anuncia sixel `?62;4c`); +/// duplicar la respuesta confundiría al programa. +#[derive(Debug, Clone, PartialEq, Eq)] +pub(crate) enum TermQuery { + /// DSR 5 `CSI 5 n` — «¿estás ok?». + Dsr5, + /// CPR `CSI 6 n` — posición del cursor. Muchas libs la usan como sonda + /// síncrona de arranque (Ink/Node incluida). + Cpr, + /// DECXCPR `CSI ? 6 n` — CPR extendida. + CprDec, + /// DECRQM `CSI ? Ps $ p` — «¿está seteado el modo Ps?». + DecRqm(String), + /// XTVERSION `CSI > 0 q` — nombre/versión del terminal. + XtVersion, + /// `CSI 18 t` — tamaño de la ventana en celdas. + WinChars, + /// `CSI 14 t` — tamaño de la ventana en px. + WinPx, + /// `CSI 16 t` — tamaño de una celda en px. + CellPx, + /// OSC 10 `?` — color de foreground (detección claro/oscuro). + OscFg, + /// OSC 11 `?` — color de background (detección claro/oscuro). + OscBg, +} + +/// Texto + runs de color `(col_desde, col_hasta, rgba)` de la fila `r` del +/// screen (respeta la vista de scrollback activa). Los colores Idx/Rgb van a +/// RGBA concreto (paleta ANSI fija, independiente del theme); Default no +/// genera run (el render usa el color del theme). Bold se aproxima aclarando. +fn fila_con_runs( + screen: &vt100::Screen, + r: u16, + cols: u16, +) -> (String, Vec<(u32, u32, [u8; 4])>) { + let mut texto = String::new(); + let mut runs: Vec<(u32, u32, [u8; 4])> = Vec::new(); + let mut col_char: u32 = 0; + let mut run: Option<(u32, [u8; 4])> = None; + let mut cerrar = |run: &mut Option<(u32, [u8; 4])>, hasta: u32, runs: &mut Vec<(u32, u32, [u8; 4])>| { + if let Some((desde, rgba)) = run.take() { + if hasta > desde { + runs.push((desde, hasta, rgba)); + } + } + }; + for c in 0..cols { + let Some(cell) = screen.cell(r, c) else { continue }; + if cell.is_wide_continuation() { + continue; + } + let contents = cell.contents(); + let contents: &str = if contents.is_empty() { " " } else { contents }; + let ancho = cell.is_wide(); + let rgba: Option<[u8; 4]> = match cell.fgcolor() { + vt100::Color::Default => None, + vt100::Color::Rgb(rr, gg, bb) => Some([rr, gg, bb, 255]), + vt100::Color::Idx(i) => { + let col = crate::view::ansi_idx_to_color(i); + Some([ + (col.components[0] * 255.0) as u8, + (col.components[1] * 255.0) as u8, + (col.components[2] * 255.0) as u8, + 255, + ]) + } + }; + match (run.as_ref().map(|(_, c)| *c), rgba) { + (Some(a), Some(b)) if a == b => {} + (None, None) => {} + _ => { + cerrar(&mut run, col_char, &mut runs); + if let Some(rgba) = rgba { + run = Some((col_char, rgba)); + } + } + } + texto.push_str(contents); + col_char += contents.chars().count() as u32; + // Glifo ANCHO (emoji/CJK): ocupa 2 celdas del terminal pero 1 char — + // sin relleno, todo lo que sigue en la fila queda corrido 1 columna + // (el "descuadre" de espacios). Un espacio restituye la paridad. + if ancho { + texto.push(' '); + col_char += 1; + } + } + cerrar(&mut run, col_char, &mut runs); + let recortado = texto.trim_end().to_string(); + let fin = recortado.chars().count() as u32; + runs.retain_mut(|(d, h, _)| { + *h = (*h).min(fin); + *d < *h + }); + (recortado, runs) +} + +/// Detector de [`TermQuery`]s en el stream del PTY. Mantiene un carry chico +/// para secuencias partidas entre chunks (el PTY entrega en bloques +/// arbitrarios). Es un escáner paralelo al vt100: NO consume nada del stream +/// (el parser ve los mismos bytes), sólo detecta qué hay que contestar. +pub(crate) struct QueryScanner { + carry: Vec, +} + +/// Resultado de intentar parsear una secuencia que empieza en ESC. +enum SeqParse { + /// Secuencia completa de `len` bytes, con query detectada o no. + Seq(usize, Option), + /// Faltan bytes (chunk cortado a mitad de secuencia). + Incomplete, +} + +impl QueryScanner { + pub(crate) fn new() -> Self { + Self { carry: Vec::new() } + } + + /// Escanea un chunk (con el carry previo antepuesto) y devuelve las + /// queries completas encontradas; una secuencia cortada queda en el carry. + pub(crate) fn scan(&mut self, bytes: &[u8]) -> Vec { + let mut data = std::mem::take(&mut self.carry); + data.extend_from_slice(bytes); + let mut out = Vec::new(); + let mut i = 0; + while i < data.len() { + if data[i] != 0x1b { + i += 1; + continue; + } + match Self::parse_seq(&data[i..]) { + SeqParse::Seq(len, q) => { + if let Some(q) = q { + out.push(q); + } + i += len.max(1); + } + SeqParse::Incomplete => { + // Guardamos la cola acotada: una "secuencia" que no cierra + // en 256 bytes no es una query real — la soltamos. + let tail = &data[i..]; + if tail.len() <= 256 { + self.carry = tail.to_vec(); + } + break; + } + } + } + out + } + + /// Parsea UNA secuencia que arranca en `data[0] == ESC`. Sólo clasifica + /// las queries conocidas; el resto se consume sin query. + fn parse_seq(data: &[u8]) -> SeqParse { + if data.len() < 2 { + return SeqParse::Incomplete; + } + match data[1] { + // CSI: ESC [ params/intermedios final(0x40..0x7e) + b'[' => { + let mut j = 2; + while j < data.len() { + let b = data[j]; + if (0x40..=0x7e).contains(&b) { + let params = &data[2..j]; + return SeqParse::Seq(j + 1, Self::classify_csi(params, b)); + } + if !(0x20..=0x3f).contains(&b) { + // Byte ilegal dentro de un CSI: no es una secuencia. + return SeqParse::Seq(j, None); + } + j += 1; + } + SeqParse::Incomplete + } + // OSC: ESC ] … BEL | ESC \ + b']' => match Self::find_st(&data[2..]) { + Some((body_len, term_len)) => { + let body = &data[2..2 + body_len]; + let q = match body { + b"10;?" => Some(TermQuery::OscFg), + b"11;?" => Some(TermQuery::OscBg), + _ => None, + }; + SeqParse::Seq(2 + body_len + term_len, q) + } + None => { + if data.len() > 4096 { + // OSC gigante sin terminador (payload raro): rendirse. + SeqParse::Seq(data.len(), None) + } else { + SeqParse::Incomplete + } + } + }, + // DCS/PM/APC: consumir hasta ST (las gráficas ya las sacó el + // GraphicsScanner; esto es sólo para no partir el escaneo). + b'P' | b'^' | b'_' => match Self::find_st(&data[2..]) { + Some((body_len, term_len)) => SeqParse::Seq(2 + body_len + term_len, None), + None => { + if data.len() > 4096 { + SeqParse::Seq(data.len(), None) + } else { + SeqParse::Incomplete + } + } + }, + // Cualquier otro ESC X de dos bytes. + _ => SeqParse::Seq(2, None), + } + } + + /// Busca el terminador de una cadena OSC/DCS: BEL o ST (`ESC \`). Devuelve + /// `(largo_del_cuerpo, largo_del_terminador)`. + fn find_st(data: &[u8]) -> Option<(usize, usize)> { + for (k, b) in data.iter().enumerate() { + if *b == 0x07 { + return Some((k, 1)); + } + if *b == 0x1b && data.get(k + 1) == Some(&b'\\') { + return Some((k, 2)); + } + } + None + } + + /// Clasifica un CSI por sus params/intermedios + byte final. + fn classify_csi(params: &[u8], fin: u8) -> Option { + match (params, fin) { + (b"5", b'n') => Some(TermQuery::Dsr5), + (b"6", b'n') => Some(TermQuery::Cpr), + (b"?6", b'n') => Some(TermQuery::CprDec), + (b"18", b't') => Some(TermQuery::WinChars), + (b"14", b't') => Some(TermQuery::WinPx), + (b"16", b't') => Some(TermQuery::CellPx), + (b">" | b">0", b'q') => Some(TermQuery::XtVersion), + (p, b'p') if p.first() == Some(&b'?') && p.last() == Some(&b'$') => { + let modo = String::from_utf8_lossy(&p[1..p.len() - 1]).to_string(); + (!modo.is_empty() && modo.bytes().all(|b| b.is_ascii_digit())) + .then_some(TermQuery::DecRqm(modo)) + } + _ => None, + } + } +} + +/// Sesión TUI sobre PTY — bufferea el parser vt100 y los dims actuales. +pub struct TuiSession { + pub parser: vt100::Parser, + /// Buzón del [`crate::campana::Campanario`] montado en el parser: campanadas + /// (BEL), título OSC y notificaciones de escritorio. El parser se queda con + /// los callbacks por valor, así que la lectura va por este handle. + buzon: std::sync::Arc>, + pub rows: u16, + pub cols: u16, + /// Programa bajo el PTY (basename incluido) — define el skin. + pub program: String, + /// Skin de render elegido al arrancar. + pub skin: AppSkin, + /// Separa las secuencias gráficas (kitty/sixel) del texto/ANSI antes de + /// alimentar el vt100. Mantiene estado entre chunks (transmisión chunked). + pub scanner: llimphi_term_graphics::GraphicsScanner, + /// Detecta las queries del terminal (DA1/CPR/OSC 10-11/…) que hay que + /// contestar por stdin — el vt100 no responde ninguna y los TUIs modernos + /// (claude/Ink, crossterm) se bloquean esperando. + queries: QueryScanner, + /// Imágenes vivas colocadas por el programa, en orden de aparición. + pub images: Vec, + /// Filas del scrollback ya cosechadas (ver [`Self::cosechar`]). + pub cosechadas: usize, + /// Filas VIVAS ya cosechadas del scrollback (dedup con la pre-cosecha). + pub pre_cosechadas: usize, + /// Líneas del área viva ya EXPORTADAS al historial, en orden. claude/Ink + /// re-renderiza in-place SIN scrollear (todo vive en pocas filas que se + /// reescriben), así que el tracking es por CONTENIDO, no por posición: se + /// exporta el prefijo estable nuevo que aún no está aquí. + pub exportadas: Vec, + /// Estabilidad POR LÍNEA: `(hash, ticks)` de cada fila del screen. Una + /// fila es "sellada" cuando su contenido no cambió en ESTABLE_DRAINS + /// drains. El candidato a cosechar es el prefijo de filas selladas — así + /// la caja de input + spinner (que siempre cambian) quedan de cola sin + /// número fijo, y una respuesta corta apenas se escribe y queda quieta + /// se cosecha (no se pierde en una zona activa fija). + pub linea_estab: Vec<(u64, u8)>, +} + +impl TuiSession { + pub fn new(program: &str, rows: u16, cols: u16) -> Self { + let campanario = crate::campana::Campanario::new(); + let buzon = campanario.buzon(); + Self { + // Scrollback GRANDE a propósito: lo que scrollea fuera de la + // pantalla viva es EL LOG de la corrida (la cosecha de + // [`Self::cosechar`]); los redibujos in-place (spinners, UI) jamás + // entran. Un TUI alt-screen (vim/htop) no scrollea → no logea. + parser: vt100::Parser::new_with_callbacks(rows, cols, 10_000, campanario), + buzon, + rows, + cols, + program: program.to_string(), + skin: app_skin_for(program), + scanner: llimphi_term_graphics::GraphicsScanner::new(), + queries: QueryScanner::new(), + images: Vec::new(), + cosechadas: 0, + pre_cosechadas: 0, + exportadas: Vec::new(), + linea_estab: Vec::new(), + } + } + + /// Lee el buzón de avisos del terminal (ver [`crate::campana`]). Si el lock + /// está tomado devuelve el default — un aviso perdido no vale un stall del + /// render (mismo criterio que los espejos `tui_*_vivo`). + fn buzon(&self) -> crate::campana::Buzon { + self.buzon + .try_lock() + .map(|g| g.clone()) + .unwrap_or_default() + } + + /// Campanadas (BEL) acumuladas desde que arrancó el programa. Monótono: el + /// chasis guarda la última que acusó y el delta le dice si algo llamó. + pub fn campanadas(&self) -> u64 { + self.buzon().campanadas + } + + /// Título de ventana que puso el programa por OSC 0/2 — la fuente de + /// contexto de la pestaña. `None` si nunca puso ninguno. + pub fn titulo_osc(&self) -> Option { + self.buzon().titulo + } + + /// Se lleva las notificaciones de escritorio sin cosechar (OSC 9/777/99). + pub fn tomar_notificaciones(&mut self) -> Vec { + let Ok(mut g) = self.buzon.try_lock() else { + return Vec::new(); + }; + std::mem::take(&mut g.notificaciones) + } + + /// `true` si la pantalla viva muestra un **menú modal** de claude — el + /// picker de resume (`-r`: «Resume session (N of M)», «Space to preview»), + /// la confianza de carpeta («Enter to confirm»), o cualquier select con + /// «Esc to cancel». Es presentación interactiva, NO log: cada fila se + /// re-renderiza al navegar y la heurística de estabilidad sellaba el menú + /// entero como historia falsa que además CAMBIABA con las flechas (foto + /// del 17-jul: el picker cosechado con números de línea). Mientras esto + /// dé `true`, la pre-cosecha se pausa. + pub fn menu_modal_vivo(&self) -> bool { + let screen = self.parser.screen(); + if screen.alternate_screen() { + return false; + } + for r in 0..self.rows { + let texto: String = (0..self.cols) + .map(|c| match screen.cell(r, c) { + Some(cl) if !cl.contents().is_empty() => cl.contents(), + _ => " ".into(), + }) + .collect(); + let t = texto.trim(); + if t.contains("Esc to cancel") + || t.contains("esc to cancel") + || t.contains("Enter to confirm") + || t.contains("enter to confirm") + || t.contains("Resume session (") + || t.contains("Space to preview") + { + return true; + } + } + false + } + + /// `true` si la pantalla viva muestra el **spinner de trabajo** de claude + /// («✻ Pensando…», «· Thinking…», o el hint «esc to interrupt»): el + /// programa está ocupado. Escanea las últimas ~8 filas con contenido — + /// barato, y el spinner siempre vive pegado a la caja de input. + pub fn spinner_vivo(&self) -> bool { + let screen = self.parser.screen(); + if screen.alternate_screen() { + return false; + } + let mut vistas = 0u8; + for r in (0..self.rows).rev() { + let texto: String = (0..self.cols) + .map(|c| match screen.cell(r, c) { + Some(cl) if !cl.contents().is_empty() => cl.contents(), + _ => " ".into(), + }) + .collect(); + let t = texto.trim(); + if t.is_empty() { + continue; + } + if t.contains("esc to interrupt") { + return true; + } + // Glifos rotantes del spinner + elipsis («✻ Cavilando…»). El + // estado terminado («✻ Worked for 8s») no lleva elipsis → idle. + if t.starts_with(['✻', '✳', '✶', '✽', '✢', '∗', '·']) && t.contains('…') { + return true; + } + vistas += 1; + if vistas >= 8 { + break; + } + } + false + } + + /// Devuelve las filas NUEVAS que scrollearon fuera de la pantalla viva + /// desde la última cosecha (más viejas primero), cada una con sus runs + /// de color reales `(col_desde, col_hasta, rgba)`. Es el "output que va + /// para log": contenido consolidado que el programa empujó hacia arriba; + /// la presentación (redibujos in-place) nunca llega aquí. Restaura la + /// vista del scrollback a 0 (la pantalla viva) al salir. + pub fn cosechar(&mut self) -> Vec<(String, Vec<(u32, u32, [u8; 4])>)> { + let cols = self.cols; + let filas_pantalla = self.rows as usize; + let screen = self.parser.screen_mut(); + // set_scrollback clampa al tamaño real → el offset resultante ES el + // total de filas en el scrollback. + screen.set_scrollback(usize::MAX); + let total = screen.scrollback(); + let mut nuevas = Vec::new(); + let mut pendientes = total.saturating_sub(self.cosechadas); + while pendientes > 0 { + // Con offset = pendientes, la fila 0 de la vista es la más vieja + // aún no cosechada; entran hasta `filas_pantalla` por tanda. + screen.set_scrollback(pendientes); + let tanda = pendientes.min(filas_pantalla); + for r in 0..tanda as u16 { + nuevas.push(fila_con_runs(screen, r, cols)); + } + pendientes -= tanda; + } + screen.set_scrollback(0); + self.cosechadas = total; + // DEDUP con la pre-cosecha: las primeras filas que entran al + // scrollback son exactamente las que ya exportamos en vivo. + let saltar = self.pre_cosechadas.min(nuevas.len()); + self.pre_cosechadas -= saltar; + nuevas.drain(..saltar); + nuevas + } + + /// Pre-cosecha del contenido ASENTADO de la pantalla viva (skin claude): + /// las filas de arriba de la COLA viva que se quedaron QUIETAS varios + /// drains ya no van a cambiar — se exportan SIN esperar a que scrollee + /// ("los cerrados pasan de una"). No depende de detectar la caja de input + /// del programa (era frágil: claude la indenta y buscarla en col 0 + /// fallaba, por eso "no cosechaba nada"). La cola viva son las últimas + /// [`Self::COLA_VIVA`] filas con contenido; todo lo de arriba que esté + /// estable ≥[`Self::ASENTADO_DRAINS`] drains se cosecha. Los updates + /// in-place (⎿ Running…, spinners) nunca se aquietan → jamás salen antes + /// de tiempo. El dedup con el scrollback lo lleva [`Self::cosechar`] vía + /// `pre_cosechadas`. + /// Cuántos drains idénticos sella una línea. + pub const ESTABLE_DRAINS: u8 = 3; + + /// Cosecha del contenido SELLADO (skin claude, que re-renderiza in-place + /// sin scrollear). Estabilidad POR LÍNEA: el candidato es el prefijo de + /// filas que no cambiaron en ESTABLE_DRAINS drains; la caja de input + + /// spinner (siempre mutando) quedan fuera SIN número fijo. Exportación + /// MONÓTONA contra [`Self::exportadas`] (sólo el sufijo nuevo; reset sólo + /// si las primeras líneas cambian = /clear) para no re-exportar en bucle. + pub fn pre_cosechar_asentado(&mut self) -> Vec<(String, Vec<(u32, u32, [u8; 4])>)> { + if !matches!(self.skin, AppSkin::Claude) { + return Vec::new(); + } + let cols = self.cols; + let rows = self.rows; + let screen = self.parser.screen(); + if screen.alternate_screen() { + return Vec::new(); + } + // MENÚ MODAL a la vista (picker de `-r`, confianza, selects): pura + // presentación — pausar la pre-cosecha o el menú entra como historia + // falsa. Al cerrarse, las filas cambian → los sellos se re-ganan en + // ESTABLE_DRAINS drains y la conversación real fluye normal. + if self.menu_modal_vivo() { + return Vec::new(); + } + use std::hash::{Hash, Hasher}; + // Actualizar la estabilidad por-línea y hallar el corte = primera fila + // que NO está sellada (cambió hace poco). + self.linea_estab.resize(rows as usize, (0, 0)); + let mut corte = rows; + let mut filas: Vec<(String, Vec<(u32, u32, [u8; 4])>)> = Vec::with_capacity(rows as usize); + for r in 0..rows { + let fila = fila_con_runs(screen, r, cols); + let mut h = std::collections::hash_map::DefaultHasher::new(); + fila.0.hash(&mut h); + let hash = h.finish(); + let (ph, pt) = self.linea_estab[r as usize]; + let ticks = if hash == ph { pt.saturating_add(1) } else { 0 }; + self.linea_estab[r as usize] = (hash, ticks); + if ticks < Self::ESTABLE_DRAINS && corte == rows { + corte = r; + } + filas.push(fila); + } + // Recortar filas vacías del final del candidato sellado. + let mut cand: Vec<_> = filas.into_iter().take(corte as usize).collect(); + while cand.last().is_some_and(|(t, _)| t.trim().is_empty()) { + cand.pop(); + } + if cand.is_empty() { + return Vec::new(); + } + // Exportación por PREFIJO COMÚN: truncamos lo ya exportado al punto + // donde deja de coincidir con el candidato (así olvidamos líneas + // selladas que desaparecieron — p.ej. el menú de arranque de claude + // tras confirmar) y exportamos el sufijo nuevo. NO hay loop: las + // líneas que aún cambian (timestamps, spinner) no están selladas, así + // que no entran al candidato y no rompen el prefijo. + let mut comun = 0usize; + while comun < self.exportadas.len() + && comun < cand.len() + && self.exportadas[comun] == cand[comun].0 + { + comun += 1; + } + self.exportadas.truncate(comun); + if cand.len() <= comun { + return Vec::new(); + } + let nuevas: Vec<_> = cand[comun..].to_vec(); + for (t, _) in &nuevas { + self.exportadas.push(t.clone()); + } + nuevas + } + + /// Volcado FINAL al terminar el run: la cosecha pendiente del scrollback + /// MÁS la pantalla viva entera (recortando filas vacías del final). Un + /// output corto que nunca llegó a scrollear (nace y muere en pantalla) + /// pasa igual al log/desplegables — "detectado cerrado", como debe. + pub fn volcar_final(&mut self) -> Vec<(String, Vec<(u32, u32, [u8; 4])>)> { + let mut todo = self.cosechar(); + let cols = self.cols; + let rows = self.rows; + let screen = self.parser.screen(); + if !screen.alternate_screen() { + // Todo el screen vivo menos lo ya exportado (prefijo común). + let mut vivas: Vec<(String, Vec<(u32, u32, [u8; 4])>)> = + (0..rows).map(|r| fila_con_runs(screen, r, cols)).collect(); + while vivas.last().is_some_and(|(t, _)| t.trim().is_empty()) { + vivas.pop(); + } + let mut comun = 0usize; + while comun < self.exportadas.len() + && comun < vivas.len() + && self.exportadas[comun] == vivas[comun].0 + { + comun += 1; + } + todo.extend(vivas.into_iter().skip(comun)); + self.exportadas.clear(); + } + todo + } + + /// Procesa un bloque de bytes crudos del PTY: separa las secuencias + /// gráficas (las decodifica y las acumula en `images`), detecta las + /// **queries del terminal** y alimenta el resto al vt100. Devuelve las + /// respuestas (kitty `a=q` + DA1/CPR/OSC/…) que el caller debe escribir de + /// vuelta por el stdin del PTY — sin ellas el programa queda esperando. + pub fn process_bytes(&mut self, bytes: &[u8]) -> Vec> { + use llimphi_term_graphics::GraphicsCommand; + let mut passthrough = Vec::with_capacity(bytes.len()); + let cmds = self.scanner.feed(bytes, &mut passthrough); + let mut responses = Vec::new(); + if !cmds.is_empty() { + // Anclamos al cursor *antes* de procesar el texto de este lote — + // las herramientas one-shot emiten la imagen al inicio de su salida. + let (row, col) = self.parser.screen().cursor_position(); + for cmd in cmds { + match cmd { + GraphicsCommand::Image { image, cols, rows, .. } => { + let (px_w, px_h) = (image.width, image.height); + let brush = llimphi_image::from_rgba8(image.rgba, px_w, px_h); + self.images.push(TermImage { + image: brush, + col, + row, + cols, + rows, + px_w, + px_h, + }); + } + GraphicsCommand::Delete { .. } => self.images.clear(), + GraphicsCommand::Query { response } => responses.push(response), + } + } + } + let queries = self.queries.scan(&passthrough); + self.parser.process(&passthrough); + for q in queries { + responses.push(self.responder(&q)); + } + responses + } + + /// La respuesta a una [`TermQuery`], con el estado actual de la sesión. + /// Nos presentamos como un VT220 con color (DA1 62;22) — suficiente para + /// que las libs de detección concluyan y sigan. Celda nominal 8×16 px + /// (la misma aproximación del resize del PTY). + fn responder(&self, q: &TermQuery) -> Vec { + let (row, col) = self.parser.screen().cursor_position(); + match q { + TermQuery::Dsr5 => b"\x1b[0n".to_vec(), + TermQuery::Cpr => format!("\x1b[{};{}R", row + 1, col + 1).into_bytes(), + TermQuery::CprDec => format!("\x1b[?{};{}R", row + 1, col + 1).into_bytes(), + // «Modo no reconocido» (0): honesto y suficiente — el programa + // sabe que puede seguir sin esa feature. + TermQuery::DecRqm(m) => format!("\x1b[?{m};0$y").into_bytes(), + TermQuery::XtVersion => b"\x1bP>|shuma 1.0\x1b\\".to_vec(), + TermQuery::WinChars => { + format!("\x1b[8;{};{}t", self.rows, self.cols).into_bytes() + } + TermQuery::WinPx => { + format!("\x1b[4;{};{}t", u32::from(self.rows) * 16, u32::from(self.cols) * 8) + .into_bytes() + } + TermQuery::CellPx => b"\x1b[6;16;8t".to_vec(), + // Tema oscuro por defecto de la suite (detección claro/oscuro). + TermQuery::OscFg => b"\x1b]10;rgb:e6e6/e6e6/e6e6\x1b\\".to_vec(), + TermQuery::OscBg => b"\x1b]11;rgb:1414/1414/1a1a\x1b\\".to_vec(), + } + } + + /// Cambia las dimensiones del buffer interno del parser. El resize + /// del PTY real (que dispara SIGWINCH al child) lo hace el caller + /// vía `RunHandle::resize`. + pub fn set_size(&mut self, rows: u16, cols: u16) { + if rows == self.rows && cols == self.cols { + return; + } + self.parser.screen_mut().set_size(rows, cols); + self.rows = rows; + self.cols = cols; + } +} + +impl std::fmt::Debug for ActiveRun { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.debug_struct("ActiveRun") + .field("command", &self.command) + .field("finished", &self.handle.is_finished()) + .field("tui", &self.tui.is_some()) + .finish() + } +} + +/// Dims fijos para el PTY mientras el chasis no exponga el ancho real +/// del panel. 80×24 es el default histórico y vim/htop arrancan bien. +pub(crate) const PTY_ROWS: u16 = 24; +pub(crate) const PTY_COLS: u16 = 80; + +/// Tabla de comandos que pedimos PTY automáticamente. Otros pueden +/// pedirlo con el prefijo `:tui ...`. +pub(crate) const TUI_ALLOWLIST: &[&str] = &[ + "vi", "vim", "nvim", "nano", "emacs", "helix", "hx", "htop", "btop", "top", "less", "more", + "man", "claude", "tig", "tui", "watch", + // Visores de imágenes en terminal: necesitan PTY (ser un tty) para emitir + // kitty/sixel, que el scanner de `TuiSession::process_bytes` decodifica. + "chafa", "img2sixel", "viu", "timg", "catimg", "icat", "kitten", "tdf", +]; + +/// Selección activa/última en el card de vim, en coordenadas locales px +/// del panel (`ax,ay` = ancla del press; `hx,hy` = cabeza/cursor). +/// `active` = hay un drag en curso. +#[derive(Debug, Clone, Copy)] +pub struct VimSel { + pub ax: f32, + pub ay: f32, + pub hx: f32, + pub hy: f32, + pub active: bool, +} + +/// Recursos GPU del modo grilla (Fase 4 del SDD-TERMINAL). Se inicializan +/// lazy la primera vez que el `gpu_paint_with` del `generic_grid_panel` +/// recibe un device, y persisten entre frames (re-crear el pipeline cada +/// frame sería absurdo — el WGSL no cambia). El atlas crece y la textura +/// se re-aloca cuando aparece un glifo nuevo que no entra. +pub struct GpuGridResources { + pub pipeline: llimphi_widget_terminal::CellPipeline, + pub atlas: llimphi_widget_terminal::GlyphAtlas, + pub atlas_texture: llimphi_ui::llimphi_hal::wgpu::Texture, + pub atlas_view: llimphi_ui::llimphi_hal::wgpu::TextureView, + /// Tamaño del atlas para detectar grow → re-crear textura. + pub atlas_size: (u32, u32), +} + +/// Estado de la barra de búsqueda Ctrl+F sobre el cuerpo de output. +/// La barra es focus-grabbing: mientras está abierta, las teclas van a +/// `query`, no al input del shell. El `current` index navega ciclicamente +/// con `FindNext`/`FindPrev`; al cambiar, el `update` re-arma +/// `surf_selection` como la span del match actual (paridad de pintado y +/// copy con la selección por mouse). +#[derive(Debug, Clone, Default)] +pub struct FindState { + pub query: String, + pub matches: Vec, + pub current: Option, + pub case_insensitive: bool, +} + +/// Cache de las últimas N líneas spilleadas, refrescable cuando cambia el +/// `spilled_count` del `surf_history`. El `output_pane_surface` la lee en +/// cada render para prepend-ear esas líneas al view (Fase 5.11). Cap fijo +/// para acotar memoria + tiempo de refresh. +#[derive(Debug, Default, Clone)] +pub struct SurfSpilledCache { + /// Líneas spilleadas en orden cronológico (las más recientes que caben). + /// La 0 corresponde a `global_id = first_id`; la última a `first_id + + /// lines.len() - 1`. Cap a [`MAX_SPILLED_LOADED`]. + pub lines: Vec, + /// Global id de la primera línea cacheada (la más vieja del cache). + pub first_id: u64, + /// `spilled_count` al momento del último refresh — para detectar staleness. + pub cached_at: usize, + /// Inicio deseado de la ventana del archive (Fase 5.12 — paginado al + /// scrollear hacia arriba). `None` = ventana "cola" automática (las + /// últimas [`MAX_SPILLED_VISIBLE`], liviana, sigue el final cuando spillea + /// más). `Some(id)` = el usuario paginó hacia atrás: la ventana arranca en + /// `id` (clampeado a no más de [`MAX_SPILLED_LOADED`] desde el final) y se + /// congela ahí hasta que vuelva al fondo. Lo mueve `apply_scroll_delta`. + pub window_start: Option, +} + +/// Tope de líneas spilleadas que la ventana "cola" muestra de entrada +/// (pegadas al buffer vivo). Más atrás se carga paginando al scrollear. +/// ~30 KB para líneas típicas de 150 chars. +pub const MAX_SPILLED_VISIBLE: usize = 200; + +/// Tope duro de líneas spilleadas cargadas a la vez al paginar hacia atrás +/// (acota memoria + tiempo de refresh). Más viejo que esto → `:scrollback +/// open`. ~300 KB para líneas típicas. +pub const MAX_SPILLED_LOADED: usize = 2000; + +/// Cuántas líneas más viejas carga cada paginación al tocar el tope. +pub const SPILL_PAGE: usize = 200; + +/// Snapshot del layout del cuerpo de output bajo `SHUMA_TERMINAL_SURFACE=1`. +/// Lo escribe `output_pane_surface` al final del render; lo lee el handler +/// del drag de selección para resolver `(lx, ly)` a [`Point`] del store. +/// **Liviano**: `items_geo` es `Vec` (`Copy`), `store` es un Arc +/// para que el clone post-frame no copie todas las líneas. +#[derive(Clone)] +pub struct SurfLayout { + pub items_geo: Vec, + pub scroll_y: f32, + pub viewport_h: f32, + pub metrics: llimphi_widget_terminal::TermMetrics, + pub gutter_w: f32, + pub store: Arc, + /// Rangos `[start, end)` de líneas del store → id de bloque dueño. Permite + /// que el copiado de una selección **prepende el comando** del bloque donde + /// arranca (el comando es chrome, no una línea del store, así que no se + /// selecciona directamente — se adjunta al copiar). Orden de aparición. + pub block_ranges: Vec<(usize, usize, u64)>, +} + +/// Una línea del corpus de sugerencias: el texto y el `cwd` de su uso **más +/// reciente**. El cwd viaja con la línea porque el ghost y el popup rankean +/// «local al directorio antes que global» (A3). Ver +/// [`crate::update::corpus`] y `SDD-HISTORIAL.md` (Fase 1). +#[derive(Debug, Clone, Default)] +pub struct CorpusCache { + /// Líneas distintas, la más reciente primero. + pub lines: Vec, + /// Cuántas entradas del historial están ya volcadas en `lines`. + pub seen: usize, + /// La línea que estaba en la posición `seen - 1` cuando se llenó el caché. + /// **Un número solo no alcanza:** si el historial se REEMPLAZA por otro más + /// largo (recarga, o un test que enchufa otro archivo), extender «desde + /// `seen`» saltearía en silencio todo el prefijo del historial nuevo. Con + /// esta ancla el desajuste se detecta y se rehace entero. + pub ancla: Option, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct CorpusLine { + /// La línea de comandos, tal como se ejecutó. + pub line: String, + /// Directorio del uso más reciente de esa línea. + pub cwd: String, +} + +#[derive(Clone)] +pub struct State { + pub source: Source, + pub cwd: PathBuf, + pub input: crate::InputShuma, + /// Override de intención para el PRÓXIMO submit: Alt+Enter fuerza IA, + /// Ctrl+Enter fuerza shell. [`crate::update::run_submitted`] lo consume y + /// limpia. `None` = decide el clasificador ([`crate::intent`]). + pub forced_intent: Option, + /// Apps lanzables que el HOST empuja (pata, desde su `AppRegistry`). El + /// módulo es agnóstico: las apps son datos. Vacío = el host no las provee + /// (standalone). Alimenta el launcher del input **y** los candidatos-app del + /// popup de completado (con su ícono). + pub apps: Vec, + /// Petición de lanzar una app: el comando que el host debe spawnear (detached). + /// La pone [`run_submitted`] al matchear una app; el host la consume con + /// [`State::take_app_launch`]. `None` salvo entre el submit y su drenaje. + pub app_launch: Option, + /// Petición de **ejecutar una selección en un tab nuevo**: el comando que el + /// host debe correr en una tab de workspace fresca. La pone el pick «Ejecutar + /// en nuevo tab» del menú contextual del output; el host la consume con + /// [`State::take_new_tab_cmd`] (crea la tab + `RunLine`). `None` salvo entre el + /// pick y su drenaje. El módulo por sí solo no tiene tabs — es intención pura. + pub new_tab_cmd: Option, + /// Petición de abrir una **tab contra otro origen** (`:ssh `): el + /// `Source` remoto y la etiqueta con que mostrarlo. El host la consume con + /// [`State::take_new_tab_source`] y abre una tab de workspace apuntando + /// allá. Mismo mecanismo de intención pura que [`State::new_tab_cmd`] — + /// el módulo, que es una sola sesión, no puede crear tabs por sí mismo. + pub new_tab_source: Option<(shuma_module::Source, String)>, + pub output: Vec, + pub focused: bool, + /// Run en ejecución, si hay. Cloneable por `Arc>` — la + /// derivación `Clone` del state nos obliga a esto (el chasis clona + /// el state en cada `route_to_instance`). + pub running: Option>>, + /// Espejo SIN LOCK del skin del TUI vivo (`running.tui.skin`). La vista + /// decide su ESTRUCTURA con esto: `running_skin()`/`is_tui_active()` + /// hacen `try_lock`, y con streaming continuo el drain tiene el mutex + /// tomado casi siempre — cada frame contendido caía al grid crudo ("se + /// vuelve plano"). `Some` = hay PTY/TUI vivo. Se fija al montar el run + /// y se limpia al cerrarlo. + pub tui_skin_vivo: Option, + /// Espejo SIN LOCK de «el PTY vivo está en alt-screen» (vim/htop). El + /// GATE del teclado y la rama de la vista lo leen — con el try_lock, + /// bajo streaming una tecla iba al PTY y la siguiente al editor de línea + /// ("input desfasado" + autocomplete fantasma en el panel). Lo refresca + /// el drain en cada tick (ahí ya tiene el lock) y se limpia al cerrar. + pub tui_altscreen_vivo: bool, + /// Espejo SIN LOCK de «claude está trabajando» (spinner ✻/esc-to-interrupt + /// visible en la cola del grid). El host lo usa para el PS1 (pensando vs + /// idle) y para narrar «pensando…» en el placeholder de la barra — el + /// spinner real queda recortado por `input_box_top` (es chrome del input) + /// y no se ve en el panel. Refrescado por el drain, skin claude solamente. + pub claude_ocupado: bool, + /// Respuesta SUGERIDA por el asistente — la línea marcada con `➜` que cierra + /// su turno. La detecta el drain leyendo el screen, y se ofrece como ghost en + /// la barra de shuma con input vacío → `→` la acepta. `None` si no hay o el + /// usuario ya está tipeando. + pub claude_sugerencia: Option, + /// Campanadas (`^G` y demás — ver [`crate::campana`]) acumuladas por este + /// shell. **Monótono a lo largo de la vida del shell**, no del run: cada run + /// arranca su propio `TuiSession` en cero, así que acá se suman los deltas. + /// El chasis guarda la última que acusó; si esto la pasó, algo llamó. + pub campanadas: u64, + /// Espejo del contador del `TuiSession` del run actual — sólo existe para + /// calcular el delta contra `campanadas`. Se resetea al cerrar el run. + pub(crate) campanadas_run: u64, + /// Espejo SIN LOCK del programa que corre ahora (basename, sin `sudo`/env + /// adelante). Respaldo del título de la pestaña cuando el programa no puso + /// título OSC — la mayoría no lo hace. + pub comando_vivo: Option, + /// Título de ventana que puso el programa vivo por OSC 0/2. Es la fuente de + /// contexto preferida para el título de una pestaña: lo mantiene al día el + /// propio programa (vim el archivo, ssh el host, un PS1 decente el cwd). + pub titulo_osc: Option, + /// Notificaciones de escritorio pedidas por los programas (OSC 9/777/99), + /// sin cosechar. El chasis se las lleva con [`State::tomar_notificaciones`]. + pub notificaciones: Vec, + /// Caudal de salida muestreado a 10 Hz — el cava de la pestaña. Ver + /// [`crate::pulso`]. + pub pulso: crate::pulso::Pulso, + /// Sugerencia ya despachada (aceptada o descartada al enviar otra cosa). La + /// marca `➜` sobrevive en pantalla hasta scrollear, así que sin esto la + /// misma sugerencia volvería a ofrecerse turno tras turno. + pub sugerencia_consumida: Option, + /// Cola de líneas pendientes — cuando el usuario presiona Enter + /// mientras hay un run vivo, el nuevo comando entra aquí y arranca + /// cuando el actual cierra. + pub queue: VecDeque, + /// Fuente de completion (binarios en `$PATH` + paths bajo cwd). Es + /// `Arc` porque el `complete()` de `shuma-line` la usa por + /// referencia y el state se clona en cada `route_to_instance`. + pub completion_source: Arc, + /// Historial durable de líneas submitted — alimenta ghost + /// suggestion + Up/Down + Ctrl-R fuzzy. + pub history: Arc>, + /// Cursor de navegación del historial. `None` = no navegando. + pub history_cursor: Option, + /// Overlay de búsqueda Ctrl-R activo. `None` = no abierto. + pub history_search: Option, + /// Último rect (w, h) píxel del panel TUI — lo escribe el painter + /// y lo lee `drain_run` para disparar resize si cambia. Cero = + /// "todavía no se pintó". + pub last_tui_rect: Arc>, + /// Métricas reales (char_w, line_h) del monospace del card de vim, + /// medidas por el painter sobre el layout de parley y leídas por + /// `copy_vim_selection`. Cero = todavía sin medir (usar fallback). + pub vim_metrics: Arc>, + /// Jobs en background — arrancados con sufijo `&` en la línea. No + /// son el "foreground" (ese es `running`); su output se mergea al + /// buffer prefijado por `[N]`. Builtins `:jobs`, `:term N`, + /// `:stop N`, `:cont N` operan sobre estos. + pub bg_jobs: Vec>>, + /// Grafo de intenciones de la sesión — alimenta el lienzo de + /// contexto (`shuma-module-canvas`). Cada `start_run` registra un + /// nodo `%cN` y `drain_run` lo cierra con el status del exit. + pub intent_graph: SessionGraph, + /// E1 — libro de macros parametrizables (`:macro`). Cargado de + /// `~/.config/shuma/macros.toml` al arrancar; cada `:macro save`/`rm` + /// lo reescribe. Las macros se instancian sustituyendo `%1..%9` por los + /// argumentos de `:macro run`. + pub macro_book: shuma_intent::MacroBook, + /// `%cN` del run en foreground actual; `None` cuando no hay nada + /// corriendo. Se setea en `start_run` y se consume en `drain_run`. + pub current_run_node: Option, + /// Bytes acumulados de stdout+stderr del run actual; se vuelca al + /// nodo del grafo cuando el comando cierra (`complete`). + pub current_run_bytes: u64, + /// Selección del card de vim (drag-to-select). `None` = sin selección. + pub vim_sel: Option, + /// Contador monotónico de bloques de comando. Cada `Prompt` lo + /// incrementa; nunca se reusa, así el colapso sobrevive al capado + /// del buffer (los ids no se reciclan al drenar líneas viejas). + pub block_seq: u64, + /// Bloque al que se adjuntan las líneas nuevas (el último `Prompt`). + pub current_block: u64, + /// Bloques colapsados por el usuario (click en el header de la card). + /// Se renderizan plegados, mostrando sólo el header + un resumen. + pub collapsed: HashSet, + /// Sub-secciones colapsadas dentro de un bloque (`ls -R` por dir, etc.). + /// El `usize` es el índice de la sección que devolvió + /// [`sections::detect_sections`] para el comando del bloque. + pub section_collapsed: HashSet<(u64, usize)>, + /// Factor de zoom del texto del shell (1.0 = default). Ctrl+rueda lo + /// ajusta. Aplicado al `font_size`, `row_h` y `char_width` de la + /// superficie de output. Bounded [0.5, 3.0] al renderizar. + pub font_zoom: f32, + /// Offset horizontal del scroll del shell en px (≥ 0). Útil cuando + /// el zoom-in hace que las líneas excedan el viewport — Shift+rueda + /// mueve este valor. El gutter queda fijo; el texto se desplaza. + pub surf_scroll_x: f32, + /// A qué recibe el Enter de la línea: `None` = arrancar un comando + /// nuevo (la "línea"); `Some(block)` = mandar la línea por stdin al + /// comando vivo de ese bloque. Permite responder prompts de varios + /// comandos en paralelo, alternando con click/hover sobre su card. Se + /// fija al arrancar un comando, al hacer click/hover en su card o en la + /// línea, y se limpia cuando ese comando cierra. + pub input_focus: Option, + /// Estado de orden de las sub-secciones tipo tabla: por `(block, sec_idx)` + /// guarda `(col, ascending)`. Sin entry = orden natural (el del output). + /// Click en un header de columna togglea (col, true) → (col, false) → + /// remove. + pub section_sort: HashMap<(u64, usize), (usize, bool)>, + /// Etapas de pipe desplegadas — `(block, stage)`. Click en un chip de + /// etapa alterna la pertenencia; al estar presente se muestran sus + /// líneas capturadas en vivo (tee) bajo la fila de etapas. + pub expanded_stages: HashSet<(u64, usize)>, + /// Patrones de comandos inferidos del historial (`shuma-infer`). Se + /// recalculan al cerrar cada comando y alimentan el ghost con la + /// secuencia predicha (no sólo el historial reciente). Vacío al + /// arrancar y hasta tener suficiente historial. + pub patterns: Vec, + /// Corpus de sugerencias: las líneas **distintas** del historial, la más + /// reciente primero, con el cwd de ese uso. Es la Fase 1 de + /// `SDD-HISTORIAL.md` y reemplaza la ventana cruda de 2.000 entradas que + /// hacía desaparecer del autocompletado un comando muy usado por haber + /// tecleado mucho después. Lo mantiene [`crate::update::corpus::ensure`] + /// (al construir el State y al cerrar cada comando); el camino del tecleo + /// sólo lo LEE. Ver `COLA-SHUMA.md` L4. + /// Es `Arc>` porque el camino del tecleo (ghost y popup) recibe + /// `&State` y tiene que poder ponerlo al día **perezosamente**: el historial + /// crece por varias vías (submit, importación de zsh, tests) y un caché que + /// sólo se refresque en `refresh_patterns` se quedaría viejo en las otras. + /// El chequeo es O(1) (comparar la marca de agua); reconstruir sólo pasa + /// cuando el historial creció de verdad. + pub corpus: Arc>, + /// A1 — firmas de coreografías que el usuario **descartó** (chip «descartar»): + /// no se vuelven a ofrecer como grupo en esta sesión. Sólo en memoria. + pub dismissed_choreo: std::collections::HashSet>, + /// A2 — líneas largas para las que el usuario **descartó** el alias ofrecido: + /// no se vuelven a ofrecer en esta sesión. Sólo en memoria (el aceptado, en + /// cambio, queda aprendido al shumarc). + pub dismissed_alias: std::collections::HashSet, + /// A4 — corrección «¿quisiste decir…?» por bloque: cuando un comando falla + /// con `command not found`, el binario más cercano (Levenshtein) sobre la + /// línea original. Un notice clickeable bajo el bloque la lleva al input. + /// Sólo en memoria. + pub did_you_mean: std::collections::HashMap, + /// A6 — cuántos comandos largos (≥ `[rules].on_long_command_secs`) + /// terminaron **sin que el usuario los acuse**. El chasis lo lee para pintar + /// la badge en el diente de la sesión cuando no está activa, y lo pone en + /// cero al volver a ella ([`State::ack_long_alerts`]). `0` = nada pendiente. + /// Sólo en memoria. + pub long_alerts: usize, + /// Tope de captura de stdout por run, en bytes. `0` = sin tope. Lo fija + /// el builtin `:limit `. + pub capture_limit_bytes: usize, + /// Si volcar a disco la salida que excede el tope (`:spill on`). Sólo + /// tiene efecto con `capture_limit_bytes > 0`. + pub spill: bool, + /// Bloque cuyo stdout alimenta el stdin del próximo run (reprocess — + /// el `%pN` del lienzo). Lo arma el chip ↻ de una card y se consume en + /// el siguiente submit. `None` = sin reprocess armado. + pub reprocess_source: Option, + /// Primer bloque «marcado» para cotejar de un clic (el chip ⇄ del header). + /// Con otro bloque ya marcado, el segundo clic dispara `:compara %cA %cB` + /// entre ambos y vuelve a `None`. `None` = sin ancla de comparación. + pub compare_anchor: Option, + /// Grupos de comandos guardados con `:save ` — ejecutables por + /// F1..F8 (índice 0-based = número de F menos 1). + pub groups: Vec, + /// Largo del historial en el último `:save` — los comandos desde aquí + /// son los que entran al próximo grupo. + pub group_anchor: usize, + /// Completado activo (popup de candidatos). `Some` = popup abierto (Tab + /// con ≥2 opciones); se navega con Tab/flechas y se acepta con Enter. + /// Es el **tier 1** del completado: tokens (comando/flag/ruta). + pub completion: Option, + /// Tiers 2 y 3 del completado en capas, **anexados** bajo los candidatos + /// de token en el mismo popup: líneas completas del historial y grupos / + /// coreografías de comandos. El índice global recorre primero los + /// candidatos de `completion` y luego éstos. + pub completion_extra: Vec, + /// Candidato resaltado dentro del popup de completado (índice global sobre + /// candidatos de token + [`State::completion_extra`]). + pub completion_index: usize, + /// `true` si el usuario NAVEGÓ el popup (flechas/Tab/click) — o sea eligió + /// de verdad el resaltado. Enter sólo lanza un candidato-app si esto es + /// cierto; sin navegar, Enter ejecuta lo tipeado tal cual (el resaltado + /// default en un candidato-app secuestraba el `vim`/`claude` tipeado y + /// lanzaba el .desktop en su lugar). + pub completion_navegado: bool, + /// `true` si el canvas de este shell está A LA VISTA. Las teclas van al + /// PTY interactivo (claude/vim) sólo cuando es cierto: hospedado en pata + /// con el drawer PLEGADO, un PTY vivo invisible se comía todo el tipeo de + /// la barra ("input atorado"). El host lo baja al plegar y lo sube al + /// desplegar; standalone (canvas siempre visible) queda en `true`. + pub canvas_visible: bool, + /// Consola (PTY inline, skin claude): hay un CR (`\r`) pendiente de mandar + /// al PTY en el PRÓXIMO tick. Al Enter mandamos el TEXTO ya, pero el CR va + /// en una escritura posterior (otra iteración del loop) para que claude/Ink + /// lo lea como un Enter REAL y no como el final de un pegado en ráfaga — + /// mandar «texto\r» de una se coalescía en una sola lectura del PTY y Ink se + /// comía el `\r` (el bug del doble-Enter). Ver `send_consola_texto`. + pub cr_pendiente: bool, + /// Scroll del panel de output, en px medidos desde el fondo. `0` = + /// pegado al fondo (lo último siempre visible, como una terminal). + /// Crece al rodar la rueda hacia arriba (ver historial). Lo clampa + /// la `view` contra el overflow real. + pub scroll_px: f32, + /// Alto del viewport de output (lo publica el painter del panel cada + /// frame; lo lee la `view` y el handler de rueda al frame siguiente). + pub out_viewport_h: Arc>, + /// Overflow vertical del output (content_h − viewport_h, ≥0). Lo + /// publica la `view` y lo usa `Msg::Scroll` para clampar `scroll_px` + /// sin recalcular la geometría en el handler. + pub out_overflow: Arc>, + /// **Marca de agua alta monótona** del alto del contenido (px). Mientras la + /// vista está pegada al fondo, sólo CRECE: si un artefacto efímero (spinner, + /// «pensando…», un aviso que se va) hace subir el contenido y después se va, + /// el alto reservado se queda —la `view` empuja un espaciador vacío al fondo + /// por la diferencia— así la vista no rebota hacia abajo; lo efímero nuevo + /// aparece al PRINCIPIO del hueco, no después. Se resetea sólo en cortes + /// naturales (`clear`, comando nuevo). La `view` la sube; es `Arc` por + /// el mismo motivo que `out_overflow` (mutación interior bajo `&State`). + pub content_hwm: Arc>, + /// El hueco (px) que la `view` reservó de verdad en el último frame **pegado + /// al fondo**. Mientras el usuario está scrolled-up se devuelve TAL CUAL en + /// vez de recalcularlo: si el hueco se evaporara al primer paso de rueda, el + /// contenido se acortaría de golpe bajo su dedo y la vista brincaría hasta + /// una pantalla (bug del 25-jul: «un hueco abajo de todo, subo un punto y + /// salta más de una página»). Lo limpia `reset_content_hwm`. + pub content_gap: Arc>, + /// `overflow` vigente al momento en que el usuario fijó `scroll_px` por + /// última vez (rueda / scrollbar / auto-scroll de find). Lo usa el + /// render del surface para **anclar la vista del usuario al contenido** + /// cuando llegan líneas nuevas: si el usuario está scrolled-up + /// (`scroll_px > 0`), su `scroll_y` permanece donde lo dejó aunque el + /// `overflow` crezca por append — paridad con la UX que la gente espera + /// (Fase 5 del SDD-TERMINAL). `0.0` mientras esté pinned al fondo. + pub surf_scroll_anchor: f32, + /// Velocidad de scroll inercial (px por Tick) — la última entrada de + /// rueda/scrollbar la captura, y el Tick decae el valor por fricción + /// para que el scroll continúe brevemente después de soltar el wheel, + /// estilo touchpad (Fase 5 del SDD-TERMINAL). `0.0` mientras el scroll + /// está quieto. + pub surf_scroll_velocity: f32, + /// Selección viva del **stream del scrollback** (modo superficie, + /// `SHUMA_TERMINAL_SURFACE=1`). Spans una o más líneas y se traduce a + /// texto vía [`llimphi_widget_terminal::SelectionRange::slice_text`]. + /// `None` = sin selección. La pinta el `block_surface_with_selection` y + /// la mutan los handlers de drag (`SurfSelect{Press,Drag,End}`). + pub surf_selection: Option, + /// `true` mientras hay un drag de selección activo (entre el primer Move + /// y el End). Separado de `surf_selection` para distinguir "tengo una + /// selección viva" de "estoy dragueando ahora" — el primero persiste + /// post-release para que el usuario copie. + pub surf_selecting: bool, + /// Acumulador del drag (`lx0 + Σdx`, `ly0 + Σdy`). El `draggable_at` del + /// widget entrega deltas; este campo trackea la posición absoluta + /// dentro del viewport para resolverla a [`Point`] con `point_at_geo`. + pub surf_drag_acc: (f32, f32), + /// Snapshot del layout del último frame de `output_pane_surface` — + /// items en versión liviana (`ItemGeo`), métricas, gutter_w, scroll_y, + /// viewport_h y una copia barata del `Scrollback`. Lo lee el handler + /// del drag para hit-testear `(lx, ly)` contra el render previo, sin + /// re-armar los items. + pub surf_layout: Arc>>, + /// Estado de la barra de búsqueda (Ctrl+F) sobre el cuerpo de output. + /// `None` = barra cerrada. Cuando hay matches, el `current` se refleja + /// como `surf_selection` para que se vea resaltado con el mismo overlay + /// y se pueda copiar con el clipboard ya cableado. + pub find: Option, + /// Filas visibles de la COLA viva del PTY inline (vista dividida). El + /// divisor entre historial y cola se arrastra (Msg::ColaAlto). + pub cola_filas: f32, + /// Historial de líneas enviadas al PTY inline en modo CONSOLA (Enter → + /// stdin). Alimenta el autocompletado del input mientras la consola está + /// activa (en vez del completado normal de comandos/apps). + pub consola_historial: Vec, + /// Menú contextual del cuerpo de output **en modo superficie**: + /// `(x, y)` en coords del nodo raíz del shell. `None` = cerrado. + /// Distinto del `body_menu` del legacy (que carga un `block`); el + /// surface menu opera sobre el scrollback entero. + pub surf_menu: Option<(f32, f32)>, + /// **Copy-mode** del panel de output (estilo tmux/kitty): entrás con + /// `Ctrl+Shift+Espacio`, aparece un caret sólido movible por teclado + /// (flechas/hjkl, palabra, Home/End, página, g/G) y la selección se copia + /// sola al cuasi-clipboard PRIMARY mientras la extendés. `Esc`/`q` sale. + /// Es la puerta de **teclado** a la selección; el mouse ya la tenía. + pub surf_copy_mode: bool, + /// Dentro de copy-mode, si el **modo visual** está activo (`v`/Space): el + /// movimiento extiende la selección aunque no se tenga Shift. Toggle. + pub surf_copy_visual: bool, + /// Timestamp (ms unix) del último `SurfDoubleClick`. Si llega otro + /// double-click dentro de la ventana (~350 ms), el handler lo trata + /// como **triple-click** y selecciona la línea entera (paridad con la + /// UX de xterm/gnome-terminal: tap, tap-tap, tap-tap-tap-tap). + pub surf_last_dblclick_ms: u64, + /// Scrollback **persistente** del cuerpo de output (Fase 5.7 del + /// SDD-TERMINAL). Es independiente del store que `output_pane_surface` + /// reconstruye por frame para el view (esa sigue siendo la fuente de + /// verdad del render). Esta acumula CADA línea de body que pasa por + /// `push_output` desde el arranque, con cap por memoria + spill + /// opcional. Cuando el cap se excede, las líneas viejas se vuelcan al + /// spill file y siguen recuperables por `read_spilled(global_id)`. + /// Sin spill activo, igual sirve para mostrar el tamaño total del + /// historial; el view no la usa todavía (TODO: integrar para servir + /// scrolls al-pasado-spillado). + pub surf_history: Arc>, + /// Cache de las últimas líneas spilled visibles directamente al frente + /// del view (Fase 5.11). Refrescada lazy desde `surf_history.read_spilled` + /// cuando el `spilled_count` cambia. + pub surf_spilled_visible: Arc>, + /// Recursos GPU del modo grilla (atlas + pipeline + textura). `None` + /// hasta que el primer `gpu_paint_with` los inicialice. Mantenidos en + /// `Arc>` para que la closure de paint (Send+Sync+'static) + /// pueda accederlos. + pub gpu_grid: Arc>>, + /// Momento de creación de cada bloque (unix secs) — alimenta el badge + /// de "hace N minutos" en vez del crudo "exit N". Lo setea + /// [`State::push_output`] (Prompt) y [`State::open_block`]. + pub block_started: std::collections::HashMap, + /// Momento de cierre de cada bloque (unix secs) — lo setea el cierre del + /// run (notice `✔/✘`). Con [`block_started`] da la duración que alimenta + /// el titular semáforo del header colapsado. Sólo vive en memoria: no se + /// persiste (una sesión restaurada no muestra duración en sus bloques). + pub block_ended: std::collections::HashMap, + /// Texto del comando (`$ …`) por bloque. Se guarda al abrir el bloque para + /// que el header de la card sobreviva aunque la línea Prompt se recorte del + /// buffer en un output gigante (`MAX_OUTPUT_LINES`). + pub block_command: std::collections::HashMap, + /// Imágenes (kitty/sixel) horneadas por bloque al cerrar un comando PTY + /// (chafa/icat/img2sixel/…). Persisten en el scrollback: el render de la + /// superficie las emite como chrome bajo el cuerpo del bloque. Sólo en + /// memoria (una sesión restaurada no las recupera). + pub block_images: std::collections::HashMap>, + /// Instante (unix ms) de la última tecla en el input — ancla del + /// parpadeo del caret: queda sólido un instante tras tipear y luego + /// titila, para que se sienta vivo sin distraer. + pub input_edit_at_ms: u64, + /// **Trinquete** del alto del input: la mayor cantidad de filas que llegó a + /// ocupar el texto actual. Sólo sube; vuelve a cero cuando el input queda + /// vacío. Borrar una línea no encoge la caja — que se achique mientras + /// editás es justo lo que hace temblar todo. + pub input_filas_pico: Arc>, + /// Alto animado de la caja en px y el instante (unix ms) en que se avanzó + /// por última vez. El alto real persigue al objetivo con una exponencial: + /// crecer de golpe es el salto que se ve como sacudida. + pub input_alto_anim: Arc>, + /// Configuración personal cargada de `~/.config/shuma/shumarc.toml` + /// (aliases, env, dedup, captura). Si falta o no parsea, es + /// [`shuma_config::Config::default`] — el shell arranca igual. Sus + /// aliases se expanden en cada submit; sus env vars ya se aplicaron al + /// proceso en [`State::new`]. + pub config: shuma_config::Config, + /// E3 — guarda de re-entrada de `[rules].on_exit_nonzero`: se arma en + /// cada submit del usuario y se desarma al disparar la regla, para que + /// el propio comando de la regla (si también falla) no la re-dispare. + pub exit_rule_fired: bool, + /// E3 — guarda de re-entrada de `[rules].on_enter_cwd`: evita que el + /// comando de una regla de cwd que a su vez haga `cd` re-dispare reglas. + pub in_cwd_rule: bool, + /// E5 — petición al LLM pendiente que el host (chasis) debe cumplir: + /// el módulo no habla con la red, sólo expresa la intención (`:?`/ + /// `:explica`/`:resume`). El chasis la toma con [`State::take_llm_request`], + /// corre `pluma-llm` en un thread y devuelve `Msg::LlmResult`. `None` + /// salvo entre la invocación y su resultado. + pub llm_request: Option, + /// `true` mientras una petición LLM está en vuelo (la tomó el host) — + /// evita que el host la re-dispare en cada tick. + pub llm_inflight: bool, + /// Header del bloque donde aterrizará la respuesta de un `LlmKind::Text` + /// (`:explica`/`:resume`/`:filtra`): la respuesta abre su **propio bloque** + /// referenciable (`%cM`) en vez de mezclarse con el bloque actual, para que + /// se pueda volver a filtrar/guardar/encadenar. `None` para `:?`/`:haz` + /// (que van al input, sin abrir bloque). Lo arma el builtin y lo consume + /// `Msg::LlmResult`. + pub llm_block_label: Option, + /// Búsqueda semántica pendiente (`:buscar`). Mismo patrón que `llm_request`: + /// el módulo expresa la intención, el chasis embebe y devuelve + /// `Msg::SemanticResult`. `None` salvo entre la invocación y su resultado. + pub semantic_request: Option, + /// `true` mientras una búsqueda semántica está en vuelo (la tomó el host). + pub semantic_inflight: bool, + /// **Marquesina**: aviso a narrar en el input cuando está vacío (en vez del + /// placeholder «tipea un comando…»), aprovechando ese espacio. Lo fija el + /// host (pata desde `sys_alert`/notificaciones, o el chasis shuma desde el + /// centro willay) con [`State::set_marquesina`]. `None` = placeholder default. + pub marquesina: Option, + /// Contador de fase para el parpadeo de los avisos urgentes de la marquesina + /// (lo avanza el host por tick). Sólo lo usan los `Urgencia::Urgente`. + pub marquesina_fase: u8, + /// **Escucha por voz**: el «llamado shuma estilo alexa» en el input del + /// shell. Lo fija el host desde los `EventoEscucha` de `rimay-voz-host`; el + /// input sólo pinta el botón de micrófono con su halo. El dictado (STT) entra + /// por `InsertAtCursor`, como cualquier texto. + pub escucha: shuma_voz_ui::EstadoEscucha, + /// Reloj (epoch ms) para animar el halo del micrófono mientras escucha (lo + /// refresca el host por tick). El `update` no lo lee. + pub voz_reloj_ms: u64, + /// Intent: el usuario tocó el micrófono para encender (`Some(true)`) o apagar + /// (`Some(false)`). El host lo toma con [`State::tomar_mic_intent`] y + /// arranca/para la captura. `None` = nada pendiente. + pub mic_intent: Option, +} + +/// E5 — qué hacer con la respuesta del LLM. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum LlmKind { + /// `:?` — la respuesta es una línea de comando: va al input para que el + /// usuario la revise y ejecute (NUNCA se auto-ejecuta). + Command, + /// `:explica`/`:resume` — la respuesta es texto informativo: va al + /// output del bloque. + Text, + /// `:haz` — la respuesta es una **invocación de control en JSON** + /// (`{"id":…,"args":{…}}` o `nada`): el módulo la resuelve con `atipay` a un + /// plan validado y pone la línea exacta en el input, etiquetada por peligro + /// (NUNCA se auto-ejecuta). Evita que el modelo invente flags inexistentes. + Atipay, +} + +/// E5 — una invocación al LLM que el host debe cumplir. Campos públicos para +/// que el chasis los lea y arme el `ChatRequest` (system + prompt + tope). +#[derive(Debug, Clone)] +pub struct LlmRequest { + pub kind: LlmKind, + pub system: String, + pub prompt: String, + pub max_tokens: u32, + /// Backend a usar (de la config global del SO `wawa.ai.llm`). Si `backend` + /// está vacío, el chasis cae a `from_env`. Configurable desde wawa-panel. + pub llm: wawa_config::LlmSettings, +} + +/// Petición de **búsqueda semántica** que el chasis debe cumplir: embebe `query` +/// + `candidates` con el daemon de embeddings (o mock), contra un índice +/// persistido por `scope`, y devuelve los más parecidos. Campos públicos para +/// que el host arme la búsqueda. +#[derive(Debug, Clone)] +pub struct SemanticRequest { + /// Espacio del índice persistido: `"history"` (comandos) · `"files"` + /// (archivos). Cada scope tiene su archivo de índice en disco. + pub scope: String, + /// Lo que el usuario busca por significado. + pub query: String, + /// Corpus a rankear como pares `(clave, texto_a_embeber)`. La **clave** es + /// estable e identifica la entrada en el índice (y es lo que se muestra); el + /// **texto** es lo que se embebe (puede traer más contexto que la clave). + /// Para comandos clave==texto; para archivos clave incluye el mtime (para + /// re-embeber al cambiar) y el texto es ruta + fragmento del contenido. + pub candidates: Vec<(String, String)>, + /// Socket del daemon de embeddings (`""` = por defecto). + pub socket: String, + /// Dimensión del fallback mock si no hay daemon. + pub dim: usize, +} + +/// Snapshot serializable del output de una sesión — lo que el chasis +/// persiste a disco cuando la sesión tiene el flag «persistir» y rehidrata +/// al reabrir la app. Sólo datos: las asas vivas (runs, locks) no viajan. +#[derive(Debug, Clone, Default, serde::Serialize, serde::Deserialize)] +pub struct OutputSnapshot { + pub lines: Vec, + /// Comando por bloque (headers que sobreviven al recorte del buffer). + pub block_command: std::collections::HashMap, + /// Momento de apertura por bloque (unix secs) — los "hace N min". + pub block_started: std::collections::HashMap, + /// Contador monotónico al momento del snapshot. + pub block_seq: u64, +} + +impl State { + /// Captura el output vigente como [`OutputSnapshot`], limitado a las + /// últimas `max_lines` líneas (cortar al medio de un bloque es válido: + /// el render rearma el header desde `block_command`). + pub fn output_snapshot(&self, max_lines: usize) -> OutputSnapshot { + let start = self.output.len().saturating_sub(max_lines); + let lines: Vec = self.output[start..].to_vec(); + // Sólo la metadata de los bloques presentes en el recorte. + let presentes: HashSet = lines.iter().map(|l| l.block).collect(); + OutputSnapshot { + block_command: self + .block_command + .iter() + .filter(|(b, _)| presentes.contains(b)) + .map(|(b, c)| (*b, c.clone())) + .collect(), + block_started: self + .block_started + .iter() + .filter(|(b, _)| presentes.contains(b)) + .map(|(b, t)| (*b, *t)) + .collect(), + block_seq: self.block_seq, + lines, + } + } + + /// Rehidrata un snapshot al frente del buffer (pensado para el arranque, + /// con el buffer todavía vacío). Los bloques restaurados quedan + /// **plegados** (menos el último) para que la sesión abra compacta, y + /// un notice separador marca la costura. + pub fn restore_output(&mut self, snap: OutputSnapshot) { + if snap.lines.is_empty() { + return; + } + let ultimo = snap.lines.iter().map(|l| l.block).max().unwrap_or(0); + for l in &snap.lines { + if l.block != 0 && l.block != ultimo { + self.collapsed.insert(l.block); + } + } + self.block_command.extend(snap.block_command); + self.block_started.extend(snap.block_started); + self.block_seq = self.block_seq.max(snap.block_seq); + let mut restauradas = snap.lines; + let n = restauradas.len(); + restauradas.push(OutputLine::notice(format!( + "— sesión restaurada ({n} líneas) —" + ))); + restauradas.extend(std::mem::take(&mut self.output)); + self.output = restauradas; + // Las líneas nuevas siguen en bloques nuevos, nunca en los viejos. + self.current_block = 0; + } +} + +/// Estado del overlay de búsqueda Ctrl-R. +#[derive(Debug, Clone, Default)] +pub struct HistorySearch { + pub query: String, + pub selected: usize, +} + +/// Qué tier del completado en capas produjo una sugerencia anexa. +/// Una app lanzable que el host declara. `icon` es un **hint** para el host: +/// o bien un glifo unicode corto (`✶`, `❯`) que se pinta tal cual, o un nombre +/// freedesktop (`firefox`, `org.gnome.Files`) que el host resuelve a un +/// `.svg`/`.png` de su tema de íconos. El módulo no resuelve íconos: sólo +/// transporta el hint hasta el popup, que el host pinta. +#[derive(Debug, Clone, PartialEq, Eq, Default)] +pub struct LaunchableApp { + /// Nombre legible (lo que se busca y se muestra). + pub nombre: String, + /// Línea de comando a spawnear (detached) al lanzarla. + pub comando: String, + /// Hint de ícono (glifo unicode o nombre freedesktop). `None` = sin ícono. + pub icon: Option, +} + +impl LaunchableApp { + pub fn new(nombre: impl Into, comando: impl Into) -> Self { + Self { nombre: nombre.into(), comando: comando.into(), icon: None } + } + pub fn con_icono(mut self, icon: Option) -> Self { + self.icon = icon; + self + } +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum SugKind { + /// Tier 0 — una **app lanzable** que matchea lo tipeado (aceptar = lanzar). + App, + /// Tier 2 — una **línea completa** del historial que extiende el texto. + Line, + /// Tier 3 — un **grupo / coreografía** de varios comandos (joined `&&`). + Group, +} + +/// Una sugerencia de los tiers 2/3 del completado en capas. A diferencia de un +/// candidato de token (que reemplaza sólo la palabra bajo el cursor), una +/// sugerencia trae su **propio rango de reemplazo** (típicamente toda la línea) +/// y un texto a insertar distinto de lo que se muestra (un grupo se muestra con +/// su nombre pero inserta la secuencia entera). +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct Suggestion { + /// Lo que se pinta en la fila del popup (con su marcador de tier). + pub display: String, + /// El texto que se inserta al aceptar. + pub insert: String, + /// Inicio del rango de bytes a reemplazar. + pub replace_start: usize, + /// Fin del rango de bytes a reemplazar. + pub replace_end: usize, + pub kind: SugKind, + /// Sólo para [`SugKind::App`]: el hint de ícono de la app (glifo o nombre + /// freedesktop). El host lo resuelve y pinta a la izquierda de la fila. + pub icon: Option, +} + +/// Grupo de comandos guardado (`:save `) — una secuencia ejecutable +/// como una sola línea (`l1 && l2 && …`) desde una tecla de función. +#[derive(Debug, Clone)] +pub struct CommandGroup { + pub name: String, + pub lines: Vec, +} + +impl State { + pub fn new(source: Source) -> Self { + // Un contenedor arranca en SU interior (`/root`, el home del root con + // el que entramos), no en el cwd del host: tras el chroot el path del + // host no existe adentro y `pwd`/`ls`/el prompt se contradecían. + let cwd = match &source { + // Contenedor: arranca en su interior (`/root`). + Source::Container { .. } => PathBuf::from("/root"), + // Contenedor remoto: idem, su interior (`/root`) en el host remoto. + Source::RemoteContainer { .. } => PathBuf::from("/root"), + // Remoto: arranca en el `$HOME` remoto (`~`); el `cd` lo trackea. + Source::Remote { .. } => PathBuf::from("~"), + _ => std::env::current_dir().unwrap_or_else(|_| PathBuf::from("/")), + }; + let completion_source = completion_source_for(&source, &cwd); + // Configuración personal: fallback silencioso a default si falta o no + // parsea (no hay nada crítico, sólo preferencias). Las env vars del + // `.shumarc` se exportan AHORA, antes de spawnear ningún subproceso — + // los hijos las heredan. + let config = shuma_config::Config::load_default().unwrap_or_default(); + config.apply_env(); + let history = Arc::new(Mutex::new(open_history())); + // La política de dedup del historial sale del rc (default + // `IgnoreConsecutive` si no se declara). + if let Ok(mut h) = history.lock() { + h.set_dedup(match config.history.dedup { + shuma_config::DedupPolicy::None => shuma_history::DedupPolicy::None, + shuma_config::DedupPolicy::IgnoreConsecutive => { + shuma_history::DedupPolicy::IgnoreConsecutive + } + shuma_config::DedupPolicy::EraseDups => shuma_history::DedupPolicy::EraseDups, + }); + // Absorbe el historial de bash/zsh (incremental) antes de fijar el + // anchor de grupos — así lo importado queda "antes" de la sesión. + if config.history.import_shells && crate::importacion_permitida() { + absorb_shell_histories(&mut h); + } + } + // El anchor de grupos arranca al final del historial durable: el + // primer `:save` agrupa sólo lo tipeado en ESTA sesión, no meses + // de historial persistido. + let group_anchor = history.lock().map(|h| h.len()).unwrap_or(0); + let s = Self { + source, + cwd, + input: crate::InputShuma::new(), + forced_intent: None, + apps: Vec::new(), + app_launch: None, + new_tab_cmd: None, + new_tab_source: None, + output: Vec::new(), + focused: true, + running: None, + tui_skin_vivo: None, + tui_altscreen_vivo: false, + claude_ocupado: false, + claude_sugerencia: None, + campanadas: 0, + campanadas_run: 0, + comando_vivo: None, + titulo_osc: None, + notificaciones: Vec::new(), + pulso: crate::pulso::Pulso::default(), + sugerencia_consumida: None, + queue: VecDeque::new(), + completion_source, + history, + history_cursor: None, + history_search: None, + last_tui_rect: Arc::new(Mutex::new((0.0, 0.0))), + vim_metrics: Arc::new(Mutex::new((0.0, 0.0))), + bg_jobs: Vec::new(), + intent_graph: SessionGraph::new(), + macro_book: load_macro_book(), + current_run_node: None, + current_run_bytes: 0, + vim_sel: None, + block_seq: 0, + current_block: 0, + collapsed: HashSet::new(), + section_collapsed: HashSet::new(), + section_sort: HashMap::new(), + // 1.15: el texto del drawer se leía chico en metal (pedido + // 2026-07-17); Ctrl+rueda / Ctrl+-/= siguen ajustando por encima. + font_zoom: 1.15, + surf_scroll_x: 0.0, + input_focus: None, + expanded_stages: HashSet::new(), + patterns: Vec::new(), + corpus: Arc::new(Mutex::new(CorpusCache::default())), + dismissed_choreo: std::collections::HashSet::new(), + dismissed_alias: std::collections::HashSet::new(), + did_you_mean: std::collections::HashMap::new(), + long_alerts: 0, + // Política de captura inicial desde el rc (los builtins `:limit` / + // `:spill` la sobreescriben en vivo). `0` MiB = sin tope. + capture_limit_bytes: config.capture.limit_mb.saturating_mul(1024 * 1024), + spill: config.capture.spill, + reprocess_source: None, + compare_anchor: None, + groups: Vec::new(), + group_anchor, + completion: None, + completion_extra: Vec::new(), + completion_index: 0, + completion_navegado: false, + canvas_visible: true, + cr_pendiente: false, + scroll_px: 0.0, + out_viewport_h: Arc::new(Mutex::new(0.0)), + out_overflow: Arc::new(Mutex::new(0.0)), + content_hwm: Arc::new(Mutex::new(0.0)), + content_gap: Arc::new(Mutex::new(0.0)), + surf_scroll_anchor: 0.0, + surf_scroll_velocity: 0.0, + surf_selection: None, + surf_selecting: false, + surf_drag_acc: (0.0, 0.0), + surf_layout: Arc::new(Mutex::new(None)), + find: None, + cola_filas: 16.0, + consola_historial: Vec::new(), + surf_menu: None, + surf_copy_mode: false, + surf_copy_visual: false, + surf_last_dblclick_ms: 0, + // Scrollback persistente: cap por `config.scrollback.limit_mb`, + // spill opcional (si `config.scrollback.spill = true`) a un + // archivo en `$XDG_RUNTIME_DIR/shuma-.spill` (o el path + // explícito de la config). Errores I/O al armar el spill se + // ignoran silenciosamente: el history funciona sin él. + surf_history: Arc::new(Mutex::new(build_surf_history(&config))), + surf_spilled_visible: Arc::new(Mutex::new(SurfSpilledCache::default())), + gpu_grid: Arc::new(Mutex::new(None)), + block_started: std::collections::HashMap::new(), + block_ended: std::collections::HashMap::new(), + block_command: std::collections::HashMap::new(), + block_images: std::collections::HashMap::new(), + input_edit_at_ms: now_unix_millis(), + input_filas_pico: Arc::new(Mutex::new(0)), + input_alto_anim: Arc::new(Mutex::new((0.0, 0))), + config, + exit_rule_fired: false, + in_cwd_rule: false, + llm_request: None, + llm_inflight: false, + llm_block_label: None, + semantic_request: None, + semantic_inflight: false, + marquesina: None, + marquesina_fase: 0, + escucha: shuma_voz_ui::EstadoEscucha::Apagado, + voz_reloj_ms: 0, + mic_intent: None, + }; + // Corpus de sugerencias al día ANTES del primer render: el ghost y las + // sugerencias de línea leen `s.corpus`, así que sin este llenado inicial + // el autocompletado arrancaría mudo hasta el primer comando de la sesión + // (el otro punto de mantenimiento es `refresh_patterns`, al cerrar cada + // comando). Es una pasada O(N) por el historial, una sola vez. + crate::update::corpus::ensure(&s); + s + } + + // ── Voz: el «llamado shuma» en el input del shell ─────────────────────── + + /// Fija el estado de escucha (lo llama el host al recibir un `EventoEscucha`). + pub fn fijar_escucha(&mut self, e: shuma_voz_ui::EstadoEscucha) { + self.escucha = e; + } + + /// Estado actual de la escucha (para el host / tests). + pub fn escucha(&self) -> shuma_voz_ui::EstadoEscucha { + self.escucha + } + + /// Fija el reloj para animar el halo del micrófono (el host lo refresca en + /// cada tick mientras escucha). + pub fn set_voz_reloj(&mut self, reloj_ms: u64) { + self.voz_reloj_ms = reloj_ms; + } + + /// Toma el intent de encender/apagar el micrófono y lo limpia. El host lo + /// consulta tras cada `update` y arranca/para `rimay-voz-host`. + pub fn tomar_mic_intent(&mut self) -> Option { + self.mic_intent.take() + } + + /// Fija (o limpia) el aviso de la marquesina y su fase de parpadeo. Lo llama + /// el host tras leer sus fuentes de eventos; el input lo pinta como + /// placeholder cuando está vacío. + pub fn set_marquesina(&mut self, m: Option, fase: u8) { + self.marquesina = m; + self.marquesina_fase = fase; + } + + /// Resultado del último comando **terminado** (no en curso): `Some(true)` si + /// salió OK, `Some(false)` si falló, `None` si aún no corrió ninguno. Lo + /// consume el host para el PS1/**chakana** (verde ok / rojo error). + pub fn ultimo_resultado(&self) -> Option { + self.intent_graph + .commands() + .iter() + .rev() + .find(|c| !matches!(c.status, shuma_intent::NodeStatus::Running)) + .map(|c| matches!(c.status, shuma_intent::NodeStatus::Ok)) + } + + /// E5 — el host toma la petición LLM pendiente (si la hay y no hay otra + /// en vuelo), marcándola en vuelo. Devuelve `None` si no hay nada que + /// hacer. El host la corre y responde con `Msg::LlmResult`. + pub fn take_llm_request(&mut self) -> Option { + if self.llm_inflight { + return None; + } + let req = self.llm_request.take()?; + self.llm_inflight = true; + Some(req) + } + + /// El host toma la búsqueda semántica pendiente (si la hay y no hay otra en + /// vuelo), marcándola en vuelo. El host la corre y responde con + /// `Msg::SemanticResult`. + pub fn take_semantic_request(&mut self) -> Option { + if self.semantic_inflight { + return None; + } + let req = self.semantic_request.take()?; + self.semantic_inflight = true; + Some(req) + } + + /// Arma una búsqueda semántica de **archivos** (scope `files`) bajo el cwd, + /// disparada desde el **rail** del chasis (segunda entrada al mismo motor que + /// `:buscar-archivos`, sin escribir en el output ni en el historial). El host + /// la recoge con [`Self::take_semantic_request`] y responde con + /// `Msg::FileSearchResult`. Devuelve `Ok(n)` con la cantidad de candidatos, o + /// `Err` si la semántica está apagada, la consulta es vacía o no hay archivos. + pub fn arm_file_search(&mut self, query: &str) -> Result { + let q = query.trim(); + if q.is_empty() { + return Err("consulta vacía".into()); + } + let ai_sem = wawa_config::WawaConfig::load().ai.semantic; + if !ai_sem.enabled { + return Err("búsqueda semántica apagada".into()); + } + let candidates = crate::update::builtins::collect_file_candidates(&self.cwd); + if candidates.is_empty() { + return Err("sin archivos para indexar".into()); + } + let n = candidates.len(); + self.semantic_request = Some(SemanticRequest { + scope: "files".to_string(), + query: q.to_string(), + candidates, + socket: ai_sem.socket.clone(), + dim: ai_sem.effective_dim(), + }); + Ok(n) + } + + /// El host toma la app a lanzar pendiente (si la hay), limpiándola. El host la + /// spawnea detached (pata: `spawn_cmd`). Es la contraparte launcher del + /// input: [`crate::update::run_submitted`] la deja al matchear una app. + pub fn take_app_launch(&mut self) -> Option { + self.app_launch.take() + } + + /// El host toma el comando a **ejecutar en un tab nuevo** pendiente (si lo + /// hay), limpiándolo. Lo deja el pick «Ejecutar en nuevo tab» del menú + /// contextual del output; el host abre una tab de workspace fresca y le manda + /// `RunLine`. El módulo (una sola sesión) no puede crear tabs — de ahí la + /// intención drenada por afuera, espejo de [`State::take_app_launch`]. + pub fn take_new_tab_cmd(&mut self) -> Option { + self.new_tab_cmd.take() + } + + /// El host toma el **origen** de la tab nueva pendiente (`:ssh `), + /// limpiándolo: `(source, etiqueta)`. Espejo de + /// [`State::take_new_tab_cmd`], pero en vez de un comando lleva a dónde + /// debe apuntar la tab. + pub fn take_new_tab_source(&mut self) -> Option<(shuma_module::Source, String)> { + self.new_tab_source.take() + } + + /// El ULID de la sesión **persistente del daemon** montada en este shell, si + /// lo hay (un run local de foreground tiene `session: None`). El host lo usa + /// al **cerrar** una tab/sesión para `olvidar_montada` — mandarla al fondo sin + /// que el auto-reattach la reviva sola al próximo arranque. + pub fn montada_session(&self) -> Option { + self.running.as_ref()?.lock().ok()?.session + } + + /// Cierra el popup de completado sin aplicar nada — para que el host lo + /// descarte al clic fuera de su surface flotante. Espeja el `close_completion` + /// interno (usado por Escape y las teclas de edición). + pub fn close_completion(&mut self) { + self.completion = None; + self.completion_extra.clear(); + self.completion_index = 0; + self.completion_navegado = false; + } + + /// Empuja una línea al buffer asignándole bloque. Cada `Prompt` abre + /// un bloque nuevo (id monotónico); las demás líneas heredan el + /// bloque abierto. El render usa esto para agrupar cada comando con + /// Empuja una **nota** (línea informativa del shell) al buffer. Público para + /// que el host (chasis) deje avisos en el output —p.ej. el resultado de una + /// búsqueda de archivos que se pintó en el panel del Explorer. + pub fn push_notice(&mut self, text: impl Into) { + self.push_output(OutputLine::notice(text.into())); + } + + /// Radiografía de la vista consola, para el diag del host (gateado por + /// `/tmp/pata-diag`): qué rama estructural pintaría y por qué — el espejo + /// del skin, el bloque activo, su comando (de donde salen las secciones) y + /// cuánta cosecha llegó. Sin locks duros: sólo lecturas del `State`. + pub fn diag_consola(&self) -> String { + let blk = self.current_block; + let cmd = self + .block_command + .get(&blk) + .cloned() + .unwrap_or_default(); + let lines: Vec = self + .output + .iter() + .filter(|l| { + l.block == blk && matches!(l.kind, OutputKind::Stdout | OutputKind::Stderr) + }) + .map(|l| l.text.clone()) + .collect(); + let secs = crate::sections::detect_sections(&cmd, &lines).map(|v| v.len()); + format!( + "skin_vivo={:?} blk={blk} cmd={cmd:?} cosecha={} secs={secs:?}", + self.tui_skin_vivo, + lines.len(), + ) + } + + /// su salida en una card desplegable. + pub(crate) fn push_output(&mut self, mut line: OutputLine) { + if line.kind == OutputKind::Prompt { + self.block_seq += 1; + self.current_block = self.block_seq; + self.block_started.insert(self.current_block, now_unix_secs()); + // Guardamos el comando para que el header sobreviva al recorte del + // buffer en outputs gigantes (ver `command_card`). + self.block_command + .insert(self.current_block, line.text.clone()); + } + line.block = self.current_block; + // History persistente (Fase 5.7): toda línea de body se archiva en + // `surf_history`, con cap por memoria + spill opcional. Filtra los + // que NO son body (igual que `body_lines_for_block`). + push_to_surf_history(&self.surf_history, &line); + push_line(&mut self.output, line); + } + + /// Reserva un bloque nuevo sin tocar `current_block` — para runs que + /// drenan asíncronos (foreground lento, jobs de fondo) y necesitan su + /// propia card aunque otros comandos se intercalen mientras tanto. + pub(crate) fn open_block(&mut self) -> u64 { + self.block_seq += 1; + self.block_started.insert(self.block_seq, now_unix_secs()); + self.block_seq + } + + /// Empuja una línea en un bloque explícito (no en `current_block`). + /// La usa el drenado de runs async para que su salida quede en SU + /// card y no en la del comando que el usuario tipeó mientras tanto. + pub(crate) fn push_in_block(&mut self, block: u64, mut line: OutputLine) { + line.block = block; + push_to_surf_history(&self.surf_history, &line); + push_line(&mut self.output, line); + } + + /// Recupera el hueco reservado por la marca de agua alta: la vuelve a `0` para + /// que el próximo frame reserve desde el contenido vigente. Se llama en cortes + /// naturales (`clear_output`, arranque de un comando nuevo) — nunca en medio de + /// la actividad efímera, que es justo lo que el HWM estabiliza. + pub fn reset_content_hwm(&self) { + if let Ok(mut g) = self.content_hwm.lock() { + *g = 0.0; + } + if let Ok(mut g) = self.content_gap.lock() { + *g = 0.0; + } + } + + /// Vacía el buffer y el set de colapsos. No resetea `block_seq` — + /// mantener ids monotónicos es inofensivo y evita reusos. + pub(crate) fn clear_output(&mut self) { + self.output.clear(); + self.collapsed.clear(); + self.expanded_stages.clear(); + self.reprocess_source = None; + self.compare_anchor = None; + self.scroll_px = 0.0; + self.surf_scroll_anchor = 0.0; + self.surf_scroll_velocity = 0.0; + self.reset_content_hwm(); + // El builtin `clear` resetea también la history persistente; si el + // usuario quería conservar lo previo, debió correr `:save` o leer + // el spill antes. La semántica espeja el `clear` de un terminal. + if let Ok(mut h) = self.surf_history.lock() { + h.clear(); + } + if let Ok(mut c) = self.surf_spilled_visible.lock() { + *c = SurfSpilledCache::default(); + } + } + + /// Cantidad de líneas en el buffer — alimenta el monitor. + pub fn output_len(&self) -> usize { + self.output.len() + } + + /// `true` si hay un comando ejecutándose ahora. + pub fn is_running(&self) -> bool { + self.running.is_some() + } + + /// Estado de actividad para el aviso visual (color del LED): claude tiene + /// prioridad (aunque "corra", queremos su color propio), luego comando en + /// curso = movimiento, si no quieto. + pub fn activity(&self) -> Activity { + // Espejo sin lock (`tui_skin_vivo`): `running_skin()` try_lockea y + // bajo streaming el LED perdía el color claude cada frame contendido. + if self.tui_skin_vivo == Some(AppSkin::Claude) { + Activity::Claude + } else if self.is_running() { + Activity::Busy + } else { + Activity::Idle + } + } + + /// **Título de contexto** del shell: lo que la pestaña debe decir de sí + /// misma. Tres fuentes, en orden de autoridad: + /// + /// 1. el título que el programa puso por **OSC 0/2** — es lo que cualquier + /// terminal muestra en su tab, y quien lo pone sabe mejor que nadie qué + /// está haciendo (vim el archivo, ssh el host, un PS1 decente el cwd); + /// 2. el **programa que corre** (`cargo`, `claude`, `vim`), si no puso título; + /// 3. el **cwd** cuando no corre nada — el estado de reposo de un shell. + /// + /// Ya viene recortado a [`crate::campana::TITULO_MAX`]. + pub fn titulo_contexto(&self) -> String { + if let Some(t) = self + .titulo_osc + .as_deref() + .map(str::trim) + .filter(|t| !t.is_empty()) + { + return crate::campana::acortar(t); + } + if let Some(c) = self.comando_vivo.as_deref().filter(|c| !c.is_empty()) { + return crate::campana::acortar(c); + } + crate::campana::acortar(&crate::campana::nombre_de_cwd(&self.cwd)) + } + + /// **Reancla el caret**: lo deja sólido un instante y recién después titila. + /// Hay que llamarla en TODO camino que toque el input — tecla, click, pegado, + /// navegación. + /// + /// Son dos relojes porque son dos dueños: `input_edit_at_ms` lo mira el + /// chasis (marquesina, ghost, placeholder) y `marcar_actividad` es el del + /// widget (parpadeo y estela). Tenerlos separados hizo que el caret se + /// apagara **mientras se escribía**: el tipeo sólo movía el del chasis, así + /// que el widget seguía creyendo que la última actividad fue el último + /// click, entraba en parpadeo y quedaba invisible medio segundo por vez. + /// Un solo método para los dos es la única forma de que no se separen otra vez. + pub fn reanclar_caret(&mut self) { + let ahora = now_unix_millis(); + self.input_edit_at_ms = ahora; + self.input.ed().marcar_actividad(ahora); + } + + /// Campanadas acumuladas por este shell (BEL + notificaciones OSC). Ver + /// [`crate::campana`]. Monótono: el chasis compara contra lo que ya acusó. + pub fn campanadas(&self) -> u64 { + self.campanadas + } + + /// Se lleva las notificaciones de escritorio cosechadas de los programas. + pub fn tomar_notificaciones(&mut self) -> Vec { + std::mem::take(&mut self.notificaciones) + } + + /// El caudal de salida para pintarlo como cava. Ver [`crate::pulso`]. + pub fn pulso(&self) -> &crate::pulso::Pulso { + &self.pulso + } + + /// `true` si el PTY vivo está en **alternate screen** — una TUI de pantalla + /// completa (vim, htop, less, man…) que necesita capturar Esc. El chasis lo + /// consulta para decidir si Esc cierra el drawer Quake (no hay TUI) o se + /// reenvía al programa (sí la hay). + pub fn is_fullscreen_tui(&self) -> bool { + // Espejo sin lock (lo refresca el drain): el try_lock de + // `is_tui_fullscreen` flickeaba bajo streaming. + self.tui_altscreen_vivo + } + + /// `true` si hay un **PTY interactivo vivo** (esté o no en pantalla + /// completa): el programa consume teclado. El chasis lo usa para tratar el + /// drawer como una terminal estable — sin cierres por gesto liviano ni por + /// watchdog mientras el proceso siga vivo. + pub fn tiene_pty_vivo(&self) -> bool { + // Espejo sin lock — el try_lock de `is_tui_active` hacía que el PS1 + // de consola del host "dejara de salir" bajo streaming. + self.tui_skin_vivo.is_some() + } + + /// A6 — comandos largos terminados pendientes de acuse (los que el chasis + /// badgea en el diente de la sesión cuando no está activa). + pub fn long_alerts(&self) -> usize { + self.long_alerts + } + + /// A6 — el usuario volvió a esta sesión: limpia la badge de comando largo. + /// Lo llama el chasis al activar la sesión (y por Tick mientras es la activa). + pub fn ack_long_alerts(&mut self) { + self.long_alerts = 0; + } + + /// Devuelve el `ActiveRun` (foreground o background) cuyo bloque es + /// `block`, si existe — sin importar si sigue vivo. Permite dirigir el + /// stdin del input a CUALQUIER comando en curso, no sólo al foreground. + pub(crate) fn job_by_block(&self, block: u64) -> Option>> { + if let Some(r) = self.running.as_ref() { + if r.lock().map(|g| g.block == block).unwrap_or(false) { + return Some(r.clone()); + } + } + self.bg_jobs + .iter() + .find(|j| j.lock().map(|g| g.block == block).unwrap_or(false)) + .cloned() + } + + /// `true` si `block` pertenece a un comando que sigue corriendo (no ha + /// cerrado). Lo usa el render para no plegar las ejecuciones vivas y el + /// `run_submitted` para no hacerlas recede al arrancar otro comando. + pub(crate) fn block_has_live_job(&self, block: u64) -> bool { + match self.job_by_block(block) { + Some(arc) => arc + .lock() + .map(|g| !g.handle.is_finished()) + .unwrap_or(false), + None => false, + } + } + + /// Snapshot del grafo de intenciones — el chasis lo lee cada tick + /// y lo sincroniza al `shuma-module-canvas` activo. + pub fn intent_graph(&self) -> &SessionGraph { + &self.intent_graph + } +} + +#[cfg(test)] +mod graphics_glue_tests { + use super::*; + use base64::Engine; + + /// Una secuencia kitty RGBA que entra por `process_bytes` debe aparecer + /// como un `TermImage` en la sesión (decodificada a `peniko::Image`), y el + /// texto que la rodea debe llegar igual al vt100. + #[test] + fn process_bytes_acumula_imagen_kitty() { + let mut tui = TuiSession::new("chafa", 24, 80); + // 2×2 RGBA crudo (16 bytes), f=32, con celdas pedidas c=4,r=2. + let raw: Vec = (0..16).map(|i| i as u8 * 8).collect(); + let b64 = base64::engine::general_purpose::STANDARD.encode(&raw); + let seq = format!("hola\x1b_Gf=32,s=2,v=2,c=4,r=2,a=T;{b64}\x1b\\chau"); + + let responses = tui.process_bytes(seq.as_bytes()); + assert!(responses.is_empty(), "sin queries, sin respuestas"); + assert_eq!(tui.images.len(), 1, "debió acumular una imagen"); + let ti = &tui.images[0]; + assert_eq!((ti.px_w, ti.px_h), (2, 2)); + assert_eq!((ti.cols, ti.rows), (4, 2), "celdas pedidas por el protocolo"); + // El texto «hola»/«chau» llegó al vt100 (la imagen no lo comió). + let dump = tui.parser.screen().contents(); + assert!(dump.contains("hola") && dump.contains("chau"), "vt100: {dump:?}"); + } + + /// Una query de capacidad kitty produce una respuesta para escribir por + /// stdin (el handshake que hace que chafa elija kitty). + #[test] + fn process_bytes_responde_query() { + let mut tui = TuiSession::new("chafa", 24, 80); + let responses = tui.process_bytes(b"\x1b_Gi=7,a=q;AAAA\x1b\\"); + assert_eq!(responses.len(), 1); + let s = String::from_utf8_lossy(&responses[0]); + assert!(s.contains("OK"), "respuesta de query: {s}"); + assert!(tui.images.is_empty(), "una query no agrega imagen"); + } +} diff --git a/02_ruway/shuma/sandbox/shuma-module-shell/src/update.rs b/02_ruway/shuma/sandbox/shuma-module-shell/src/update.rs deleted file mode 100644 index 702f513..0000000 --- a/02_ruway/shuma/sandbox/shuma-module-shell/src/update.rs +++ /dev/null @@ -1,1737 +0,0 @@ -use super::*; - -/// Mapea `action_id` de `ShortcutAction::ModuleAction` al `Msg`. -pub fn dispatch(action_id: &str) -> Option { - match action_id { - "shell.clear" => Some(Msg::Clear), - "shell.cancel" => Some(Msg::Cancel), - _ => None, - } -} - -/// Traduce un `KeyEvent` a una llamada sobre `LineState`. Devuelve -/// `true` si tocó el state. No maneja Enter, Tab, Up/Down ni Ctrl-C -/// (esos los intercepta el `update` del módulo). -pub(crate) fn apply_key_to_line(line: &mut LineState, ev: &KeyEvent) -> bool { - match &ev.key { - Key::Named(NamedKey::Backspace) => { - line.backspace(); - true - } - Key::Named(NamedKey::Delete) => { - line.delete(); - true - } - Key::Named(NamedKey::ArrowLeft) => { - if ev.modifiers.ctrl { - line.move_word_left(); - } else { - line.move_left(); - } - true - } - Key::Named(NamedKey::ArrowRight) => { - if ev.modifiers.ctrl { - line.move_word_right(); - } else { - line.move_right(); - } - true - } - Key::Named(NamedKey::Home) => { - line.move_home(); - true - } - Key::Named(NamedKey::End) => { - line.move_end(); - true - } - Key::Named(NamedKey::Space) => { - line.insert(" "); - true - } - _ => { - if let Some(text) = &ev.text { - if !text.is_empty() && !text.chars().any(|c| c.is_control()) { - line.insert(text); - return true; - } - } - false - } - } -} - -pub fn update(state: State, msg: Msg) -> State { - let mut s = state; - match msg { - Msg::Key(ev) => { - if ev.state != KeyState::Pressed { - return s; - } - // Si hay un TUI activo, las teclas van al stdin del PTY - // (no al input). El usuario sale tipeando dentro del TUI - // (`:q` en vim, `q` en less, etc.). - if is_tui_active(&s) { - // Shift+Insert siempre pega. Ctrl-V también — en TUIs - // tipo less/vim no suele ser un binding (vim usa Ctrl-V - // para visual-block en normal mode; al editar dentro - // de insert mode tampoco). Si choca con un usuario - // específico, en el futuro lo gateamos por allowlist. - let paste = (ev.modifiers.ctrl - && matches!(&ev.key, Key::Character(c) if c.eq_ignore_ascii_case("v"))) - || (ev.modifiers.shift && matches!(&ev.key, Key::Named(NamedKey::Insert))); - if paste { - forward_paste_to_pty(&s); - return s; - } - forward_key_to_pty(&s, &ev); - return s; - } - // Si el overlay de búsqueda está abierto, las teclas van ahí. - if s.history_search.is_some() { - return handle_search_key(s, &ev); - } - // Ctrl-C: si hay run vivo, mandarle SIGTERM y comer la tecla. - if ev.modifiers.ctrl - && matches!(&ev.key, Key::Character(c) if c.eq_ignore_ascii_case("c")) - { - if s.running.is_some() { - return cancel_running(s); - } - } - // Ctrl-V (o Shift+Insert): pega del clipboard al input. - // (Si hay TUI, lo intercepta `is_tui_active` arriba; ese - // camino tiene su propio paste.) - let is_paste = (ev.modifiers.ctrl - && matches!(&ev.key, Key::Character(c) if c.eq_ignore_ascii_case("v"))) - || (ev.modifiers.shift && matches!(&ev.key, Key::Named(NamedKey::Insert))); - if is_paste { - if let Some(text) = read_clipboard() { - s.input.insert(&text); - } - return s; - } - // Ctrl-R: abrir overlay de búsqueda de historial. - if ev.modifiers.ctrl - && matches!(&ev.key, Key::Character(c) if c.eq_ignore_ascii_case("r")) - { - s.history_search = Some(HistorySearch::default()); - return s; - } - // Popup de completado abierto: las teclas lo navegan. - if s.completion.is_some() { - match &ev.key { - // Tab cicla adelante; Shift+Tab atrás. - Key::Named(NamedKey::Tab) => { - return cycle_completion(s, if ev.modifiers.shift { -1 } else { 1 }); - } - Key::Named(NamedKey::ArrowDown) => return cycle_completion(s, 1), - Key::Named(NamedKey::ArrowUp) => return cycle_completion(s, -1), - // Enter o flecha derecha: acepta el resaltado (no ejecuta). - Key::Named(NamedKey::Enter) | Key::Named(NamedKey::ArrowRight) => { - return accept_completion(s); - } - Key::Named(NamedKey::Escape) => { - close_completion(&mut s); - return s; - } - // Cualquier otra tecla cierra el popup y se procesa normal. - _ => close_completion(&mut s), - } - } - // F1..F8: ejecuta el grupo guardado de esa posición (`:save`). - // (F12 lo reserva el chasis para cerrar.) - if let Some(idx) = fkey_index(&ev.key) { - return run_group(s, idx); - } - // Enter: ejecuta — pero si el texto deja una construcción - // abierta (quote, paren, heredoc, `\` final, pipe pendiente), - // insertamos un salto de línea y seguimos editando. - // Shift+Enter fuerza salto de línea siempre. - if let Key::Named(NamedKey::Enter) = ev.key { - let pending = shuma_line::needs_continuation(s.input.text()); - if pending || ev.modifiers.shift { - s.input.insert("\n"); - s.history_cursor = None; - return s; - } - s.history_cursor = None; - s = run_submitted(s); - return s; - } - // Tab: completion. - if let Key::Named(NamedKey::Tab) = ev.key { - return apply_completion_msg(s); - } - // Up/Down: navegación de historial. - if let Key::Named(NamedKey::ArrowUp) = ev.key { - return navigate_history(s, shuma_history::Nav::Older); - } - if let Key::Named(NamedKey::ArrowDown) = ev.key { - return navigate_history(s, shuma_history::Nav::Newer); - } - // Flecha derecha al final de línea con ghost visible: acepta ghost. - if let Key::Named(NamedKey::ArrowRight) = ev.key { - if !ev.modifiers.ctrl && s.input.cursor() == s.input.text().len() { - if let Some(suffix) = current_ghost(&s) { - if !suffix.is_empty() { - s.input.insert(&suffix); - return s; - } - } - } - } - apply_key_to_line(&mut s.input, &ev); - // Cualquier edición rompe el cursor de navegación de historial. - s.history_cursor = None; - } - Msg::FocusInput => { - s.focused = true; - } - Msg::Clear => { - s.clear_output(); - } - Msg::ToggleBlock(id) => { - if !s.collapsed.remove(&id) { - s.collapsed.insert(id); - } - } - Msg::Scroll(delta) => { - // `out_overflow` lo publicó la última `view`; clampa sin que - // el handler tenga que recomputar la geometría. - let overflow = s.out_overflow.lock().map(|g| *g).unwrap_or(0.0); - s.scroll_px = (s.scroll_px + delta).clamp(0.0, overflow); - } - Msg::RunLine(line) => { - s.input.set_text(line); - s = run_submitted(s); - } - Msg::ToggleStage { block, stage } => { - let key = (block, stage); - if !s.expanded_stages.remove(&key) { - s.expanded_stages.insert(key); - } - } - Msg::SetReprocess(block) => { - // Toggle: re-armar el mismo bloque lo desarma. - if s.reprocess_source == Some(block) { - s.reprocess_source = None; - } else { - s.reprocess_source = Some(block); - s.focused = true; - } - } - Msg::RunGroup(idx) => { - s = run_group(s, idx); - } - Msg::Tick => { - s = drain_run(s); - } - Msg::Cancel => { - if s.running.is_some() { - s = cancel_running(s); - } - } - Msg::OpenDecoration(kind) => { - s = open_decoration(s, kind); - } - Msg::InsertAtCursor(text) => { - // Cerramos cualquier overlay activo para que el texto - // pegado quede visible sin tener que cerrar el Ctrl-R a mano. - s.history_search = None; - s.history_cursor = None; - s.input.insert(&text); - s.focused = true; - } - Msg::VimPaste => { - // Sólo aplica si hay un TUI vivo; `forward_paste_to_pty` es - // no-op silencioso si no. - forward_paste_to_pty(&s); - } - Msg::VimDrag { - end, - dx, - dy, - ax, - ay, - } => { - let fresh = s.vim_sel.map_or(true, |v| !v.active); - if fresh { - s.vim_sel = Some(VimSel { - ax, - ay, - hx: ax + dx, - hy: ay + dy, - active: !end, - }); - } else if let Some(v) = s.vim_sel.as_mut() { - v.hx += dx; - v.hy += dy; - if end { - v.active = false; - } - } - if end { - // Umbral mínimo de drag: un click (o jitter sub-celda) no - // selecciona ni copia. Exige cruzar ~una celda para contar. - let dragged = s.vim_sel.is_some_and(|v| { - let (dx, dy) = (v.hx - v.ax, v.hy - v.ay); - (dx * dx + dy * dy).sqrt() >= crate::view::VIM_CHAR_W as f32 - }); - if dragged { - copy_vim_selection(&s); - } else { - s.vim_sel = None; - } - } - } - } - s -} - -/// Acciona el click sobre una decoración del output. Ninguna acción -/// bloquea la UI: `xdg-open` se forkea detached, y los cambios al -/// state (cwd, input) son in-memory. -pub(crate) fn open_decoration(mut s: State, kind: shuma_line::DecorationKind) -> State { - use shuma_line::DecorationKind as Dk; - match kind { - Dk::Path { - abs, - is_dir, - is_executable, - .. - } => { - if is_dir { - // Directorios → cd. Cambia el cwd y lo refleja en el - // header sin "ejecutar" un comando. - if abs.is_dir() { - s.cwd = abs; - s.completion_source = Arc::new(ShellSource::new(&s.cwd)); - } - } else if is_executable { - // Binarios → pre-llenar el input con el path; el - // usuario decide los args y Enter. - s.input.set_text(abs.display().to_string()); - } else { - // Archivos regulares → xdg-open detached. - spawn_detached("xdg-open", &[abs.display().to_string().as_str()]); - } - } - Dk::Url(url) => { - spawn_detached("xdg-open", &[&url]); - } - Dk::GrepRef { abs, line_no, col } => { - // `$EDITOR +line file` para vim/neovim/helix; si no hay - // EDITOR, xdg-open al archivo y listo. - if let Ok(editor) = std::env::var("EDITOR") { - let line_flag = format!("+{line_no}"); - let path = abs.display().to_string(); - let args: Vec<&str> = match col { - Some(_) => vec![&line_flag, &path], - None => vec![&line_flag, &path], - }; - spawn_detached(&editor, &args); - } else { - spawn_detached("xdg-open", &[abs.display().to_string().as_str()]); - } - } - Dk::GitSha(sha) => { - // Pre-llenar `git show ` — la acción más útil 99% del tiempo. - s.input.set_text(format!("git show {sha}")); - } - Dk::IssueRef(_) | Dk::BoxDraw => { - // Sin acción asociada. - } - } - s -} - -/// Lanza un proceso "detached" — no esperamos, no leemos su output, -/// y el padre puede morir sin matarlo (`process_group(0)` para -/// despegarlo de la sesión de shuma). Usado para `xdg-open` y `$EDITOR` -/// disparados desde clicks. -pub(crate) fn spawn_detached(program: &str, args: &[&str]) { - use std::os::unix::process::CommandExt; - let _ = std::process::Command::new(program) - .args(args) - .stdin(std::process::Stdio::null()) - .stdout(std::process::Stdio::null()) - .stderr(std::process::Stdio::null()) - .process_group(0) - .spawn(); -} - -/// Aplica un Tab: -/// - popup abierto: cicla al siguiente candidato (no toca el texto, así el -/// rango de reemplazo del `Completion` guardado sigue válido). -/// - popup cerrado: 0 candidatos → nada; 1 → lo inserta directo; ≥2 → abre -/// el popup con el primero resaltado (sin tocar el texto todavía). -pub(crate) fn apply_completion_msg(mut s: State) -> State { - if let Some(comp) = &s.completion { - let n = comp.candidates.len(); - if n > 0 { - s.completion_index = (s.completion_index + 1) % n; - } - return s; - } - let comp = s.input.complete(s.completion_source.as_ref()); - if comp.is_empty() { - return s; - } - if comp.candidates.len() == 1 { - let candidate = comp.candidates[0].clone(); - s.input.apply_completion(&comp, &candidate); - return s; - } - s.completion = Some(comp); - s.completion_index = 0; - s -} - -/// Cierra el popup de completado sin aplicar nada. -pub(crate) fn close_completion(s: &mut State) { - s.completion = None; - s.completion_index = 0; -} - -/// Cicla el candidato resaltado del popup (`delta` ±1, con wrap). No-op si -/// el popup está cerrado. -pub(crate) fn cycle_completion(mut s: State, delta: i32) -> State { - if let Some(comp) = &s.completion { - let n = comp.candidates.len() as i32; - if n > 0 { - s.completion_index = (s.completion_index as i32 + delta).rem_euclid(n) as usize; - } - } - s -} - -/// Acepta el candidato resaltado del popup, lo inserta y cierra el popup. -pub(crate) fn accept_completion(mut s: State) -> State { - if let Some(comp) = s.completion.take() { - if let Some(candidate) = comp.candidates.get(s.completion_index) { - s.input.apply_completion(&comp, candidate); - } - } - s.completion_index = 0; - s -} - -/// Prefijo común más largo de un slice de strings — usado en completion -/// cuando hay múltiples candidatos. -pub(crate) fn common_prefix(items: &[String]) -> String { - let Some(first) = items.first() else { - return String::new(); - }; - let mut end = first.len(); - for s in &items[1..] { - let bytes = s.as_bytes(); - let fbytes = first.as_bytes(); - let mut i = 0; - while i < end && i < bytes.len() && bytes[i] == fbytes[i] { - i += 1; - } - end = i; - if end == 0 { - break; - } - } - // Asegurarse de cortar en límite de carácter UTF-8. - while end > 0 && !first.is_char_boundary(end) { - end -= 1; - } - first[..end].to_string() -} - -/// Navega el historial por Up/Down. -pub(crate) fn navigate_history(mut s: State, dir: shuma_history::Nav) -> State { - let next = { - let history = s.history.lock().unwrap(); - history - .navigate(s.history_cursor, dir) - .map(|(i, e)| (i, e.line.clone())) - }; - if let Some((i, line)) = next { - s.history_cursor = Some(i); - s.input.set_text(line); - } else if matches!(dir, shuma_history::Nav::Newer) { - // Salir del historial al final: línea vacía. - s.history_cursor = None; - s.input.clear(); - } - s -} - -/// Maneja teclas mientras el overlay Ctrl-R está abierto. -pub(crate) fn handle_search_key(mut s: State, ev: &KeyEvent) -> State { - let Some(mut search) = s.history_search.take() else { - return s; - }; - match &ev.key { - Key::Named(NamedKey::Escape) => { - // Salida sin aceptar. - return s; - } - Key::Named(NamedKey::Enter) => { - // Acepta el seleccionado: pasa a la línea (sin ejecutar). - let pick = { - let history = s.history.lock().unwrap(); - history - .fuzzy_search(&search.query, 50) - .get(search.selected) - .map(|e| e.line.clone()) - }; - if let Some(line) = pick { - s.input.set_text(line); - } - return s; - } - Key::Named(NamedKey::Backspace) => { - search.query.pop(); - search.selected = 0; - } - Key::Named(NamedKey::ArrowDown) => { - let history = s.history.lock().unwrap(); - let max = history.fuzzy_search(&search.query, 50).len(); - if max > 0 && search.selected + 1 < max { - search.selected += 1; - } - } - Key::Named(NamedKey::ArrowUp) => { - search.selected = search.selected.saturating_sub(1); - } - _ => { - if let Some(text) = &ev.text { - if !text.is_empty() && !text.chars().any(|c| c.is_control()) { - search.query.push_str(text); - search.selected = 0; - } - } - } - } - s.history_search = Some(search); - s -} - -/// `true` si hay un `ActiveRun` en modo TUI (PTY + vt100). Las teclas -/// van al stdin del PTY mientras esto sea cierto. -pub(crate) fn is_tui_active(s: &State) -> bool { - let Some(arc) = s.running.as_ref() else { - return false; - }; - let g = match arc.lock() { - Ok(g) => g, - Err(p) => p.into_inner(), - }; - g.tui.is_some() -} - -/// Traduce una tecla a su secuencia de bytes para el PTY (xterm-compat). -/// Las TUIs esperan estos códigos. -pub(crate) fn key_to_pty_bytes(ev: &KeyEvent) -> Vec { - match &ev.key { - Key::Named(NamedKey::Enter) => b"\r".to_vec(), - Key::Named(NamedKey::Tab) => b"\t".to_vec(), - Key::Named(NamedKey::Backspace) => b"\x7f".to_vec(), - Key::Named(NamedKey::Escape) => b"\x1b".to_vec(), - Key::Named(NamedKey::ArrowUp) => b"\x1b[A".to_vec(), - Key::Named(NamedKey::ArrowDown) => b"\x1b[B".to_vec(), - Key::Named(NamedKey::ArrowRight) => b"\x1b[C".to_vec(), - Key::Named(NamedKey::ArrowLeft) => b"\x1b[D".to_vec(), - Key::Named(NamedKey::Home) => b"\x1b[H".to_vec(), - Key::Named(NamedKey::End) => b"\x1b[F".to_vec(), - Key::Named(NamedKey::PageUp) => b"\x1b[5~".to_vec(), - Key::Named(NamedKey::PageDown) => b"\x1b[6~".to_vec(), - Key::Named(NamedKey::Delete) => b"\x1b[3~".to_vec(), - Key::Named(NamedKey::Space) => b" ".to_vec(), - _ => { - // Ctrl-: codifica el byte 0x01..0x1a para letras. - if ev.modifiers.ctrl { - if let Key::Character(c) = &ev.key { - if let Some(ch) = c.chars().next() { - let lo = ch.to_ascii_lowercase(); - if ('a'..='z').contains(&lo) { - return vec![(lo as u8) - b'a' + 1]; - } - } - } - } - ev.text.as_deref().unwrap_or("").as_bytes().to_vec() - } - } -} - -/// Lee el clipboard del SO (vía `arboard`). Devuelve `None` si no hay -/// display server, está vacío, o el contenido no es texto. No cachea — -/// el sistema tiene su propio TTL. -pub(crate) fn read_clipboard() -> Option { - let mut clip = arboard::Clipboard::new().ok()?; - clip.get_text().ok() -} - -/// Escribe texto al clipboard del SO. No-op silencioso sin display server. -pub(crate) fn set_clipboard(text: &str) { - if let Ok(mut clip) = arboard::Clipboard::new() { - let _ = clip.set_text(text.to_string()); - } -} - -/// Extrae el texto de la selección del card de vim sobre el screen -/// actual del PTY y lo copia al clipboard. Selección lineal por filas -/// (estilo terminal), cada fila recortada de espacios al final. -pub(crate) fn copy_vim_selection(s: &State) { - let Some(vs) = s.vim_sel else { return }; - let Some(arc) = s.running.as_ref() else { - return; - }; - let guard = match arc.lock() { - Ok(g) => g, - Err(p) => p.into_inner(), - }; - let Some(tui) = guard.tui.as_ref() else { - return; - }; - let screen = tui.parser.screen(); - let (rows, cols) = screen.size(); - let mut grid: Vec> = Vec::with_capacity(rows as usize); - for r in 0..rows { - let mut line: Vec = Vec::with_capacity(cols as usize); - for c in 0..cols { - let ch = match screen.cell(r, c) { - Some(cell) if cell.has_contents() => cell.contents().chars().next().unwrap_or(' '), - _ => ' ', - }; - line.push(ch); - } - grid.push(line); - } - let (cw, lh) = match s.vim_metrics.lock() { - Ok(g) if g.0 > 1.0 && g.1 > 1.0 => (g.0 as f64, g.1 as f64), - _ => (crate::view::VIM_CHAR_W, crate::view::VIM_LINE_H), - }; - let (r0, c0) = crate::view::vim_px_to_cell(vs.ax as f64, vs.ay as f64, cw, lh); - let (r1, c1) = crate::view::vim_px_to_cell(vs.hx as f64, vs.hy as f64, cw, lh); - let (sr, sc, er, ec) = if (r0, c0) <= (r1, c1) { - (r0, c0, r1, c1) - } else { - (r1, c1, r0, c0) - }; - if sr >= grid.len() { - return; - } - let er = er.min(grid.len() - 1); - let mut out = String::new(); - for r in sr..=er { - let line = &grid[r]; - let lo = if r == sr { sc.min(line.len()) } else { 0 }; - let hi = if r == er { - (ec + 1).min(line.len()) - } else { - line.len() - }; - if hi > lo { - let seg: String = line[lo..hi].iter().collect(); - out.push_str(seg.trim_end()); - } - if r != er { - out.push('\n'); - } - } - if !out.trim().is_empty() { - set_clipboard(&out); - } -} - -/// Pega el contenido del clipboard en el PTY del run activo. Si el TUI -/// hijo está en bracketed-paste mode (DECSET 2004), envuelve la -/// secuencia en `\x1b[200~...\x1b[201~` para que vim, less y emacs -/// distingan "tipeé esto" de "pegué esto" (auto-indent, paste-mode, -/// etc.). No-op silencioso si no hay TUI o el clipboard está vacío. -pub(crate) fn forward_paste_to_pty(s: &State) { - let Some(arc) = s.running.as_ref() else { - return; - }; - let Some(text) = read_clipboard() else { - return; - }; - if text.is_empty() { - return; - } - let guard = match arc.lock() { - Ok(g) => g, - Err(p) => p.into_inner(), - }; - let bracketed = guard - .tui - .as_ref() - .map(|t| t.parser.screen().bracketed_paste()) - .unwrap_or(false); - let payload: Vec = if bracketed { - let mut buf: Vec = b"\x1b[200~".to_vec(); - buf.extend_from_slice(text.as_bytes()); - buf.extend_from_slice(b"\x1b[201~"); - buf - } else { - text.into_bytes() - }; - guard.handle.write_input(payload); -} - -/// Manda los bytes de la tecla al PTY del run activo. No-op si no hay -/// tui activo. -pub(crate) fn forward_key_to_pty(s: &State, ev: &KeyEvent) { - let Some(arc) = s.running.as_ref() else { - return; - }; - let bytes = key_to_pty_bytes(ev); - if bytes.is_empty() { - return; - } - let guard = match arc.lock() { - Ok(g) => g, - Err(p) => p.into_inner(), - }; - guard.handle.write_input(bytes); -} - -/// Rama de git activa para `cwd` — `None` si no estamos en un repo (o si -/// HEAD está detached). Implementación minimalista por archivo: sube por -/// los padres buscando `.git`, lee `HEAD` y extrae `refs/heads/`. No -/// usa libgit2 ni lanza procesos (barato de llamar por frame). -pub(crate) fn git_branch(cwd: &std::path::Path) -> Option { - let mut dir = cwd.to_path_buf(); - let git_dir = loop { - let candidate = dir.join(".git"); - if candidate.exists() { - break candidate; - } - if !dir.pop() { - return None; - } - }; - // `.git` puede ser un archivo (worktrees/submódulos) con `gitdir: …`, - // o un directorio con `HEAD` dentro. - let head_path = if git_dir.is_file() { - let s = std::fs::read_to_string(&git_dir).ok()?; - let target = s.strip_prefix("gitdir:")?.trim(); - std::path::PathBuf::from(target).join("HEAD") - } else { - git_dir.join("HEAD") - }; - let head = std::fs::read_to_string(head_path).ok()?; - head.trim() - .strip_prefix("ref: refs/heads/") - .map(|b| b.to_string()) -} - -/// Marcadores de proyecto: archivos/dirs que identifican la "forma" de un -/// directorio. Gatean la predicción por estructura (no sugerir `cargo` sin -/// `Cargo.toml`). -const PROJECT_MARKERS: &[&str] = &[ - ".git", - "Cargo.toml", - "package.json", - "go.mod", - "Makefile", - "pyproject.toml", - "pom.xml", - "build.gradle", -]; - -/// Marcadores de proyecto presentes en `dir`. -fn markers_in(dir: &str) -> Vec { - let base = std::path::Path::new(dir); - PROJECT_MARKERS - .iter() - .filter(|m| base.join(m).exists()) - .map(|m| m.to_string()) - .collect() -} - -/// Construye los `CommandRecord` de `shuma-infer` a partir del historial -/// (éxito = exit 0). -fn infer_records(s: &State) -> Vec { - let Ok(history) = s.history.lock() else { - return Vec::new(); - }; - history - .entries() - .iter() - // El historial Llimphi aún no graba el exit (siempre `None`): - // tratamos lo desconocido como éxito para no descartar todo el - // corpus. Si más adelante se registra el exit, los fallos - // (`Some(c!=0)`) quedan excluidos automáticamente. - .map(|e| { - let ok = e.exit.map_or(true, |c| c == 0); - shuma_infer::CommandRecord::parse(&e.line, e.cwd.clone(), ok) - }) - .collect() -} - -/// Recalcula los patrones emergentes del historial y los cachea en el -/// state. Se llama al cerrar cada comando (cuando el historial creció). -pub(crate) fn refresh_patterns(s: &mut State) { - let records = infer_records(s); - s.patterns = shuma_infer::detect_patterns(&records, &shuma_infer::InferConfig::default()); -} - -/// Condición de disparo de un patrón: los marcadores de proyecto comunes a -/// todos los directorios donde corrió. -fn pattern_trigger(p: &shuma_infer::EmergingPattern) -> Vec { - let mut dirs = p.directories.iter(); - let Some(first) = dirs.next() else { - return Vec::new(); - }; - let mut common = markers_in(first); - for d in dirs { - let here = markers_in(d); - common.retain(|m| here.contains(m)); - } - common -} - -/// La secuencia que el motor predice como continuación de la sesión, si la -/// hay y el cwd comparte la forma del patrón. -pub(crate) fn predicted_sequence(s: &State) -> Option { - if s.patterns.is_empty() { - return None; - } - let records = infer_records(s); - let tail = &records[records.len().saturating_sub(6)..]; - let (pi, next) = shuma_infer::predict_next(tail, &s.patterns)?; - if next.is_empty() { - return None; - } - // Disparo por estructura: no anticipar un patrón en un directorio que - // no comparte su forma (no sugerir `cargo` sin `Cargo.toml`). - let trigger = pattern_trigger(&s.patterns[pi]); - if !trigger.is_empty() { - let here = markers_in(&s.cwd.to_string_lossy()); - if !trigger.iter().all(|m| here.contains(m)) { - return None; - } - } - Some(next.join(" && ")) -} - -/// Sugerencia "ghost" para la línea actual — la secuencia predicha por el -/// motor de patrones (si aplica) y, tras ella, el prefijo histórico más -/// reciente que extiende el texto que ya está tipeado. -pub(crate) fn current_ghost(s: &State) -> Option { - let text = s.input.text(); - if text.is_empty() || s.input.cursor() != text.len() { - return None; - } - // Corpus por prioridad: secuencia predicha primero, luego historial. - let mut corpus: Vec = Vec::new(); - if let Some(seq) = predicted_sequence(s) { - corpus.push(seq); - } - if let Ok(history) = s.history.lock() { - corpus.extend(history.entries().iter().rev().map(|e| e.line.clone())); - } - shuma_line::ghost_suggestion(text, &corpus) -} - -pub(crate) fn run_submitted(mut s: State) -> State { - let line = s.input.text().to_string(); - let trimmed = line.trim().to_string(); - s.input.clear(); - if trimmed.is_empty() { - return s; - } - s.push_output(OutputLine::prompt(format!("$ {trimmed}"))); - - // Append al historial — todo lo que el usuario Enter-eó queda - // registrado, builtins incluidos (para que `cd ../foo` reaparezca - // por Up). `IgnoreConsecutive` evita ráfagas iguales. - { - let now = std::time::SystemTime::now() - .duration_since(std::time::UNIX_EPOCH) - .map(|d| d.as_secs()) - .unwrap_or(0); - let entry = shuma_history::Entry::new(trimmed.clone(), s.cwd.display().to_string(), now); - if let Ok(mut h) = s.history.lock() { - let _ = h.append(entry); - } - } - // Recalcula los patrones emergentes con el historial ya actualizado — - // alimentan la predicción del ghost para el próximo comando. - refresh_patterns(&mut s); - - // Builtins primero — no spawnean proceso, corren aunque haya run vivo. - if let Some((cmd, rest)) = split_first_word(&trimmed) { - match cmd { - "cd" => { - return apply_cd(s, rest); - } - "pwd" => { - let cwd_str = s.cwd.display().to_string(); - s.push_output(OutputLine::stdout(cwd_str)); - return s; - } - "clear" => { - s.clear_output(); - return s; - } - "exit" => { - s.push_output(OutputLine::notice( - "exit: el chasis maneja la salida (F12 para cerrar)", - )); - return s; - } - ":jobs" => return apply_jobs_list(s), - ":term" => return apply_jobs_signal(s, rest, JobSignal::Term), - ":stop" => return apply_jobs_signal(s, rest, JobSignal::Stop), - ":cont" => return apply_jobs_signal(s, rest, JobSignal::Cont), - ":limit" => return apply_capture_limit(s, rest), - ":spill" => return apply_spill(s, rest), - ":save" => return save_group(s, rest), - ":groups" => return apply_groups_list(s), - _ => {} - } - } - - // Sufijo `&` (con espacios opcionales antes) → background. El - // background siempre arranca, sin encolar; no hay límite. - if let Some(stripped) = trimmed.strip_suffix('&') { - let cmd = stripped.trim_end().to_string(); - if cmd.is_empty() { - return s; - } - return start_bg(s, cmd); - } - - // Comando externo foreground. Si ya hay uno corriendo, lo encolamos; - // si no, arrancamos ahora mismo. - if s.running.is_some() { - s.queue.push_back(trimmed); - s.push_output(OutputLine::notice( - "⌛ en cola — esperando a que el comando actual termine", - )); - return s; - } - start_run(s, trimmed) -} - -#[derive(Debug, Clone, Copy)] -pub(crate) enum JobSignal { - Term, - Stop, - Cont, -} - -/// Lista los bg_jobs con su índice y comando. Marca finalizados. -pub(crate) fn apply_jobs_list(mut s: State) -> State { - if s.bg_jobs.is_empty() { - s.push_output(OutputLine::notice("(sin jobs en background)")); - return s; - } - // Snapshot de los Arc para no retener el borrow de `s.bg_jobs` - // mientras `push_output` toma `&mut s`. - let jobs = s.bg_jobs.clone(); - for (i, arc) in jobs.iter().enumerate() { - let (cmd, status) = match arc.lock() { - Ok(g) => ( - g.command.clone(), - if g.handle.is_finished() { - "done" - } else { - "running" - }, - ), - Err(p) => { - let g = p.into_inner(); - ( - g.command.clone(), - if g.handle.is_finished() { - "done" - } else { - "running" - }, - ) - } - }; - s.push_output(OutputLine::notice(format!("[{i}] {status} {cmd}"))); - } - s -} - -/// Aplica `:term N` / `:stop N` / `:cont N` al job de índice `N`. -/// Stop/Cont son no-op en jobs sin `Killer` (remotos vía daemon). -pub(crate) fn apply_jobs_signal(mut s: State, rest: &str, sig: JobSignal) -> State { - let idx: usize = match rest.trim().parse() { - Ok(n) => n, - Err(_) => { - s.push_output(OutputLine::notice("uso: :term N | :stop N | :cont N")); - return s; - } - }; - let Some(arc) = s.bg_jobs.get(idx).cloned() else { - s.push_output(OutputLine::notice(format!("no hay job [{idx}]"))); - return s; - }; - let guard = match arc.lock() { - Ok(g) => g, - Err(p) => p.into_inner(), - }; - let acted = match sig { - JobSignal::Term => match guard.killer.as_ref() { - Some(k) => { - k.term(); - true - } - None => { - // Remoto: cancel via stream close. - guard.handle.kill(); - true - } - }, - JobSignal::Stop => guard.killer.as_ref().map(|k| k.stop()).unwrap_or(false), - JobSignal::Cont => guard.killer.as_ref().map(|k| k.cont()).unwrap_or(false), - }; - let label = match sig { - JobSignal::Term => "TERM", - JobSignal::Stop => "STOP", - JobSignal::Cont => "CONT", - }; - drop(guard); - s.push_output(OutputLine::notice(if acted { - format!("[{idx}] SIG{label} enviado") - } else { - format!("[{idx}] no se pudo enviar SIG{label}") - })); - s -} - -/// `:limit ` — tope de captura de stdout por run. `0` = sin tope. -pub(crate) fn apply_capture_limit(mut s: State, rest: &str) -> State { - match rest.trim().parse::() { - Ok(mb) => { - s.capture_limit_bytes = mb.saturating_mul(1024 * 1024); - let msg = if mb == 0 { - "captura sin tope".to_string() - } else { - format!("captura limitada a {mb} MB por comando") - }; - s.push_output(OutputLine::notice(msg)); - } - Err(_) => s.push_output(OutputLine::notice("uso: :limit (0 = sin tope)")), - } - s -} - -/// `:spill on|off` — volcar a disco la salida que excede el `:limit`. -pub(crate) fn apply_spill(mut s: State, rest: &str) -> State { - let arg = rest.trim(); - let on = matches!(arg, "on" | "si" | "sí" | "1" | "true"); - let off = matches!(arg, "off" | "no" | "0" | "false"); - if !on && !off { - s.push_output(OutputLine::notice("uso: :spill on|off")); - return s; - } - s.spill = on; - let note = match (on, s.capture_limit_bytes) { - (true, 0) => "spill activado — pero sin `:limit ` no tiene efecto", - (true, _) => "spill activado — la salida excedente se vuelca a disco", - (false, _) => "spill desactivado", - }; - s.push_output(OutputLine::notice(note)); - s -} - -/// `:save ` — guarda como grupo los comandos del historial desde el -/// último `:save` (excluyendo los meta-comandos `:`). Ejecutables por F1..F8. -pub(crate) fn save_group(mut s: State, rest: &str) -> State { - let name = rest.trim().to_string(); - if name.is_empty() { - s.push_output(OutputLine::notice( - "uso: :save (agrupa los comandos desde el último :save)", - )); - return s; - } - let (lines, hist_len) = { - let Ok(h) = s.history.lock() else { - return s; - }; - let entries = h.entries(); - // El propio `:save` ya entró al historial: lo excluimos junto con el - // resto de meta-comandos `:`. - let upto = entries.len().saturating_sub(1); - let lines: Vec = entries - .get(s.group_anchor..upto) - .unwrap_or(&[]) - .iter() - .map(|e| e.line.clone()) - .filter(|l| !l.trim_start().starts_with(':')) - .collect(); - (lines, entries.len()) - }; - if lines.is_empty() { - s.push_output(OutputLine::notice( - "nada que guardar — corré algún comando antes de `:save`", - )); - return s; - } - // El próximo grupo arranca desde acá. - s.group_anchor = hist_len; - // Reemplaza un grupo homónimo, si existe. - let n = lines.len(); - if let Some(g) = s.groups.iter_mut().find(|g| g.name == name) { - g.lines = lines; - } else { - s.groups.push(CommandGroup { name: name.clone(), lines }); - } - let fkey = s - .groups - .iter() - .position(|g| g.name == name) - .map(|i| i + 1) - .unwrap_or(0); - s.push_output(OutputLine::notice(format!( - "grupo «{name}» guardado ({n} comandos) — F{fkey} lo ejecuta" - ))); - s -} - -/// `:groups` — lista los grupos guardados con su tecla de función. -pub(crate) fn apply_groups_list(mut s: State) -> State { - if s.groups.is_empty() { - s.push_output(OutputLine::notice( - "(sin grupos — `:save ` guarda los últimos comandos)", - )); - return s; - } - let rows: Vec = s - .groups - .iter() - .enumerate() - .map(|(i, g)| format!("F{} {} ({} cmds)", i + 1, g.name, g.lines.len())) - .collect(); - for r in rows { - s.push_output(OutputLine::notice(r)); - } - s -} - -/// Reconstruye el stdout de un bloque (su card) uniendo las líneas -/// `Stdout` sin etapa — para alimentarlo como stdin de un reprocess. -pub(crate) fn gather_block_stdout(s: &State, block: u64) -> String { - let mut out = String::new(); - for l in &s.output { - if l.block == block && l.kind == OutputKind::Stdout && l.stage.is_none() { - out.push_str(&l.text); - out.push('\n'); - } - } - out -} - -/// Índice de grupo (0-based) para F1..F8; `None` para cualquier otra tecla. -pub(crate) fn fkey_index(key: &Key) -> Option { - match key { - Key::Named(NamedKey::F1) => Some(0), - Key::Named(NamedKey::F2) => Some(1), - Key::Named(NamedKey::F3) => Some(2), - Key::Named(NamedKey::F4) => Some(3), - Key::Named(NamedKey::F5) => Some(4), - Key::Named(NamedKey::F6) => Some(5), - Key::Named(NamedKey::F7) => Some(6), - Key::Named(NamedKey::F8) => Some(7), - _ => None, - } -} - -/// Ejecuta el grupo de índice `idx` (0-based) como una sola línea -/// (`l1 && l2 && …`). No-op si no existe ese grupo. -pub(crate) fn run_group(s: State, idx: usize) -> State { - let Some(joined) = s - .groups - .get(idx) - .map(|g| g.lines.join(" && ")) - .filter(|j| !j.is_empty()) - else { - return s; - }; - let mut s = s; - s.input.set_text(joined); - run_submitted(s) -} - -/// Variante de `start_run` que arranca como job background. La salida -/// se mergea al output buffer prefijada por `[N]`. Devuelve `s` con el -/// nuevo job en `bg_jobs`. -pub(crate) fn start_bg(mut s: State, line: String) -> State { - let cwd_str = s.cwd.display().to_string(); - let (spec, _tui) = build_spec(&line, &cwd_str); - // Background no soporta TUI (no le pintamos el grid; el panel - // sería robado al foreground). Si la línea era TUI, la corremos - // sin PTY igual — el binario podrá quejarse, pero al menos no - // tira la UI. - let bg_spec = if matches!(spec.exec, Exec::Pty { .. }) { - let mut s2 = spec.clone(); - s2.exec = Exec::Shell { - line: line.clone(), - program: "bash".into(), - }; - s2 - } else { - spec - }; - let handle = shuma_exec::run(&bg_spec); - let killer = handle.killer(); - let idx = s.bg_jobs.len(); - // Cada job de fondo vive en SU propia card (bloque propio). Sin esto - // su salida se intercalaba en la card del comando de foreground. - let bg_block = s.open_block(); - s.push_in_block(bg_block, OutputLine::prompt(format!("[{idx}] $ {line} &"))); - let active = ActiveRun { - handle: BackendHandle::Local(handle), - killer: Some(killer), - command: line, - tui: None, - block: bg_block, - }; - s.bg_jobs.push(Arc::new(Mutex::new(active))); - s -} - -pub(crate) fn start_run(mut s: State, line: String) -> State { - let cwd_str = s.cwd.display().to_string(); - let (mut spec, tui) = build_spec(&line, &cwd_str); - // Config de captura y reprocess sólo aplican a runs no-PTY (los TUI - // capturan a vt100, no a buffer, y no consumen stdin reprocesado). - if tui.is_none() { - spec.capture_limit = s.capture_limit_bytes; - spec.spill_path = (s.spill && s.capture_limit_bytes > 0).then(|| { - std::env::temp_dir().join(format!( - "shuma-spill-{}-{}.log", - std::process::id(), - s.current_block - )) - }); - // Reprocess armado: el stdout del bloque fuente alimenta el stdin. - if let Some(src) = s.reprocess_source.take() { - let data = gather_block_stdout(&s, src); - if !data.is_empty() { - spec.stdin_data = Some(data); - } - } - } else { - // Un run TUI desarma cualquier reprocess pendiente (no aplica). - s.reprocess_source = None; - } - // Registramos la intención antes de hacer spawn — si el spawn - // remoto falla, igual queda el nodo `%cN` con status `Failed` - // marcado más abajo (vía el RunEvent::Failed que retorna el - // backend). El lienzo refleja el intento. - s.current_run_node = Some(s.intent_graph.record(line.clone())); - s.current_run_bytes = 0; - // El prompt de este run ya abrió su bloque (current_block); fijamos - // que TODA su salida —drenada en ticks futuros— vaya a esa card. - let run_block = s.current_block; - let active = match &s.source { - Source::Local => { - // Camino histórico — exec directo sobre esta máquina. - let handle = shuma_exec::run(&spec); - let killer = handle.killer(); - ActiveRun { - handle: BackendHandle::Local(handle), - killer: Some(killer), - command: line, - tui, - block: run_block, - } - } - Source::Daemon { socket, .. } => { - let sock = socket - .clone() - .unwrap_or_else(shuma_protocol::default_socket_path); - // PTY remoto full-duplex: conservamos la `TuiSession` para - // pintar el terminal localmente; las teclas/resize viajan al - // daemon por el asa remota. - if tui.is_some() { - match shuma_remote_exec::run_pty(&spec, &sock) { - Ok(h) => ActiveRun { - handle: BackendHandle::Remote(h), - killer: None, - command: line, - tui, - block: run_block, - }, - Err(e) => { - s.push_output(OutputLine::notice(format!("✘ daemon pty: {e}"))); - fail_pending_intent(&mut s); - return s; - } - } - } else { - match shuma_remote_exec::run(&spec, &sock) { - Ok(h) => ActiveRun { - handle: BackendHandle::Remote(h), - killer: None, - command: line, - tui: None, - block: run_block, - }, - Err(e) => { - s.push_output(OutputLine::notice(format!("✘ daemon: {e}"))); - fail_pending_intent(&mut s); - return s; - } - } - } - } - Source::DaemonTcp { - addr, - server_pub_hex, - .. - } => { - // Identidad y pubkey del server hacen falta en ambos caminos - // (PTY y no-PTY); las resolvemos una vez antes de ramificar. - let kp = match load_or_create_identity() { - Ok(kp) => kp, - Err(e) => { - s.push_output(OutputLine::notice(format!("✘ identity: {e}"))); - fail_pending_intent(&mut s); - return s; - } - }; - let server_pub = match parse_pub_hex(server_pub_hex) { - Ok(p) => p, - Err(e) => { - s.push_output(OutputLine::notice(format!("✘ server_pub_hex: {e}"))); - fail_pending_intent(&mut s); - return s; - } - }; - if tui.is_some() { - match shuma_remote_exec::run_pty_tcp(&spec, addr, kp, server_pub) { - Ok(h) => ActiveRun { - handle: BackendHandle::Remote(h), - killer: None, - command: line, - tui, - block: run_block, - }, - Err(e) => { - s.push_output(OutputLine::notice(format!("✘ daemon tcp pty: {e}"))); - fail_pending_intent(&mut s); - return s; - } - } - } else { - match shuma_remote_exec::run_tcp(&spec, addr, kp, server_pub) { - Ok(h) => ActiveRun { - handle: BackendHandle::Remote(h), - killer: None, - command: line, - tui: None, - block: run_block, - }, - Err(e) => { - s.push_output(OutputLine::notice(format!("✘ daemon tcp: {e}"))); - fail_pending_intent(&mut s); - return s; - } - } - } - } - Source::Remote { .. } => { - // SSH (matilda usa esta variante para otra cosa). El shell - // no tiene un transporte SSH para comandos arbitrarios aún; - // fallback a local con notice claro. - s.push_output(OutputLine::notice( - "shell vía SSH no implementado todavía — corro local", - )); - let handle = shuma_exec::run(&spec); - let killer = handle.killer(); - ActiveRun { - handle: BackendHandle::Local(handle), - killer: Some(killer), - command: line, - tui, - block: run_block, - } - } - }; - s.running = Some(Arc::new(Mutex::new(active))); - s -} - -/// Cierra el nodo `%cN` registrado por `start_run` como fallido cuando -/// el spawn no llega a colocar el `RunHandle` (errores de socket/identity/ -/// pub-hex/tcp). Sin esto el lienzo mostraría el comando como "running" -/// para siempre. Limpiá también el contador de bytes. -pub(crate) fn fail_pending_intent(s: &mut State) { - if let Some(id) = s.current_run_node.take() { - s.intent_graph.complete(id, false, 0); - } - s.current_run_bytes = 0; -} - -/// Carga el `Keypair` del shell desde el archivo de identidad, -/// creando uno nuevo si no existe. Usa el path por defecto de -/// `shuma-link::Keypair::default_path()` (`~/.config/shuma/keys/identity`). -pub(crate) fn load_or_create_identity() -> Result { - let path = shuma_link::Keypair::default_path() - .ok_or_else(|| "no se pudo derivar el path de identidad".to_string())?; - shuma_link::Keypair::load_or_generate(&path).map_err(|e| e.to_string()) -} - -pub(crate) fn parse_pub_hex(hex_str: &str) -> Result { - shuma_link::PublicKey::from_hex(hex_str).map_err(|e| e.to_string()) -} - -/// Si `line` es un pipe «simple» de ≥2 etapas —sólo `Command`/`Argument`/ -/// `Flag`/`Pipe`/espacio, sin comillas, variables, redirecciones, -/// operadores, globs (`* ? [ ] { }`) ni `~`— devuelve sus etapas como -/// [`StageSpec`] para correrlo por `Exec::Direct`. Si no, `None` (cae a -/// `sh -c`, que sí absorbe esa sintaxis). Un único comando también cae a -/// `sh -c`: el modo directo sólo aporta cuando hay tubería que interceptar. -/// -/// Conservador a propósito: `shuma_line::Stage` no recoge los `StringLit` -/// en `args`, así que un pipe con comillas debe ir al shell o perdería el -/// argumento citado. -pub(crate) fn simple_pipe_stages(line: &str) -> Option> { - use shuma_line::TokenKind::*; - let tokens = shuma_line::tokenize(line, shuma_line::Dialect::Bash); - let simple = !tokens.is_empty() - && tokens.iter().all(|t| { - matches!(t.kind, Command | Argument | Flag | Pipe | Whitespace) - && !t.text.contains(['*', '?', '[', ']', '{', '}']) - && !t.text.starts_with('~') - }); - if !simple { - return None; - } - let pipeline = shuma_line::split_pipeline(&tokens); - if pipeline.stages.len() < 2 { - return None; - } - let mut stages = Vec::with_capacity(pipeline.stages.len()); - for st in &pipeline.stages { - // Una etapa sin comando (línea incompleta, p. ej. termina en `|`) - // → al shell, que reporta el error de sintaxis como toca. - let program = st.command.clone()?; - stages.push(StageSpec { - program, - args: st.args.clone(), - }); - } - Some(stages) -} - -/// Decide cómo lanzar `line`: si el primer token está en la allowlist -/// TUI (o el usuario lo prefijó con `:tui`), abre un PTY; si es un pipe -/// simple, lo corre directo con captura por etapa; si no, va por el shell -/// normal (streaming Stdout/Stderr). -pub(crate) fn build_spec(line: &str, cwd: &str) -> (CommandSpec, Option) { - // Prefijo explícito `:tui `. - let (cmd_line, force_tui) = match line.strip_prefix(":tui ") { - Some(rest) => (rest.trim(), true), - None => (line, false), - }; - let first_word = cmd_line.split_whitespace().next().unwrap_or(""); - let is_tui = force_tui || TUI_ALLOWLIST.contains(&first_word); - if !is_tui { - // Pipe «simple» (sólo comandos/args/flags y `|`, sin comillas, - // variables, redirecciones, globs ni `~`): lo corremos directo - // —conectando los procesos nosotros— y activamos la captura por - // etapa (tee) para inspeccionar los intermedios en vivo. Cualquier - // sintaxis que el modo directo no absorbe cae a `sh -c`. - if let Some(stages) = simple_pipe_stages(line) { - return ( - CommandSpec { - exec: Exec::Direct { stages }, - cwd: cwd.to_string(), - capture_limit: 0, - spill_path: None, - stdin_data: None, - capture_stages: true, - }, - None, - ); - } - return (CommandSpec::shell(line, cwd), None); - } - // Bajo PTY: parseamos en stages básicos por whitespace. No soporta - // pipes ni redirecciones — un TUI fullscreen no los usa. - let parts: Vec = cmd_line.split_whitespace().map(String::from).collect(); - if parts.is_empty() { - return (CommandSpec::shell(line, cwd), None); - } - let program = parts[0].clone(); - let args = parts[1..].to_vec(); - let spec = CommandSpec { - exec: Exec::Pty { - program, - args, - cols: PTY_COLS, - rows: PTY_ROWS, - }, - cwd: cwd.to_string(), - capture_limit: 0, - spill_path: None, - stdin_data: None, - capture_stages: false, - }; - // Stage marker — usamos `parts` para sintaxis, no para ejecutar; el - // Exec::Pty arma el spawn directo. La conversión a `StageSpec` - // queda como guía visual del tooltip si después la queremos - // exponer (hoy `Exec::Pty` no usa stages). - let _ = StageSpec { - program: parts[0].clone(), - args: parts[1..].to_vec(), - }; - // `program` ya se movió al `Exec::Pty`; usamos `parts[0]` (sigue vivo). - (spec, Some(TuiSession::new(&parts[0], PTY_ROWS, PTY_COLS))) -} - -pub(crate) fn drain_run(mut s: State) -> State { - let Some(active_arc) = s.running.clone() else { - return s; - }; - let mut finished_with: Option = None; - // Bloque de ESTE run — toda su salida va a su card, aunque el usuario - // haya tipeado otros comandos (que movieron `current_block`) mientras - // corría. - let run_block; - { - let mut guard = match active_arc.lock() { - Ok(g) => g, - Err(p) => p.into_inner(), - }; - run_block = guard.block; - // Resize del PTY si el rect del panel cambió desde el último - // tick. Cell size aproximado: 7.5 px ancho × 16 px alto (12 pt - // monoespacio en Llimphi default). Si el panel se redimensiona - // el TUI hace SIGWINCH al child. - let want_resize: Option<(u16, u16)> = if let Some(tui) = guard.tui.as_ref() { - let (w, h) = match s.last_tui_rect.lock() { - Ok(g) => *g, - Err(p) => *p.into_inner(), - }; - if w > 1.0 && h > 1.0 { - let cols = ((w / 7.5).floor() as i32).clamp(20, 400) as u16; - let rows = ((h / 16.0).floor() as i32).clamp(5, 200) as u16; - if rows != tui.rows || cols != tui.cols { - Some((rows, cols)) - } else { - None - } - } else { - None - } - } else { - None - }; - if let Some((rows, cols)) = want_resize { - guard.handle.resize(rows, cols); - if let Some(tui) = guard.tui.as_mut() { - tui.set_size(rows, cols); - } - } - let events = guard.handle.try_events(); - for ev in events { - match ev { - RunEvent::Stdout(line) => { - // +1 por el `\n` implícito de cada línea drenada. - s.current_run_bytes = s.current_run_bytes.saturating_add(line.len() as u64 + 1); - s.push_in_block(run_block, OutputLine::stdout(line)); - } - RunEvent::StageStdout { stage, line } => { - // Salida de una etapa intermedia (tee). NO suma a - // `current_run_bytes` (el grafo cuenta la salida final); - // queda guardada para el desplegable de su etapa. - s.push_in_block(run_block, OutputLine::stage_stdout(stage, line)); - } - RunEvent::Stderr(line) => { - s.current_run_bytes = s.current_run_bytes.saturating_add(line.len() as u64 + 1); - s.push_in_block(run_block, OutputLine::stderr(line)); - } - RunEvent::Truncated => s.push_in_block( - run_block, - OutputLine::notice("… (salida truncada por límite de captura)"), - ), - RunEvent::Spilled(path) => s.push_in_block( - run_block, - OutputLine::notice(format!("… (resto volcado a {path})")), - ), - RunEvent::Bytes(bytes) => { - s.current_run_bytes = s.current_run_bytes.saturating_add(bytes.len() as u64); - if let Some(tui) = guard.tui.as_mut() { - tui.parser.process(&bytes); - } - } - ev @ (RunEvent::Exited(_) | RunEvent::Failed(_)) => { - finished_with = Some(ev); - } - } - } - } - if let Some(ev) = finished_with { - let ok = matches!(ev, RunEvent::Exited(0)); - let notice = match ev { - RunEvent::Exited(0) => "✔ exit 0".to_string(), - RunEvent::Exited(code) => format!("✘ exit {code}"), - RunEvent::Failed(e) => format!("✘ no se pudo spawnear: {e}"), - _ => unreachable!(), - }; - s.push_in_block(run_block, OutputLine::notice(notice)); - // Cerrá el nodo del grafo de intenciones — el lienzo lo refleja - // como verde/rojo en el próximo render. - if let Some(id) = s.current_run_node.take() { - s.intent_graph.complete(id, ok, s.current_run_bytes); - } - s.current_run_bytes = 0; - s.running = None; - // Si quedó algo en cola, arrancarlo ya — sin esperar otro Tick. - if let Some(next) = s.queue.pop_front() { - s = start_run(s, next); - } - } - // Drenado de jobs background — cada uno aporta sus líneas - // prefijadas por `[N]`. Los terminados se eliminan del Vec. - s = drain_bg_jobs(s); - s -} - -/// Drena los `bg_jobs` y los limpia. Las líneas se prefijan `[N]` -/// para distinguir su origen. -pub(crate) fn drain_bg_jobs(mut s: State) -> State { - let mut next_jobs: Vec>> = Vec::with_capacity(s.bg_jobs.len()); - // Snapshot de los Arc: `push_output` toma `&mut s`, incompatible con - // retener el borrow de `s.bg_jobs` durante el loop. - let jobs = s.bg_jobs.clone(); - for arc in jobs.iter() { - let mut keep = true; - let mut finished: Option = None; - // Bloque propio del job — su salida vive en SU card, nunca en la - // del foreground (era el bug del "output mezclado"). - let job_block; - { - let mut guard = match arc.lock() { - Ok(g) => g, - Err(p) => p.into_inner(), - }; - job_block = guard.block; - for ev in guard.handle.try_events() { - match ev { - RunEvent::Stdout(line) => s.push_in_block(job_block, OutputLine::stdout(line)), - RunEvent::StageStdout { stage, line } => { - s.push_in_block(job_block, OutputLine::stage_stdout(stage, line)) - } - RunEvent::Stderr(line) => s.push_in_block(job_block, OutputLine::stderr(line)), - RunEvent::Truncated => { - s.push_in_block(job_block, OutputLine::notice("… (truncada)")) - } - RunEvent::Spilled(path) => s.push_in_block( - job_block, - OutputLine::notice(format!("… (volcado a {path})")), - ), - RunEvent::Bytes(_) => { - // Background sin PTY — no debería emitir Bytes. - } - ev @ (RunEvent::Exited(_) | RunEvent::Failed(_)) => { - finished = Some(ev); - } - } - } - } - if let Some(ev) = finished { - let notice = match ev { - RunEvent::Exited(0) => "✔ exit 0".to_string(), - RunEvent::Exited(code) => format!("✘ exit {code}"), - RunEvent::Failed(e) => format!("✘ failed: {e}"), - _ => unreachable!(), - }; - s.push_in_block(job_block, OutputLine::notice(notice)); - keep = false; - } - if keep { - next_jobs.push(arc.clone()); - } - } - s.bg_jobs = next_jobs; - s -} - -pub(crate) fn cancel_running(mut s: State) -> State { - let mut run_block = s.current_block; - if let Some(arc) = s.running.as_ref() { - let guard = match arc.lock() { - Ok(g) => g, - Err(p) => p.into_inner(), - }; - run_block = guard.block; - // Local: SIGKILL al grupo entero — Ctrl-C debe doler en una UI. - // Remoto: cerrar el stream — el daemon detecta EOF y mata al - // hijo. La forma del notice no cambia. - if let Some(killer) = guard.killer.as_ref() { - killer.kill(); - } else { - guard.handle.kill(); - } - // El próximo Tick observará `RunEvent::Exited` y limpiará el handle. - } - s.push_in_block(run_block, OutputLine::notice("⏹ cancel (SIGKILL enviado)")); - s -} - -pub(crate) fn apply_cd(mut s: State, rest: &str) -> State { - let target = if rest.trim().is_empty() { - // `cd` sin args → HOME (convención bash/zsh). - match std::env::var("HOME") { - Ok(h) => PathBuf::from(h), - Err(_) => { - s.push_output(OutputLine::notice("cd: HOME no está definido")); - return s; - } - } - } else { - let trimmed = rest.trim(); - let p = PathBuf::from(trimmed); - if p.is_absolute() { - p - } else { - s.cwd.join(p) - } - }; - match std::fs::canonicalize(&target) { - Ok(canonical) => { - if canonical.is_dir() { - s.cwd = canonical; - } else { - s.push_output(OutputLine::notice(format!( - "cd: no es un directorio: {}", - target.display() - ))); - } - } - Err(e) => { - s.push_output(OutputLine::notice(format!("cd: {}: {e}", target.display()))); - } - } - s -} - -pub(crate) fn split_first_word(line: &str) -> Option<(&str, &str)> { - let line = line.trim_start(); - if line.is_empty() { - return None; - } - match line.find(char::is_whitespace) { - Some(i) => Some((&line[..i], &line[i + 1..])), - None => Some((line, "")), - } -} - -pub(crate) fn push_line(buf: &mut Vec, line: OutputLine) { - buf.push(line); - let len = buf.len(); - if len > MAX_OUTPUT_LINES { - buf.drain(0..len - MAX_OUTPUT_LINES); - } -} diff --git a/02_ruway/shuma/sandbox/shuma-module-shell/src/update/body_editor.rs b/02_ruway/shuma/sandbox/shuma-module-shell/src/update/body_editor.rs new file mode 100644 index 0000000..2115a6a --- /dev/null +++ b/02_ruway/shuma/sandbox/shuma-module-shell/src/update/body_editor.rs @@ -0,0 +1,55 @@ +use super::*; + +/// Copia el bloque entero al clipboard: el comando (`$ …`) seguido de su +/// salida completa (stdout y stderr en orden). Es el "copiar comando + salida" +/// estilo terminal moderna; lo dispara el botón ⧉ del header del bloque en la +/// superficie de terminal. No-op si el bloque no tiene ni comando ni cuerpo. +/// +/// (Lo que queda de la vieja maquinaria del cuerpo IDE per-comando: el resto +/// —selección por-card, doble-click, menú legacy— se borró en la Fase 5 del +/// SDD-TERMINAL cuando la superficie virtualizada pasó a ser la única vía. La +/// selección/copia/menú del stream ahora viven en `update/surface.rs`.) +pub(crate) fn copy_command_block(s: &State, block: u64) { + let mut partes: Vec = Vec::new(); + if let Some(cmd) = s.block_command.get(&block) { + // `block_command` guarda el texto ya con el prefijo "$ ". + partes.push(cmd.clone()); + } + partes.extend(body_lines_for_block(s, block)); + if partes.is_empty() { + return; + } + set_clipboard(&partes.join("\n")); +} + +/// Rango `[start, end)` (en columnas/chars) de la palabra en `line_text` +/// que contiene la columna `col` — alfanumérico + `_`, igual que el +/// text-editor. Si `col` cae sobre un no-word-char, devuelve un rango +/// vacío en `col` (no selecciona). Lo usa el doble-click de la superficie +/// de terminal (`update/surface.rs`) para seleccionar la palabra. +pub(crate) fn word_range_at(line_text: &str, col: usize) -> (usize, usize) { + let chars: Vec = line_text.chars().collect(); + let is_word = |c: char| c.is_alphanumeric() || c == '_'; + if col >= chars.len() || !is_word(chars[col]) { + // Permití también el caso "el cursor quedó justo después de la + // última letra de la palabra" (col == len o sobre separador): mira + // el char anterior. + if col > 0 && col <= chars.len() && is_word(chars[col - 1]) { + let mut start = col; + while start > 0 && is_word(chars[start - 1]) { + start -= 1; + } + return (start, col); + } + return (col, col); + } + let mut start = col; + while start > 0 && is_word(chars[start - 1]) { + start -= 1; + } + let mut end = col; + while end < chars.len() && is_word(chars[end]) { + end += 1; + } + (start, end) +} diff --git a/02_ruway/shuma/sandbox/shuma-module-shell/src/update/builtins.rs b/02_ruway/shuma/sandbox/shuma-module-shell/src/update/builtins.rs new file mode 100644 index 0000000..f8eb249 --- /dev/null +++ b/02_ruway/shuma/sandbox/shuma-module-shell/src/update/builtins.rs @@ -0,0 +1,2445 @@ +use super::*; + +#[derive(Debug, Clone, Copy)] +pub(crate) enum JobSignal { + /// SIGTERM — pedido cortés de terminar (`:term N`). + Term, + /// SIGKILL — fin inmediato e incondicional (`:kill N`). + Kill, + Stop, + Cont, +} + +/// Lista los bg_jobs con su índice y comando. Marca finalizados. +pub(crate) fn apply_jobs_list(mut s: State) -> State { + if s.bg_jobs.is_empty() { + s.push_output(OutputLine::notice("(sin jobs en background)")); + return s; + } + // Snapshot de los Arc para no retener el borrow de `s.bg_jobs` + // mientras `push_output` toma `&mut s`. + let jobs = s.bg_jobs.clone(); + for (i, arc) in jobs.iter().enumerate() { + let (cmd, status) = match arc.lock() { + Ok(g) => ( + g.command.clone(), + if g.handle.is_finished() { + "done" + } else { + "running" + }, + ), + Err(p) => { + let g = p.into_inner(); + ( + g.command.clone(), + if g.handle.is_finished() { + "done" + } else { + "running" + }, + ) + } + }; + s.push_output(OutputLine::notice(format!("[{i}] {status} {cmd}"))); + } + s +} + +/// Aplica `:term N` / `:kill N` / `:stop N` / `:cont N` al job de índice `N`. +/// Stop/Cont son no-op en jobs sin `Killer` (remotos vía daemon); Term/Kill +/// caen a cerrar el stream del daemon. +pub(crate) fn apply_jobs_signal(mut s: State, rest: &str, sig: JobSignal) -> State { + let idx: usize = match rest.trim().parse() { + Ok(n) => n, + Err(_) => { + s.push_output(OutputLine::notice("uso: :term N | :kill N | :stop N | :cont N")); + return s; + } + }; + let Some(arc) = s.bg_jobs.get(idx).cloned() else { + s.push_output(OutputLine::notice(format!("no hay job [{idx}]"))); + return s; + }; + let guard = match arc.lock() { + Ok(g) => g, + Err(p) => p.into_inner(), + }; + let acted = match sig { + JobSignal::Term => match guard.killer.as_ref() { + Some(k) => { + k.term(); + true + } + None => { + // Remoto: cancel via stream close. + guard.handle.kill(); + true + } + }, + JobSignal::Kill => match guard.killer.as_ref() { + Some(k) => { + k.kill(); + true + } + None => { + // Remoto: el daemon no expone SIGKILL fino; cerrar el stream + // es lo más fuerte que tenemos. + guard.handle.kill(); + true + } + }, + JobSignal::Stop => guard.killer.as_ref().map(|k| k.stop()).unwrap_or(false), + JobSignal::Cont => guard.killer.as_ref().map(|k| k.cont()).unwrap_or(false), + }; + let label = match sig { + JobSignal::Term => "TERM", + JobSignal::Kill => "KILL", + JobSignal::Stop => "STOP", + JobSignal::Cont => "CONT", + }; + drop(guard); + s.push_output(OutputLine::notice(if acted { + format!("[{idx}] SIG{label} enviado") + } else { + format!("[{idx}] no se pudo enviar SIG{label}") + })); + s +} + +/// `:env` — variables de ambiente **aprendibles**, organizadas en grupos +/// (el panel «Environment» del sidebar muestra y activa/desactiva los +/// grupos; este builtin es la vía de teclado). +/// +/// - `:env` lista los grupos con sus variables. +/// - `:env NAME=VALOR [@grupo]` exporta al proceso Y la aprende al grupo +/// (default «general») en `env.json` — sobrevive reinicios. +/// - `:env -NAME` la olvida (proceso + todos los grupos). +pub(crate) fn apply_env(mut s: State, rest: &str) -> State { + let arg = rest.trim(); + if arg.is_empty() { + let groups = shuma_config::load_env_groups(); + if groups.iter().all(|g| g.vars.is_empty()) { + s.push_output(OutputLine::notice( + "(sin variables — `:env NAME=valor` exporta y aprende al grupo «general»)", + )); + } else { + for g in &groups { + s.push_output(OutputLine::notice(format!( + "[{}] {} — {} variable{}", + if g.active { "on " } else { "off" }, + g.name, + g.vars.len(), + if g.vars.len() == 1 { "" } else { "s" }, + ))); + for (k, v) in &g.vars { + s.push_output(OutputLine::notice(format!(" {k}={v}"))); + } + } + } + return s; + } + // `:env sync [--show]` — absorbe el entorno de una terminal normal (login + // shell) al proceso: hace aparecer binarios del `PATH` de tu `.zshrc`/ + // `.bashrc` (p. ej. `claude`) y demás vars que shuma no heredó por + // lanzarse fuera de una shell de login. Corre automático al arrancar; esto + // es para re-sincronizar a mano. + if arg == "sync" || arg.starts_with("sync ") { + let show = arg["sync".len()..].trim() == "--show"; + let report = shuma_config::login_env::sync_into_process(); + let shell = report.shell.clone().unwrap_or_else(|| "shell".into()); + if let Some(e) = &report.failed { + s.push_output(OutputLine::notice(format!("✘ :env sync — {e}"))); + return s; + } + if report.applied.is_empty() { + s.push_output(OutputLine::notice(format!( + "entorno ya al día con {shell} — nada nuevo ({} vars vistas)", + report.captured + ))); + return s; + } + s.push_output(OutputLine::notice(format!( + "✔ {} variable{} absorbida{} de {shell}{}", + report.applied.len(), + if report.applied.len() == 1 { "" } else { "s" }, + if report.applied.len() == 1 { "" } else { "s" }, + if report.path_changed { " (PATH incluido — binarios nuevos disponibles)" } else { "" }, + ))); + if show { + for (k, v) in &report.applied { + s.push_output(OutputLine::notice(format!(" {k}={v}"))); + } + } + if report.path_changed { + // PATH cambió → el cache de comandos del completado quedó viejo. + s.completion_source = crate::completion_source_for(&s.source, &s.cwd); + } + return s; + } + + // `:env -NAME` — olvidar de todos los grupos. + if let Some(name) = arg.strip_prefix('-') { + let name = name.trim(); + if !es_nombre_env(name) { + s.push_output(OutputLine::notice("uso: :env [-NAME | NAME=valor [@grupo]]")); + return s; + } + std::env::remove_var(name); + let mut groups = shuma_config::load_env_groups(); + let mut hits = 0; + for g in &mut groups { + if g.remove(name) { + hits += 1; + } + } + if hits > 0 { + let _ = shuma_config::save_env_groups(&groups); + s.push_output(OutputLine::notice(format!( + "✔ {name} olvidada ({hits} grupo{})", + if hits == 1 { "" } else { "s" } + ))); + } else { + s.push_output(OutputLine::notice(format!( + "{name} no estaba en ningún grupo — igual se removió del proceso" + ))); + } + return s; + } + // `:env NAME=VALOR [@grupo]` — exportar + aprender. + let (asign, grupo) = match arg.rsplit_once('@') { + Some((a, g)) if !g.trim().is_empty() && !g.contains('=') => { + (a.trim(), g.trim().to_string()) + } + _ => (arg, "general".to_string()), + }; + let Some((name, value)) = asign.split_once('=') else { + s.push_output(OutputLine::notice("uso: :env (listar grupos)")); + s.push_output(OutputLine::notice(" :env NAME=valor [@grupo] (exportar + aprender)")); + s.push_output(OutputLine::notice(" :env -NAME (olvidar)")); + s.push_output(OutputLine::notice(" :env sync [--show] (absorber el entorno de tu terminal)")); + return s; + }; + let (name, value) = (name.trim(), value.trim()); + if !es_nombre_env(name) { + s.push_output(OutputLine::notice(format!( + "✘ `{name}` no es un nombre de variable válido ([A-Za-z_][A-Za-z0-9_]*)" + ))); + return s; + } + // Valor con expansión de `$VAR` contra el ambiente vigente — permite + // `:env PATH=$PATH:/opt/bin`. + let value = shuma_config::expand_env(value); + let mut groups = shuma_config::load_env_groups(); + let g = match groups.iter_mut().find(|g| g.name == grupo) { + Some(g) => g, + None => { + groups.push(shuma_config::EnvGroup::new(grupo.clone())); + groups.last_mut().expect("recién pusheado") + } + }; + g.upsert(name, &value); + let activo = g.active; + if activo { + std::env::set_var(name, &value); + } + match shuma_config::save_env_groups(&groups) { + Ok(()) => s.push_output(OutputLine::notice(format!( + "✔ {name}={value} aprendida al grupo «{grupo}»{}", + if activo { " y exportada" } else { " (grupo inactivo — no exporta)" } + ))), + Err(e) => s.push_output(OutputLine::notice(format!( + "{name}={value} exportada — pero no se pudo guardar env.json: {e}" + ))), + } + s +} + +fn es_nombre_env(name: &str) -> bool { + !name.is_empty() + && name + .chars() + .enumerate() + .all(|(i, c)| c == '_' || c.is_ascii_alphabetic() || (i > 0 && c.is_ascii_digit())) +} + +/// `true` si la línea es **sólo** asignaciones `NAME=VALOR` (una o más, +/// separadas por espacios) sin comando que las siga — el caso `PATH=$PATH:/x` +/// o `FOO=bar BAZ=qux` que el usuario espera que **persista en la sesión** +/// (a diferencia de `FOO=bar cmd`, que es env de un solo comando y debe ir a +/// bash). Un token que no tenga forma `nombre=...` corta: hay un comando. +pub(crate) fn es_asignacion_pura(line: &str) -> bool { + let mut vio_alguna = false; + for tok in line.split_whitespace() { + match tok.split_once('=') { + Some((name, _)) if es_nombre_env(name) => vio_alguna = true, + _ => return false, + } + } + vio_alguna +} + +/// Aplica una asignación `NAME=VALOR` al ambiente del proceso (los hijos la +/// heredan). Expande `$VAR` contra el ambiente vigente. Devuelve el nombre si +/// se aplicó, y si tocó `PATH` (para refrescar el cache de comandos). +fn set_session_var(asign: &str) -> Option<(String, bool)> { + let (name, value) = asign.split_once('=')?; + let name = name.trim(); + if !es_nombre_env(name) { + return None; + } + // Quita comillas envolventes simples/dobles antes de expandir. + let raw = value.trim(); + let raw = raw + .strip_prefix('"') + .and_then(|v| v.strip_suffix('"')) + .or_else(|| raw.strip_prefix('\'').and_then(|v| v.strip_suffix('\''))) + .unwrap_or(raw); + let value = shuma_config::expand_env(raw); + std::env::set_var(name, &value); + Some((name.to_string(), name == "PATH")) +} + +/// `export NAME=VALOR [NAME2=VALOR2 …]` — variables de ambiente de **sesión**. +/// A diferencia de `:env` (que aprende al `env.json` y sobrevive reinicios), +/// `export` es efímero como en un shell real: vale para los comandos de esta +/// sesión y se pierde al cerrar. `$VAR` se expande. Si toca `PATH`, se +/// refresca el escaneo de binarios para el autocompletado. +/// +/// También es el camino de las **asignaciones puras** (`PATH=$PATH:/opt/bin`): +/// se rutean aquí en vez de a bash (donde no persistirían entre comandos). +pub(crate) fn apply_export(mut s: State, rest: &str) -> State { + let arg = rest.trim(); + if arg.is_empty() { + s.push_output(OutputLine::notice( + "export NAME=valor — variable de sesión (efímera). Para que sobreviva reinicios: :env", + )); + return s; + } + let mut aplicadas: Vec = Vec::new(); + let mut path_cambio = false; + for tok in arg.split_whitespace() { + if !tok.contains('=') { + // `export NAME` (sin `=`): en nuestro modelo el ambiente ya se + // hereda, así que es informativo. Avisamos si no existe. + if std::env::var_os(tok).is_none() { + s.push_output(OutputLine::notice(format!("export: {tok} no está definida"))); + } + continue; + } + match set_session_var(tok) { + Some((name, es_path)) => { + path_cambio |= es_path; + aplicadas.push(name); + } + None => { + s.push_output(OutputLine::notice(format!( + "export: `{tok}` no es una asignación válida (NAME=valor)" + ))); + } + } + } + if path_cambio { + // PATH cambió → el cache de comandos del completado quedó viejo; + // reconstruimos la fuente para que los binarios nuevos aparezcan. + s.completion_source = crate::completion_source_for(&s.source, &s.cwd); + } + for name in &aplicadas { + let val = std::env::var(name).unwrap_or_default(); + s.push_output(OutputLine::notice(format!("✔ export {name}={val}"))); + } + s +} + +/// `unset NAME [NAME2 …]` — quita variables de ambiente de la sesión. +pub(crate) fn apply_unset(mut s: State, rest: &str) -> State { + let arg = rest.trim(); + if arg.is_empty() { + s.push_output(OutputLine::notice("uso: unset NAME [NAME2 …]")); + return s; + } + let mut path_cambio = false; + for name in arg.split_whitespace() { + if !es_nombre_env(name) { + s.push_output(OutputLine::notice(format!("unset: `{name}` no es un nombre válido"))); + continue; + } + std::env::remove_var(name); + path_cambio |= name == "PATH"; + s.push_output(OutputLine::notice(format!("✔ unset {name}"))); + } + if path_cambio { + s.completion_source = crate::completion_source_for(&s.source, &s.cwd); + } + s +} + +/// `:persist` — asegura que la sesión persista lo máximo posible hoy. +/// +/// - `:persist` muestra el estado de cada capa de persistencia. +/// - `:persist on` enciende captura con spill (límite default 64 MB si no +/// había) y lo aprende al rc (`[capture]` + `[scrollback]`). +/// - `:persist off` apaga el spill (el resto de las capas no se tocan). +pub(crate) fn apply_persist(mut s: State, rest: &str) -> State { + let arg = rest.trim(); + match arg { + "" => { + let limit_mb = s.capture_limit_bytes / (1024 * 1024); + let spill_estado = match (s.spill, s.capture_limit_bytes) { + (true, 0) => "spill on, pero sin :limit (sin efecto)".to_string(), + (true, _) => format!("spill on, límite {limit_mb} MB"), + (false, _) => "off — `:persist on` lo enciende".to_string(), + }; + let scrollback = match s.surf_history.lock() { + Ok(h) if h.spill_path().is_some() => "✔ spillea a disco".to_string(), + _ => "sólo en memoria ([scrollback].spill = true para disco)".to_string(), + }; + let daemon = daemon_socket_path(); + let daemon_estado = match &daemon { + Some(p) if p.exists() => format!("✔ corriendo ({})", p.display()), + Some(p) => format!("no corre (socket esperado: {})", p.display()), + None => "sin XDG_RUNTIME_DIR".to_string(), + }; + s.push_output(OutputLine::notice("persistencia de la sesión:")); + s.push_output(OutputLine::notice(" ✔ historial de comandos — durable siempre")); + s.push_output(OutputLine::notice( + " ✔ sesiones del chasis — sessions.json (se rearman al abrir)", + )); + s.push_output(OutputLine::notice(format!(" · captura por comando — {spill_estado}"))); + s.push_output(OutputLine::notice(format!(" · scrollback — {scrollback}"))); + s.push_output(OutputLine::notice(format!(" · shuma-daemon — {daemon_estado}"))); + s.push_output(OutputLine::notice( + " · output de la sesión — flag «Persistir sesión» en el panel izquierdo \ + (guarda y restaura el historial visible al reabrir)", + )); + s.push_output(OutputLine::notice( + " · comandos en primer plano mueren con la app — `:spawn ` lo corre \ + en el daemon y SOBREVIVE a cerrar shuma (`:sessions`/`:attach` para reconectar)", + )); + } + "on" => { + s.spill = true; + if s.capture_limit_bytes == 0 { + s.capture_limit_bytes = 64 * 1024 * 1024; + } + let mb = s.capture_limit_bytes / (1024 * 1024); + if let Some(rc) = shuma_config::Config::default_path() { + let _ = shuma_config::upsert_key(&rc, "capture", "limit_mb", &mb.to_string()); + let _ = shuma_config::upsert_key(&rc, "capture", "spill", "true"); + let _ = shuma_config::upsert_key(&rc, "scrollback", "spill", "true"); + } + s.push_output(OutputLine::notice(format!( + "✔ persistencia on: captura {mb} MB + spill a disco, aprendido al shumarc \ + (el scrollback spillea desde la próxima sesión)" + ))); + } + "off" => { + s.spill = false; + if let Some(rc) = shuma_config::Config::default_path() { + let _ = shuma_config::upsert_key(&rc, "capture", "spill", "false"); + let _ = shuma_config::upsert_key(&rc, "scrollback", "spill", "false"); + } + s.push_output(OutputLine::notice("persistencia off (spill apagado)")); + } + _ => s.push_output(OutputLine::notice("uso: :persist [on|off]")), + } + s +} + +/// Socket admin esperado del shuma-daemon local. +fn daemon_socket_path() -> Option { + std::env::var_os("XDG_RUNTIME_DIR").map(|d| std::path::PathBuf::from(d).join("shuma.sock")) +} + +/// `:limit ` — tope de captura de stdout por run. `0` = sin tope. +pub(crate) fn apply_capture_limit(mut s: State, rest: &str) -> State { + match rest.trim().parse::() { + Ok(mb) => { + s.capture_limit_bytes = mb.saturating_mul(1024 * 1024); + let msg = if mb == 0 { + "captura sin tope".to_string() + } else { + format!("captura limitada a {mb} MB por comando") + }; + s.push_output(OutputLine::notice(msg)); + } + Err(_) => s.push_output(OutputLine::notice("uso: :limit (0 = sin tope)")), + } + s +} + +/// `:spill on|off` — volcar a disco la salida que excede el `:limit`. +pub(crate) fn apply_spill(mut s: State, rest: &str) -> State { + let arg = rest.trim(); + let on = matches!(arg, "on" | "si" | "sí" | "1" | "true"); + let off = matches!(arg, "off" | "no" | "0" | "false"); + if !on && !off { + s.push_output(OutputLine::notice("uso: :spill on|off")); + return s; + } + s.spill = on; + let note = match (on, s.capture_limit_bytes) { + (true, 0) => "spill activado — pero sin `:limit ` no tiene efecto", + (true, _) => "spill activado — la salida excedente se vuelca a disco", + (false, _) => "spill desactivado", + }; + s.push_output(OutputLine::notice(note)); + s +} + +/// `:scrollback` (sin args): muestra el estado del scrollback persistente — +/// líneas en memoria + spilleadas + path del spill file. `:scrollback open` +/// abre el spill file con `$EDITOR` (o cae a `xdg-open`) para que el +/// usuario inspeccione el archive sin salir del shell. +pub(crate) fn apply_scrollback(mut s: State, rest: &str) -> State { + let arg = rest.trim(); + let snap = match s.surf_history.lock() { + Ok(g) => (g.len(), g.spilled_count(), g.spill_path()), + Err(p) => { + let g = p.into_inner(); + (g.len(), g.spilled_count(), g.spill_path()) + } + }; + let (in_mem, in_spill, spill_path) = snap; + match arg { + "open" => { + let Some(path) = spill_path else { + s.push_output(OutputLine::notice( + ":scrollback open — el spill no está activo (ver [scrollback].spill = true en shumarc.toml)", + )); + return s; + }; + let path_s = path.display().to_string(); + // Preferimos $EDITOR para inspección textual; si no, xdg-open. + if let Ok(editor) = std::env::var("EDITOR") { + spawn_detached(&editor, &[path_s.as_str()]); + } else { + spawn_detached("xdg-open", &[path_s.as_str()]); + } + s.push_output(OutputLine::notice(format!("abriendo {path_s}…"))); + } + "" => { + // Estado: líneas en memoria + en spill + path. + s.push_output(OutputLine::notice(format!( + "scrollback: {in_mem} líneas en memoria, {in_spill} archivadas" + ))); + if let Some(p) = spill_path { + s.push_output(OutputLine::notice(format!( + "spill: {} ({:?})", + p.display(), + p.metadata().ok().map(|m| m.len()).unwrap_or(0) + ))); + s.push_output(OutputLine::notice( + "abrí con `:scrollback open` o `cat`-éalo desde otra shell", + )); + } else { + s.push_output(OutputLine::notice( + "spill no activo — activa con [scrollback].spill = true en shumarc.toml", + )); + } + } + a if a.starts_with("grep ") => { + let pattern = a[5..].trim(); + if pattern.is_empty() { + s.push_output(OutputLine::notice("uso: :scrollback grep ")); + return s; + } + s = apply_scrollback_grep(s, pattern); + } + _ => { + s.push_output(OutputLine::notice("uso: :scrollback [open | grep ]")); + } + } + s +} + +/// Busca un substring literal en TODO el archive del scrollback — tanto +/// las líneas en memoria como las del spill file. Útil cuando el usuario +/// sabe que algo apareció hace mucho y ya está fuera del cache visible. +/// Reporta los hits como notices con su `global_id` 1-based. +/// Case-sensitive (literal); el usuario usa el `:scrollback open` + el +/// search de `$EDITOR` para casos más complejos. +fn apply_scrollback_grep(mut s: State, pattern: &str) -> State { + let hist = match s.surf_history.lock() { + Ok(g) => g.clone(), + Err(p) => p.into_inner().clone(), + }; + let mut hits: Vec<(u64, String)> = Vec::new(); + let total_spilled = hist.spilled_count(); + // Spilled: leer una por una. Lentos en archives enormes; el caller + // que necesite más velocidad usa el editor con su grep. + for id in 0..total_spilled as u64 { + if let Ok(Some(text)) = hist.read_spilled(id) { + if text.contains(pattern) { + hits.push((id, text)); + } + } + // Cap defensivo: nunca más de 1000 hits para no saturar el output. + if hits.len() >= 1000 { + break; + } + } + // In-memory: las líneas vigentes (índices 0..len → global ids + // dropped+0..dropped+len). + let in_mem = hist.len(); + let dropped = hist.dropped(); + for i in 0..in_mem { + if hits.len() >= 1000 { + break; + } + if let Some(text) = hist.line(i) { + if text.contains(pattern) { + hits.push((dropped + i as u64, text.to_string())); + } + } + } + if hits.is_empty() { + s.push_output(OutputLine::notice(format!( + "grep: sin hits para `{pattern}` ({} líneas revisadas)", + total_spilled + in_mem + ))); + return s; + } + s.push_output(OutputLine::notice(format!( + "grep: {} hit{} para `{pattern}`", + hits.len(), + if hits.len() == 1 { "" } else { "s" } + ))); + for (id, text) in hits.iter().take(50) { + s.push_output(OutputLine::notice(format!(" [{}] {}", id + 1, text))); + } + if hits.len() > 50 { + s.push_output(OutputLine::notice(format!( + " … y {} más (cap del builtin a 50 visibles)", + hits.len() - 50 + ))); + } + s +} + +/// `:save ` — guarda como grupo los comandos del historial desde el +/// último `:save` (excluyendo los meta-comandos `:`). Ejecutables por F1..F8. +pub(crate) fn save_group(mut s: State, rest: &str) -> State { + let name = rest.trim().to_string(); + if name.is_empty() { + s.push_output(OutputLine::notice( + "uso: :save (agrupa los comandos desde el último :save)", + )); + return s; + } + let (lines, hist_len) = { + let Ok(h) = s.history.lock() else { + return s; + }; + let entries = h.entries(); + // El propio `:save` ya entró al historial: lo excluimos junto con el + // resto de meta-comandos `:`. + let upto = entries.len().saturating_sub(1); + let lines: Vec = entries + .get(s.group_anchor..upto) + .unwrap_or(&[]) + .iter() + .map(|e| e.line.clone()) + .filter(|l| !l.trim_start().starts_with(':')) + .collect(); + (lines, entries.len()) + }; + if lines.is_empty() { + s.push_output(OutputLine::notice( + "nada que guardar — corre algún comando antes de `:save`", + )); + return s; + } + // El próximo grupo arranca desde aquí. + s.group_anchor = hist_len; + // Reemplaza un grupo homónimo, si existe. + let n = lines.len(); + if let Some(g) = s.groups.iter_mut().find(|g| g.name == name) { + g.lines = lines; + } else { + s.groups.push(CommandGroup { name: name.clone(), lines }); + } + let fkey = s + .groups + .iter() + .position(|g| g.name == name) + .map(|i| i + 1) + .unwrap_or(0); + s.push_output(OutputLine::notice(format!( + "grupo «{name}» guardado ({n} comandos) — F{fkey} lo ejecuta" + ))); + s +} + +/// `:groups` — lista los grupos guardados con su tecla de función. +pub(crate) fn apply_groups_list(mut s: State) -> State { + if s.groups.is_empty() { + s.push_output(OutputLine::notice( + "(sin grupos — `:save ` guarda los últimos comandos)", + )); + return s; + } + let rows: Vec = s + .groups + .iter() + .enumerate() + .map(|(i, g)| format!("F{} {} ({} cmds)", i + 1, g.name, g.lines.len())) + .collect(); + for r in rows { + s.push_output(OutputLine::notice(r)); + } + s +} + +// ─────────────────────────── E1 · Macros parametrizables ─────────────────── + +/// Carga el libro de macros de `~/.config/shuma/macros.toml`. Ausente o +/// corrupto → libro vacío (config de conveniencia, el shell arranca igual). +pub(crate) fn load_macro_book() -> shuma_intent::MacroBook { + shuma_config::macros_path() + .and_then(|p| std::fs::read_to_string(p).ok()) + .and_then(|s| toml::from_str(&s).ok()) + .unwrap_or_default() +} + +/// Persiste el libro de macros (atómico: tmp + rename). +pub(crate) fn save_macro_book(book: &shuma_intent::MacroBook) { + let Some(path) = shuma_config::macros_path() else { + return; + }; + let Ok(text) = toml::to_string_pretty(book) else { + return; + }; + if let Some(dir) = path.parent() { + let _ = std::fs::create_dir_all(dir); + } + let tmp = path.with_extension("toml.tmp"); + if std::fs::write(&tmp, text).is_ok() { + let _ = std::fs::rename(&tmp, path); + } +} + +/// Sustituye los huecos `%1..%9` de una plantilla de macro por los argumentos +/// posicionales, y `%*` por todos unidos por espacio. Un `%` sin dígito válido +/// detrás se deja literal. Un hueco sin argumento se reemplaza por vacío. +pub(crate) fn substitute_macro_params(template: &str, args: &[&str]) -> String { + let mut out = String::with_capacity(template.len()); + let mut chars = template.chars().peekable(); + while let Some(c) = chars.next() { + if c != '%' { + out.push(c); + continue; + } + match chars.peek() { + Some('*') => { + chars.next(); + out.push_str(&args.join(" ")); + } + Some(d) if d.is_ascii_digit() && *d != '0' => { + let idx = (*d as u8 - b'1') as usize; + chars.next(); + if let Some(a) = args.get(idx) { + out.push_str(a); + } + } + // `%` solo o seguido de algo que no es hueco: literal. + _ => out.push('%'), + } + } + out +} + +/// `:macro [save | run args… | rm | +/// list]` — el plano de control de las macros parametrizables. Sin subcomando +/// (o `list`) las lista. +pub(crate) fn apply_macro(s: State, rest: &str) -> State { + let mut parts = rest.trim().splitn(2, char::is_whitespace); + let sub = parts.next().unwrap_or(""); + let arg = parts.next().unwrap_or("").trim(); + match sub { + "" | "list" | "ls" => list_macros(s), + "save" | "set" => macro_save(s, arg), + "run" => macro_run(s, arg), + "rm" | "del" | "delete" => macro_rm(s, arg), + other => { + let mut s = s; + s.push_output(OutputLine::notice(format!( + "macro: subcomando «{other}» desconocido — usa save | run | rm | list" + ))); + s + } + } +} + +/// `:macro save ` — guarda (o reemplaza) una macro. La +/// plantilla es todo lo que sigue al nombre y puede tener huecos `%1..%9`. +fn macro_save(mut s: State, arg: &str) -> State { + let mut it = arg.splitn(2, char::is_whitespace); + let name = it.next().unwrap_or("").trim(); + let template = it.next().unwrap_or("").trim(); + if name.is_empty() || template.is_empty() { + s.push_output(OutputLine::notice( + "uso: :macro save ", + )); + return s; + } + s.macro_book + .insert(shuma_intent::Macro::new(name).step(template)); + save_macro_book(&s.macro_book); + s.push_output(OutputLine::notice(format!( + "✔ macro «{name}» guardada — `:macro run {name} …` la corre" + ))); + s +} + +/// `:macro run arg1 arg2 …` — instancia la macro sustituyendo +/// `%1..%9` por los argumentos y la ejecuta (varios pasos → `a && b && …`). +fn macro_run(mut s: State, arg: &str) -> State { + let mut it = arg.split_whitespace(); + let Some(name) = it.next() else { + s.push_output(OutputLine::notice("uso: :macro run [args…]")); + return s; + }; + let args: Vec<&str> = it.collect(); + let Some(m) = s.macro_book.by_name(name) else { + s.push_output(OutputLine::notice(format!( + "macro «{name}» no existe — `:macros` las lista" + ))); + return s; + }; + let joined = instantiate_macro(m, &args); + if joined.trim().is_empty() { + return s; + } + s.input.set_text(&joined); + run_submitted(s) +} + +/// Instancia una macro: sustituye `%1..%9`/`%*` en cada paso por `args` y une +/// los pasos con `&&` (una sola línea ejecutable). Puro — sin tocar el State +/// ni disco; el corazón testeable de `:macro run`. +pub(crate) fn instantiate_macro(m: &shuma_intent::Macro, args: &[&str]) -> String { + m.intentions + .iter() + .map(|t| substitute_macro_params(t, args)) + .collect::>() + .join(" && ") +} + +/// `:macro rm ` — borra una macro del libro. +fn macro_rm(mut s: State, arg: &str) -> State { + let name = arg.trim(); + if name.is_empty() { + s.push_output(OutputLine::notice("uso: :macro rm ")); + return s; + } + let mut book = shuma_intent::MacroBook::new(); + let mut removed = false; + for m in s.macro_book.all() { + if m.name == name { + removed = true; + } else { + book.insert(m.clone()); + } + } + if removed { + s.macro_book = book; + save_macro_book(&s.macro_book); + s.push_output(OutputLine::notice(format!("✔ macro «{name}» borrada"))); + } else { + s.push_output(OutputLine::notice(format!("macro «{name}» no existe"))); + } + s +} + +/// `:macros` / `:macro list` — lista las macros guardadas con su plantilla. +pub(crate) fn list_macros(mut s: State) -> State { + if s.macro_book.is_empty() { + s.push_output(OutputLine::notice( + "(sin macros — `:macro save ` guarda una)", + )); + return s; + } + let rows: Vec = s + .macro_book + .all() + .iter() + .map(|m| format!("• {} → {}", m.name, m.intentions.join(" && "))) + .collect(); + for r in rows { + s.push_output(OutputLine::notice(r)); + } + s +} + +// ─────────────────────────── E6 · :stats (telemetría local) ──────────────── + +/// Agregado por binario para el reporte de `:stats`. +struct StatRow { + binario: String, + veces: u64, + fallos: u64, + durs: Vec, + ultimo_started: u64, +} + +impl StatRow { + /// Percentil `p` (0.0..=1.0) de las duraciones registradas, en ms. + /// `None` si ningún run del binario reportó duración. + fn percentil(&self, p: f64) -> Option { + if self.durs.is_empty() { + return None; + } + let mut v = self.durs.clone(); + v.sort_unstable(); + let idx = ((v.len() as f64 - 1.0) * p).round() as usize; + v.get(idx).copied() + } +} + +/// `:stats [filtro]` — telemetría propia, local, consultable (E6). Lee el +/// historial durable (`line`/`exit`/`started`/`duration_ms`) y arma una tabla +/// por binario: veces, fallos, %fallo, p50/p95 de duración, último uso. La +/// tabla se renderiza con el mismo widget ordenable que `ls -l` (el detector +/// de `sections.rs` reconoce `:stats` y parsea las filas tab-separadas). Cero +/// red: los datos nunca salen de la máquina. Alimenta los rankings de A3/A4. +/// +/// `:stats foo` filtra a los binarios cuyo nombre contiene `foo`. +pub(crate) fn apply_stats(mut s: State, rest: &str) -> State { + let filtro = rest.trim(); + // Snapshot del historial para no retener el lock mientras pusheamos. + let entries: Vec = match s.history.lock() { + Ok(h) => h.entries().to_vec(), + Err(p) => p.into_inner().entries().to_vec(), + }; + if entries.is_empty() { + s.push_output(OutputLine::notice( + "(historial vacío — corre algunos comandos y vuelve a `:stats`)", + )); + return s; + } + let now_s = now_unix_millis() / 1000; + match compute_stats(&entries, filtro, now_s) { + Some(lines) => { + for l in lines { + s.push_output(OutputLine::stdout(l)); + } + } + None => s.push_output(OutputLine::notice(if filtro.is_empty() { + "(sin binarios medibles en el historial)".to_string() + } else { + format!("(ningún binario contiene «{filtro}»)") + })), + } + s +} + +/// Corazón puro de `:stats`: de un slice de entradas del historial deriva las +/// líneas de salida (1 de resumen sin tab + header + filas tab-separadas que +/// `sections::detect_stats` parsea como tabla ordenable). `None` si no hay +/// ningún binario medible (todo builtins o nada matchea el filtro). `now_s` se +/// inyecta para que el «hace cuánto» sea determinista en tests. +pub(crate) fn compute_stats( + entries: &[shuma_history::Entry], + filtro: &str, + now_s: u64, +) -> Option> { + // Agregación por binario (primera palabra de la línea). Los meta-comandos + // del shell (`:save`, `:stats`…) se omiten: no son procesos medibles. + let mut por_bin: std::collections::HashMap = std::collections::HashMap::new(); + let total_lineas = entries.len(); + let mut con_exit = 0u64; + let mut horas = [0u64; 24]; + for e in entries { + let Some(bin) = e.line.split_whitespace().next() else { + continue; + }; + if bin.starts_with(':') { + continue; + } + if !filtro.is_empty() && !bin.contains(filtro) { + continue; + } + horas[((e.started / 3600) % 24) as usize] += 1; + let row = por_bin.entry(bin.to_string()).or_insert_with(|| StatRow { + binario: bin.to_string(), + veces: 0, + fallos: 0, + durs: Vec::new(), + ultimo_started: 0, + }); + row.veces += 1; + if let Some(code) = e.exit { + con_exit += 1; + if code != 0 { + row.fallos += 1; + } + } + if let Some(d) = e.duration_ms { + row.durs.push(d); + } + row.ultimo_started = row.ultimo_started.max(e.started); + } + if por_bin.is_empty() { + return None; + } + // Orden por defecto: más usados primero (la columna es re-ordenable en UI). + let mut filas: Vec = por_bin.into_values().collect(); + filas.sort_by(|a, b| b.veces.cmp(&a.veces).then(a.binario.cmp(&b.binario))); + let distintos = filas.len(); + let pico = horas + .iter() + .enumerate() + .max_by_key(|(_, n)| **n) + .filter(|(_, n)| **n > 0) + .map(|(h, _)| h); + + let pico_txt = pico + .map(|h| format!(" · pico {h:02}–{:02}h UTC", (h + 1) % 24)) + .unwrap_or_default(); + let alcance = if filtro.is_empty() { + String::new() + } else { + format!(" (filtro «{filtro}»)") + }; + let mut out = Vec::with_capacity(filas.len() + 2); + // Resumen (sin tab → el detector lo deja como sección «resumen»). + out.push(format!( + "{total_lineas} comandos en historial · {distintos} binarios distintos · \ + {con_exit} con código de salida{pico_txt}{alcance}" + )); + // Tabla tab-separada: header + una fila por binario. + out.push("comando\tveces\tfallos\t%fallo\tp50ms\tp95ms\túltimo".to_string()); + for f in filas.iter().take(200) { + let pct = if f.veces > 0 { (f.fallos * 100) / f.veces } else { 0 }; + let p50 = f.percentil(0.50).map(|v| v.to_string()).unwrap_or_else(|| "-".into()); + let p95 = f.percentil(0.95).map(|v| v.to_string()).unwrap_or_else(|| "-".into()); + let ultimo = humanizar_hace(now_s.saturating_sub(f.ultimo_started)); + out.push(format!( + "{}\t{}\t{}\t{}\t{}\t{}\t{}", + f.binario, f.veces, f.fallos, pct, p50, p95, ultimo + )); + } + Some(out) +} + +/// «hace cuánto», compacto, desde segundos transcurridos: `ahora` / `Nm` / +/// `Nh` / `Nd`. Determinista, sin formato de fecha (la celda es angosta). +fn humanizar_hace(secs: u64) -> String { + match secs { + 0..=59 => "ahora".to_string(), + 60..=3599 => format!("{}m", secs / 60), + 3600..=86_399 => format!("{}h", secs / 3600), + _ => format!("{}d", secs / 86_400), + } +} + +// ─────────────────────────── Predicción (frecuencia · cwd · contexto) ────── + +/// `:predice` (`:sugiere`/`:next`) — muestra qué comandos y qué secuencias/ +/// grupos son probables AQUÍ, combinando frecuencia, directorio actual y +/// contexto (marcadores de proyecto). Es la cara consultable de la maquinaria +/// que ya alimenta el ghost (A3) y las coreografías (A1): el ghost predice +/// inline UNA continuación; esto lista el ranking completo para elegir. +pub(crate) fn apply_predict(mut s: State, _rest: &str) -> State { + let cwd = s.cwd.display().to_string(); + // 1) La continuación inmediata que el motor de patrones anticipa, si la hay. + if let Some(seq) = predicted_sequence(&s) { + s.push_output(OutputLine::notice(format!("→ probable ahora: {seq}"))); + } + // 2) Comandos rankeados por frecuencia × cwd × recencia. + let preds = { + let entries: Vec = match s.history.lock() { + Ok(h) => h.entries().to_vec(), + Err(p) => p.into_inner().entries().to_vec(), + }; + rank_command_predictions(&entries, &cwd, 8) + }; + if preds.is_empty() { + s.push_output(OutputLine::notice( + "(sin historial para predecir — corre algunos comandos)", + )); + return s; + } + s.push_output(OutputLine::notice(format!("comandos probables en {cwd}:"))); + for p in &preds { + // Marca de afinidad: ◆ si pesó el cwd actual, · si es global. + let marca = if p.cwd_freq > 0 { "◆" } else { "·" }; + let detalle = if p.cwd_freq > 0 { + format!("{}× aquí / {}× total", p.cwd_freq, p.freq) + } else { + format!("{}× total", p.freq) + }; + s.push_output(OutputLine::stdout(format!(" {marca} {} ({detalle})", p.line))); + } + // 3) Secuencias/grupos aplicables al contexto + grupos guardados (F-keys). + let seqs = applicable_sequences(&s); + if !seqs.is_empty() || !s.groups.is_empty() { + s.push_output(OutputLine::notice("secuencias / grupos aquí:")); + } + for (nombre, linea, occ) in seqs.iter().take(5) { + s.push_output(OutputLine::stdout(format!( + " ⟫ {nombre} ({occ}×) → {linea}" + ))); + } + let group_rows: Vec = s + .groups + .iter() + .enumerate() + .map(|(i, g)| format!(" F{} {} → {}", i + 1, g.name, g.lines.join(" && "))) + .collect(); + for row in group_rows { + s.push_output(OutputLine::stdout(row)); + } + s +} + +// ─────────────────────────── E5 · LLM como instrumento ───────────────────── + +/// `:? ` — lenguaje natural → línea de comando propuesta. El +/// módulo sólo arma la petición (`State::llm_request`); el host corre el LLM +/// y devuelve `Msg::LlmResult`. La respuesta va al **input** para revisar y +/// Enter — NUNCA se auto-ejecuta. Rotulado `🜲 llm`, opt-in por invocación. +pub(crate) fn apply_ask(mut s: State, rest: &str) -> State { + let q = rest.trim(); + if q.is_empty() { + s.push_output(OutputLine::notice( + "uso: :? — el LLM propone una línea de comando (no la ejecuta)", + )); + return s; + } + let system = "Eres un asistente de shell en Linux. El usuario describe lo que quiere lograr. \ + Responde EXCLUSIVAMENTE con UNA sola línea de comando de shell que lo cumpla — sin \ + explicación, sin markdown, sin backticks, sin comentarios. Una sola línea." + .to_string(); + s.llm_request = Some(LlmRequest { + kind: LlmKind::Command, + system, + prompt: q.to_string(), + max_tokens: 200, + llm: wawa_config::WawaConfig::load().ai.resolve(None, Some("shuma")).clone(), + }); + s.push_output(OutputLine::notice(format!("🜲 llm · pensando una línea para: {q}"))); + s +} + +/// `:haz ` (`:hace`/`:control`) — lenguaje natural → una acción de +/// **control de la suite** (mirada/sandokan/…), elegida del catálogo `atipay`. +/// A diferencia de `:?` (cualquier comando de shell), aquí el LLM se ciñe al +/// vocabulario real de control del sistema: el catálogo va en el system prompt +/// como menú y el modelo devuelve la línea exacta (`mirada-ctl …`/`sandokan-cli +/// …`). La propuesta va al **input** — NUNCA se auto-ejecuta. Mismo camino que +/// `:?` (`LlmKind::Command`), sin tocar el chasis. +pub(crate) fn apply_hacer(mut s: State, rest: &str) -> State { + let q = rest.trim(); + if q.is_empty() { + s.push_output(OutputLine::notice( + "uso: :haz — el LLM elige una acción de control (no la ejecuta)", + )); + return s; + } + // El catálogo se identifica por `id`; el modelo elige UNO y sus args. Así + // no puede inventar flags: la línea de comando la arma `atipay` (validada). + let menu = atipay::Catalogo::estandar().prompt_menu_ids(); + let system = format!( + "Eres el controlador del escritorio tawasuyu. El usuario describe lo que quiere lograr. \ + Elige EXACTAMENTE UNA acción del catálogo de abajo y responde SÓLO con un objeto JSON \ + {{\"id\":\"\",\"args\":{{\"\":\"\"}}}} — sin explicación, \ + sin markdown, sin backticks. Si la acción no lleva parámetros, omití \"args\" o déjalo {{}}. \ + Si nada del catálogo encaja, responde exactamente: nada.\n\n\ + Catálogo de acciones de control (id — qué hace — parámetros):\n{menu}" + ); + s.llm_request = Some(LlmRequest { + kind: LlmKind::Atipay, + system, + prompt: q.to_string(), + max_tokens: 120, + llm: wawa_config::WawaConfig::load().ai.resolve(None, Some("shuma")).clone(), + }); + s.push_output(OutputLine::notice(format!("🜲 llm · eligiendo una acción de control para: {q}"))); + s +} + +/// Resuelve la respuesta de `:haz` (`LlmKind::Atipay`): el modelo eligió una +/// acción y devolvió su `id` + args en JSON. Se parsea a una `atipay::Invocacion`, +/// se valida con el catálogo a un `Plan`, y la **línea de comando exacta** va al +/// input etiquetada por peligro — NUNCA se auto-ejecuta (revisar y Enter). Como +/// la arma `atipay`, el modelo no puede colar flags inexistentes. Tolerante: +/// «nada» / JSON inválido / id desconocido → aviso, sin romper. +pub(crate) fn resolver_atipay(mut s: State, text: &str) -> State { + let raw = text.trim(); + if raw.is_empty() || raw.eq_ignore_ascii_case("nada") { + s.push_output(OutputLine::notice("🜲 ninguna acción de control encaja")); + return s; + } + // El modelo puede colar markdown/backticks; quédate con el objeto JSON. + let json = match (raw.find('{'), raw.rfind('}')) { + (Some(i), Some(j)) if j > i => &raw[i..=j], + _ => { + s.push_output(OutputLine::notice("🜲 no entendí la elección del modelo")); + return s; + } + }; + let inv: atipay::Invocacion = match serde_json::from_str(json) { + Ok(inv) => inv, + Err(_) => { + s.push_output(OutputLine::notice("🜲 no entendí la elección del modelo")); + return s; + } + }; + match atipay::Catalogo::estandar().plan(&inv) { + Ok(plan) => { + let etiqueta = match plan.peligro { + atipay::Peligro::Seguro => "seguro", + atipay::Peligro::Reversible => "reversible", + atipay::Peligro::Disruptivo => "⚠ DISRUPTIVO", + }; + s.input.set_text(&plan.linea_comando()); + s.focused = true; + s.push_output(OutputLine::notice(format!( + "🜲 {} [{}] — en el input, revisa y Enter (no se ejecutó)", + plan.id, etiqueta + ))); + } + Err(e) => s.push_output(OutputLine::notice(format!("🜲 {e}"))), + } + s +} + +/// `:explica [%cN]` / `:resume [%cN]` — explica o resume la salida de un +/// bloque (la del más reciente si no se da ref). El resultado va al output, +/// rotulado `🜲`. `summarize=true` → `:resume`. +pub(crate) fn apply_explain(mut s: State, rest: &str, summarize: bool) -> State { + let Some((block, stage)) = parse_block_and_stage(&s, rest) else { + s.push_output(OutputLine::notice( + "uso: :explica %cN[.K] (o sin ref, sobre el bloque más reciente con salida)", + )); + return s; + }; + let label = target_label(block, stage); + let body = gather_target_text(&s, block, stage); + if body.trim().is_empty() { + s.push_output(OutputLine::notice(format!( + "{label} no tiene salida para {}", + if summarize { "resumir" } else { "explicar" } + ))); + return s; + } + // Cap del cuerpo para no inflar el prompt (los logs gigantes se truncan). + let body = cap_prompt_body(&body, 8000); + let (system, verbo) = if summarize { + ( + "Eres un asistente de shell. Resume en español, en pocas líneas y conciso, la salida \ + de un comando. Destacá lo importante; nada de relleno.", + "resumiendo", + ) + } else { + ( + "Eres un asistente de shell. Explica en español, claro y breve, qué dice la salida de \ + un comando y si hay algo que atender (errores, avisos). Sin relleno.", + "explicando", + ) + }; + s.llm_request = Some(LlmRequest { + kind: LlmKind::Text, + system: system.to_string(), + prompt: format!("Salida del bloque {label}:\n\n{body}"), + max_tokens: 600, + llm: wawa_config::WawaConfig::load().ai.resolve(None, Some("shuma")).clone(), + }); + // La respuesta abre su propio bloque referenciable (`%cM`) — re-filtrable. + let etiqueta = if summarize { "resume" } else { "explica" }; + s.llm_block_label = Some(format!("🜲 :{etiqueta} {label}")); + s.push_output(OutputLine::notice(format!("🜲 llm · {verbo} el bloque {label}…"))); + s +} + +/// `:filtra [%cN]` (`:filter`/`:fia`) — **filtro IA** sobre la +/// salida de un bloque: el LLM aplica la instrucción en lenguaje natural a la +/// salida (stdout + stderr + respuestas de IA previas) y devuelve SÓLO el texto +/// resultante, que aterriza en su **propio bloque** (`OutputKind::Ai`, `%cM`). +/// Como el resultado es salida de primera clase, se puede volver a filtrar, a +/// `:write`/`:yank` o encadenar con `%cM`. Sin ref, opera sobre el último +/// bloque con salida. Gramática: si el primer token es `%cN`/`%pN`, ese es el +/// bloque y el resto la instrucción; si no, la instrucción es todo y el bloque +/// es el último con salida. +pub(crate) fn apply_filter(mut s: State, rest: &str) -> State { + let rest = rest.trim(); + if rest.is_empty() { + s.push_output(OutputLine::notice( + "uso: :filtra [%cN] — el LLM transforma la salida del bloque \ + (p. ej. «sólo los errores», «a JSON», «traduce al inglés»)", + )); + return s; + } + // ¿El primer token es una ref de bloque (con o sin etapa `.K`)? + let mut parts = rest.splitn(2, char::is_whitespace); + let first = parts.next().unwrap_or(""); + let first_is_ref = first.starts_with("%c") || first.starts_with("%p"); + let (target, instr) = if first_is_ref { + (parse_block_and_stage(&s, first), parts.next().unwrap_or("").trim().to_string()) + } else { + (parse_block_and_stage(&s, ""), rest.to_string()) + }; + if instr.is_empty() { + s.push_output(OutputLine::notice( + "✘ :filtra — falta la instrucción (qué hacer con la salida)", + )); + return s; + } + let Some((block, stage)) = target else { + s.push_output(OutputLine::notice( + "✘ :filtra — no hay salida de un bloque previo para filtrar", + )); + return s; + }; + let label = target_label(block, stage); + let body = gather_target_text(&s, block, stage); + if body.trim().is_empty() { + s.push_output(OutputLine::notice(format!( + "✘ :filtra — {label} no tiene salida" + ))); + return s; + } + let body = cap_prompt_body(&body, 8000); + let system = "Eres un filtro de texto en una terminal. Recibes la SALIDA de un comando y una \ + INSTRUCCIÓN de qué hacer con ella. Aplica la instrucción y devuelve EXCLUSIVAMENTE el texto \ + resultante — sin explicación, sin preámbulo, sin markdown, sin backticks. Si la instrucción \ + pide un formato (JSON, CSV, tabla), devuelve sólo eso." + .to_string(); + s.llm_request = Some(LlmRequest { + kind: LlmKind::Text, + system, + prompt: format!("INSTRUCCIÓN: {instr}\n\nSALIDA del bloque {label}:\n\n{body}"), + max_tokens: 1200, + llm: wawa_config::WawaConfig::load().ai.resolve(None, Some("shuma")).clone(), + }); + s.llm_block_label = Some(format!("🜲 :filtra «{instr}» ← {label}")); + s.push_output(OutputLine::notice(format!( + "🜲 llm · filtrando {label}: {instr}…" + ))); + s +} + +/// `:buscar ` (`:search`) — búsqueda **semántica** sobre el historial: +/// rankea los comandos pasados por significado, no por substring. El módulo sólo +/// arma la petición (`State::semantic_request`); el chasis embebe con el daemon +/// `rimay-verbo` (o un mock determinista si no hay daemon) y devuelve +/// `Msg::SemanticResult`. Opt-in: requiere `[ai.semantic] enabled = true`. +pub(crate) fn apply_search(mut s: State, rest: &str) -> State { + let q = rest.trim(); + if q.is_empty() { + s.push_output(OutputLine::notice( + "uso: :buscar — encuentra comandos pasados por significado", + )); + return s; + } + let ai_sem = wawa_config::WawaConfig::load().ai.semantic; + if !ai_sem.enabled { + s.push_output(OutputLine::notice( + "búsqueda semántica apagada — activala en wawa-panel (IA y semántica)", + )); + return s; + } + // Corpus: líneas de comando del historial, deduplicadas (las más recientes + // primero), capadas para no embeber miles por consulta. Clave==texto. + const MAX_CANDIDATES: usize = 500; + let mut seen = std::collections::HashSet::new(); + let mut candidates: Vec<(String, String)> = Vec::new(); + { + let guard = match s.history.lock() { + Ok(g) => g, + Err(p) => p.into_inner(), + }; + for e in guard.entries().iter().rev() { + let line = e.line.trim(); + if line.is_empty() || line.starts_with(':') { + continue; + } + if seen.insert(line.to_string()) { + candidates.push((line.to_string(), line.to_string())); + if candidates.len() >= MAX_CANDIDATES { + break; + } + } + } + } + if candidates.is_empty() { + s.push_output(OutputLine::notice("no hay historial sobre el cual buscar todavía")); + return s; + } + let n = candidates.len(); + s.semantic_request = Some(SemanticRequest { + scope: "history".to_string(), + query: q.to_string(), + candidates, + socket: ai_sem.socket.clone(), + dim: ai_sem.effective_dim(), + }); + s.push_output(OutputLine::notice(format!( + "🔎 buscando «{q}» por significado en {n} comandos…" + ))); + s +} + +/// `:buscar-archivos ` (`:fbuscar`/`:fsearch`) — búsqueda **semántica +/// de archivos** bajo el cwd: rankea por significado el nombre + un fragmento +/// del contenido de cada archivo (espeja el `run_find_semantic` de nahual, pero +/// sobre el índice persistido de shuma). Opt-in por `[ai.semantic] enabled`. +pub(crate) fn apply_search_files(mut s: State, rest: &str) -> State { + let q = rest.trim(); + if q.is_empty() { + s.push_output(OutputLine::notice( + "uso: :buscar-archivos — encuentra archivos del cwd por significado", + )); + return s; + } + let ai_sem = wawa_config::WawaConfig::load().ai.semantic; + if !ai_sem.enabled { + s.push_output(OutputLine::notice( + "búsqueda semántica apagada — activala en wawa-panel (IA y semántica)", + )); + return s; + } + let root = s.cwd.clone(); + let cwd_label = s.cwd.display().to_string(); + let candidates = collect_file_candidates(&root); + if candidates.is_empty() { + s.push_output(OutputLine::notice(format!( + "no encontré archivos para indexar bajo {cwd_label}" + ))); + return s; + } + let n = candidates.len(); + s.semantic_request = Some(SemanticRequest { + scope: "files".to_string(), + query: q.to_string(), + candidates, + socket: ai_sem.socket.clone(), + dim: ai_sem.effective_dim(), + }); + s.push_output(OutputLine::notice(format!( + "🔎 buscando archivos «{q}» por significado en {n} bajo {cwd_label}…" + ))); + s +} + +/// Recolecta archivos bajo `root` para la búsqueda semántica: recorre acotado +/// (sin entrar en dirs pesados/ocultos), y por cada archivo arma un par +/// `(clave, texto)`. La **clave** es `mtime\0ruta` (cambia al editar el archivo, +/// forzando re-embeber); el **texto** es la ruta relativa + un fragmento del +/// contenido si es texto. Acotado en cantidad y bytes para no embeber un árbol. +pub(crate) fn collect_file_candidates(root: &std::path::Path) -> Vec<(String, String)> { + const MAX_FILES: usize = 800; + const SNIPPET_BYTES: usize = 1500; + const SKIP_DIRS: &[&str] = &[".git", "target", "node_modules", ".cache", "dist", ".venv"]; + + let mut out: Vec<(String, String)> = Vec::new(); + let mut stack = vec![root.to_path_buf()]; + while let Some(dir) = stack.pop() { + if out.len() >= MAX_FILES { + break; + } + let Ok(rd) = std::fs::read_dir(&dir) else { continue }; + for ent in rd.flatten() { + let path = ent.path(); + let name = ent.file_name().to_string_lossy().to_string(); + if name.starts_with('.') { + continue; // ocultos fuera + } + let Ok(ft) = ent.file_type() else { continue }; + if ft.is_dir() { + if !SKIP_DIRS.contains(&name.as_str()) { + stack.push(path); + } + continue; + } + if !ft.is_file() { + continue; + } + let rel = path.strip_prefix(root).unwrap_or(&path).to_string_lossy().to_string(); + // mtime como token de versión (segundos unix); 0 si no se puede leer. + let mtime = ent + .metadata() + .ok() + .and_then(|m| m.modified().ok()) + .and_then(|t| t.duration_since(std::time::UNIX_EPOCH).ok()) + .map(|d| d.as_secs()) + .unwrap_or(0); + // Texto a embeber: ruta relativa + fragmento del contenido si parece + // texto (leemos pocos bytes y descartamos binarios con bytes nulos). + let mut text = rel.clone(); + if let Ok(bytes) = std::fs::read(&path) { + let head = &bytes[..bytes.len().min(SNIPPET_BYTES)]; + if !head.contains(&0) { + if let Ok(snippet) = std::str::from_utf8(head) { + text.push('\n'); + text.push_str(snippet); + } + } + } + out.push((format!("{mtime}\0{rel}"), text)); + if out.len() >= MAX_FILES { + break; + } + } + } + out +} + +/// Resuelve el bloque objetivo de `:explica`/`:resume`: un `%cN`/`%pN`, un +/// número pelado, o —sin ref— el bloque más reciente con stdout. +fn parse_block_ref(s: &State, rest: &str) -> Option { + let t = rest.trim(); + if !t.is_empty() { + let t = t + .strip_prefix("%c") + .or_else(|| t.strip_prefix("%p")) + .unwrap_or(t); + return t.trim().parse::().ok(); + } + // Sin ref: el bloque del stdout más reciente. + s.output + .iter() + .rev() + .find(|l| l.kind == OutputKind::Stdout && l.stage.is_none()) + .map(|l| l.block) +} + +/// Parsea una ref que puede traer **etapa del tee**: `%c5.2` / `%c5:2` → +/// `(5, Some(2))`; `%c5` → `(5, None)`. La etapa es 0-based, como los chips del +/// pipe. Reusa [`parse_block_ref`] para el bloque (acepta `%cN`/`%pN`/número +/// pelado y, vacío, el último con salida). Así los intermedios del tee —antes +/// sólo mirables— se vuelven direccionables por `:filtra`/`:write`/`:yank`/ +/// `:explica`. +fn parse_block_and_stage(s: &State, token: &str) -> Option<(u64, Option)> { + let t = token.trim(); + let (base, stage) = match t.rsplit_once(['.', ':']) { + Some((b, k)) => match k.parse::() { + Ok(k) => (b, Some(k)), + Err(_) => (t, None), + }, + None => (t, None), + }; + let block = parse_block_ref(s, base)?; + Some((block, stage)) +} + +/// Texto del objetivo de un redireccionador: el bloque entero +/// ([`gather_block_text`]) o, si `stage` está dado, sólo las líneas capturadas +/// de esa etapa intermedia del pipe (tee). +fn gather_target_text(s: &State, block: u64, stage: Option) -> String { + let Some(k) = stage else { + return gather_block_text(s, block); + }; + let mut out = String::new(); + for l in &s.output { + if l.block == block && l.stage == Some(k) { + out.push_str(&l.text); + out.push('\n'); + } + } + out +} + +/// Etiqueta corta de un objetivo para los avisos: `%c5` o `%c5.2`. +fn target_label(block: u64, stage: Option) -> String { + match stage { + Some(k) => format!("%c{block}.{k}"), + None => format!("%c{block}"), + } +} + +/// Trunca un cuerpo a `max` chars por el medio (cabeza + cola), preservando +/// el principio y el final — lo más útil de un log para resumir/explicar. +fn cap_prompt_body(body: &str, max: usize) -> String { + if body.len() <= max { + return body.to_string(); + } + let head = max * 2 / 3; + let tail = max - head; + let start: String = body.chars().take(head).collect(); + let end: String = body.chars().rev().take(tail).collect::>().into_iter().rev().collect(); + format!("{start}\n…[recortado]…\n{end}") +} + +// ─────────── E4 · sesiones PTY persistentes del daemon (sobreviven la app) ── + +/// Socket del daemon a usar: el de `Source::Daemon` si la sesión corre contra +/// uno, o el default (`$XDG_RUNTIME_DIR/shuma.sock`) — así `:spawn`/`:sessions` +/// funcionan también en modo Local si hay un daemon corriendo. +fn daemon_socket_for(s: &State) -> std::path::PathBuf { + match &s.source { + Source::Daemon { socket, .. } => { + socket.clone().unwrap_or_else(shuma_protocol::default_socket_path) + } + _ => shuma_protocol::default_socket_path(), + } +} + +/// Archivo que recuerda EL CONJUNTO de sesiones persistentes montadas en el +/// frontend — una por línea (ULID). Vive junto al socket del daemon (runtime +/// dir: sobrevive a reiniciar el compositor/pata, muere con el reboot — +/// exactamente la vida de las sesiones). El auto-reattach lo lee al arrancar +/// para re-montar TODAS las que sigan vivas (una tab por sesión). Antes era un +/// único ULID que se sobrescribía: al renacer pata sólo volvía UNA sesión y las +/// demás quedaban vivas pero huérfanas. +pub(crate) fn montada_path() -> std::path::PathBuf { + shuma_protocol::default_socket_path().with_file_name("shuma-montada") +} + +/// Todas las sesiones montadas registradas, en orden de registro. Tolera el +/// formato viejo (un único ULID en una línea). +pub(crate) fn leer_montadas() -> Vec { + std::fs::read_to_string(montada_path()) + .map(|c| { + c.lines() + .filter_map(|l| ulid::Ulid::from_string(l.trim()).ok()) + .collect() + }) + .unwrap_or_default() +} + +fn escribir_montadas(ids: &[ulid::Ulid]) { + let p = montada_path(); + if ids.is_empty() { + let _ = std::fs::remove_file(p); + return; + } + let cuerpo = ids + .iter() + .map(|i| i.to_string()) + .collect::>() + .join("\n"); + let _ = std::fs::write(p, cuerpo); +} + +/// Registra `id` en el conjunto montado (idempotente). El auto-reattach del +/// próximo arranque la vuelve a montar como una tab. +pub(crate) fn recordar_montada(id: ulid::Ulid) { + let mut ids = leer_montadas(); + if !ids.contains(&id) { + ids.push(id); + escribir_montadas(&ids); + } +} + +/// Quita `id` del conjunto (al desadjuntar o matar). No toca a las demás — un +/// mount/detach de una sesión no pisa el registro de las otras. +pub fn olvidar_montada(id: ulid::Ulid) { + let mut ids = leer_montadas(); + let antes = ids.len(); + ids.retain(|&x| x != id); + if ids.len() != antes { + escribir_montadas(&ids); + } +} + +/// Monta un asa remota + TUI como el run de foreground activo, en un bloque +/// nuevo. Compartido por `:spawn`, `:attach` y el auto-reattach — todos +/// rinden una sesión del daemon como si fuera un comando TUI local, pero +/// sobreviven a cerrar shuma. Registra `session` como LA montada (el +/// auto-reattach del próximo arranque vuelve a ella). +fn mount_session_run( + mut s: State, + handle: shuma_remote_exec::RemoteRunHandle, + program: &str, + prompt: String, + rows: u16, + cols: u16, + session: ulid::Ulid, +) -> State { + // El comando previo con cuerpo recede (se pliega), como en run_submitted. + let prev = s.current_block; + if prev != 0 && !body_lines_for_block(&s, prev).is_empty() { + s.collapsed.insert(prev); + } + let block = s.open_block(); + s.push_in_block(block, OutputLine::prompt(prompt)); + // `push_in_block` no registra `block_command` (eso lo hace `push_output` + // al abrir bloque por Prompt) — sin esto, `detect_sections` no ve que el + // bloque es de claude y la cosecha queda plana, sin secciones. + s.block_command.insert(block, format!("$ {program}")); + let tui = TuiSession::new(program, rows, cols); + s.tui_skin_vivo = Some(tui.skin); + s.tui_altscreen_vivo = false; + s.claude_ocupado = false; + s.running = Some(std::sync::Arc::new(std::sync::Mutex::new(ActiveRun { + handle: BackendHandle::Remote(handle), + killer: None, + command: program.to_string(), + tui: Some(tui), + block, + session: Some(session), + }))); + recordar_montada(session); + s.current_block = block; + s.focused = true; + s +} + +/// Auto-arranque perezoso del daemon (modelo tmux: el server nace en el +/// primer uso, desacoplado — sobrevive a reinicios del compositor). `false` +/// si no se pudo garantizar (ya avisó al usuario). +fn asegurar_daemon(s: &mut State, sock: &std::path::Path) -> bool { + match shuma_remote_exec::ensure_daemon(sock) { + Ok(true) => { + s.push_output(OutputLine::notice("(shuma-daemon arrancado)")); + true + } + Ok(false) => true, + Err(e) => { + s.push_output(OutputLine::notice(format!("✘ shuma-daemon — {e}"))); + false + } + } +} + +/// `:spawn ` — corre `` como **sesión PTY persistente** del daemon: +/// vive en el daemon, **sobrevive a cerrar shuma**. Se adjunta y la renderiza +/// como un TUI; cerrar shuma (o Ctrl-C) la desadjunta sin matarla +/// (`:sessions` / `shuma pty attach ` para reconectar). +pub(crate) fn apply_spawn_session(mut s: State, rest: &str) -> State { + let cmd = rest.trim(); + if cmd.is_empty() { + s.push_output(OutputLine::notice( + "uso: :spawn — corre en el daemon, sobrevive a cerrar shuma", + )); + return s; + } + let sock = daemon_socket_for(&s); + if !asegurar_daemon(&mut s, &sock) { + return s; + } + let (rows, cols) = (40u16, 120u16); + let spec = shuma_exec::CommandSpec { + exec: shuma_exec::Exec::Pty { + program: "bash".into(), + args: vec!["-lc".into(), cmd.to_string()], + cols, + rows, + }, + cwd: s.cwd.display().to_string(), + capture_limit: 0, + spill_path: None, + stdin_data: None, + env: Vec::new(), + capture_stages: false, + }; + match shuma_remote_exec::spawn_session(&spec, &sock, cmd) { + Ok((session, handle)) => { + s = mount_session_run( + s, + handle, + "bash", + format!("$ :spawn {cmd} · sesión {session} (sobrevive a cerrar shuma)"), + rows, + cols, + session, + ); + } + Err(e) => { + s.push_output(OutputLine::notice(format!( + "✘ :spawn — ¿hay un shuma-daemon corriendo? ({e})" + ))); + } + } + s +} + +/// `:sessions` — lista las sesiones PTY persistentes del daemon. +pub(crate) fn apply_sessions(mut s: State, _rest: &str) -> State { + let sock = daemon_socket_for(&s); + match shuma_remote_exec::list_sessions(&sock) { + Ok(sessions) => { + if sessions.is_empty() { + s.push_output(OutputLine::notice( + "(sin sesiones persistentes — `:spawn ` crea una)", + )); + return s; + } + for ss in &sessions { + let estado = if ss.alive { + format!("viva · {} adj", ss.attached) + } else { + format!("muerta · exit {}", ss.exit_code.unwrap_or(-1)) + }; + s.push_output(OutputLine::notice(format!( + "{} {:<20} [{estado}] {}", + ss.session, ss.label, ss.program + ))); + } + s.push_output(OutputLine::notice( + ":attach para verla · :kill-session para matarla", + )); + } + Err(e) => s.push_output(OutputLine::notice(format!( + "✘ :sessions — ¿hay un shuma-daemon corriendo? ({e})" + ))), + } + s +} + +/// `:attach ` — se re-adjunta a una sesión persistente y la renderiza. +pub(crate) fn apply_attach_session(mut s: State, rest: &str) -> State { + let Ok(id) = ulid::Ulid::from_string(rest.trim()) else { + s.push_output(OutputLine::notice("uso: :attach (`:sessions` las lista)")); + return s; + }; + let sock = daemon_socket_for(&s); + if !asegurar_daemon(&mut s, &sock) { + return s; + } + let (rows, cols) = (40u16, 120u16); + // Programa para el skin del TUI: lo sacamos de la lista si está. + let program = shuma_remote_exec::list_sessions(&sock) + .ok() + .and_then(|v| v.into_iter().find(|x| x.session == id)) + .map(|x| x.program) + .unwrap_or_else(|| "bash".into()); + match shuma_remote_exec::attach_session(&sock, id, rows, cols) { + Ok(handle) => { + s = mount_session_run(s, handle, &program, format!("$ :attach {id}"), rows, cols, id); + } + Err(e) => s.push_output(OutputLine::notice(format!("✘ :attach — {e}"))), + } + s +} + +/// Re-adjunta al arrancar la sesión persistente que quedó montada en el +/// frontend anterior (si el daemon y la sesión siguen vivos). Lo llama el +/// HOST una vez al construir el módulo — no vive en `State::new` para que +/// los tests no dependan del entorno. **No** arranca el daemon: si murió, +/// sus sesiones murieron con él (se limpia el registro y listo). +/// Re-adjunta la sesión viva descrita por `info` dentro de `s` y la monta como +/// el run de foreground. Núcleo compartido por `auto_reattach` (una, compat) y +/// `reattach_en` (todas, vía el host con tabs). +fn reattach_info( + mut s: State, + sock: &std::path::Path, + info: &shuma_protocol::PtySessionInfo, +) -> State { + let id = info.session; + match shuma_remote_exec::attach_session(sock, id, info.rows, info.cols) { + Ok(handle) => mount_session_run( + s, + handle, + &info.program, + format!("$ {} · re-adjuntado a {id} (sesión persistente)", info.label), + info.rows, + info.cols, + id, + ), + Err(e) => { + s.push_output(OutputLine::notice(format!("✘ re-attach de {id} — {e}"))); + s + } + } +} + +/// Sesiones persistentes registradas que siguen VIVAS en el daemon, en orden de +/// creación. Purga del registro las ausentes y reapa las muertas. Si el daemon +/// murió (reboot) limpia el registro entero y devuelve vacío. Lo usa el host +/// para re-montar una tab por sesión al arrancar. +pub fn montadas_vivas() -> Vec { + let ids = leer_montadas(); + if ids.is_empty() { + return Vec::new(); + } + let sock = shuma_protocol::default_socket_path(); + if std::os::unix::net::UnixStream::connect(&sock).is_err() { + // Daemon muerto (reboot): las sesiones no existen más. + let _ = std::fs::remove_file(montada_path()); + return Vec::new(); + } + let Ok(sesiones) = shuma_remote_exec::list_sessions(&sock) else { + return Vec::new(); + }; + let mut vivas = Vec::new(); + let mut quedan = Vec::new(); + for id in ids { + match sesiones.iter().find(|x| x.session == id) { + Some(info) if info.alive => { + quedan.push(id); + vivas.push(info.clone()); + } + // Murió mientras nadie miraba: cosechala, no montés un cadáver. + Some(_) => { + let _ = shuma_remote_exec::kill_session(&sock, id); + } + // Ya no existe en el daemon: la olvidamos (no la re-anotamos). + None => {} + } + } + escribir_montadas(&quedan); + // Orden = el del REGISTRO (`shuma-montada`), que es el orden en que el usuario + // abrió los tabs — NO `created_unix_ms`. `vivas` ya se armó recorriendo `ids` + // en orden de archivo; reordenar por timestamp descartaba esa secuencia y hacía + // que al reiniciar pata los tabs aparecieran en otro orden. El auto-reattach + // re-monta los tabs en este orden. + vivas +} + +/// Re-adjunta la sesión viva `info` dentro de `s` (que el host crea fresco por +/// tab). Complemento de [`montadas_vivas`]. +pub fn reattach_en(s: State, info: &shuma_protocol::PtySessionInfo) -> State { + let sock = shuma_protocol::default_socket_path(); + reattach_info(s, &sock, info) +} + +/// TODAS las sesiones persistentes del daemon (no sólo las registradas para +/// auto-reattach), en orden de creación. Fuente del gestor de sesiones +/// (taskmanager). Vacío si el daemon no responde. A diferencia de +/// [`montadas_vivas`], NO purga ni exige registro: muestra el fondo completo, +/// incluidas sesiones que nunca se anotaron (p. ej. huérfanas de un bug viejo). +pub fn listar_sesiones() -> Vec { + let sock = shuma_protocol::default_socket_path(); + let mut v = shuma_remote_exec::list_sessions(&sock).unwrap_or_default(); + v.sort_by_key(|i| i.created_unix_ms); + v +} + +/// Mira una sesión del fondo **sin adjuntarse**: (título OSC, pantalla en +/// texto). El gestor de sesiones la usa para rotular cada sesión con lo que el +/// programa dice ser (`nvim src/lib.rs`, `sergio@tawasuyu: ~/tawasuyu`, lo que +/// claude ponga) y para pintar su miniatura. `None` si el daemon no responde o +/// la sesión ya no existe. +pub fn mirar_sesion( + id: ulid::Ulid, + rows: u16, + cols: u16, +) -> Option<(Option, Vec)> { + let sock = shuma_protocol::default_socket_path(); + shuma_remote_exec::snapshot_session(&sock, id, rows, cols).ok().flatten() +} + +/// Mata la sesión persistente `id_str` (ULID) y la saca del registro de +/// auto-reattach. `true` si existía. Para el botón "matar" del gestor. +pub fn matar_sesion(id_str: &str) -> bool { + let Ok(id) = ulid::Ulid::from_string(id_str.trim()) else { + return false; + }; + olvidar_montada(id); + let sock = shuma_protocol::default_socket_path(); + shuma_remote_exec::kill_session(&sock, id).unwrap_or(false) +} + +/// Re-adjunta al arrancar la PRIMERA sesión persistente montada que siga viva +/// (compat: hosts de un solo pane). El host con tabs usa `montadas_vivas` + +/// `reattach_en` para re-montarlas TODAS. No arranca el daemon: si murió, sus +/// sesiones murieron con él (`montadas_vivas` limpia el registro). +pub fn auto_reattach(s: State) -> State { + let vivas = montadas_vivas(); + let Some(info) = vivas.first() else { return s }; + let sock = shuma_protocol::default_socket_path(); + reattach_info(s, &sock, info) +} + +/// `:kill-session ` — mata (o reapea) una sesión persistente. +pub(crate) fn apply_kill_session(mut s: State, rest: &str) -> State { + let Ok(id) = ulid::Ulid::from_string(rest.trim()) else { + s.push_output(OutputLine::notice("uso: :kill-session ")); + return s; + }; + let sock = daemon_socket_for(&s); + match shuma_remote_exec::kill_session(&sock, id) { + Ok(true) => s.push_output(OutputLine::notice(format!("✔ sesión {id} matada"))), + Ok(false) => s.push_output(OutputLine::notice(format!("no existía: {id}"))), + Err(e) => s.push_output(OutputLine::notice(format!("✘ {e}"))), + } + s +} + +/// Reconstruye el stdout de un bloque (su card) uniendo las líneas +/// `Stdout` sin etapa — para alimentarlo como stdin de un reprocess. +/// Expande `~` al HOME y resuelve rutas relativas contra el cwd del shell. +fn resolve_write_path(path: &str, s: &State) -> std::path::PathBuf { + if let Some(rest) = path.strip_prefix("~/") { + if let Ok(home) = std::env::var("HOME") { + return std::path::PathBuf::from(home).join(rest); + } + } + let p = std::path::PathBuf::from(path); + if p.is_absolute() { + p + } else { + s.cwd.join(p) + } +} + +/// `:write [%cN] ` — vuelca el stdout de un bloque a un archivo +/// (el flujo "lo corrí, ahora guárdalo"). Sin ref usa el último bloque con +/// salida. Es complemento de `%cN` (E2): la scrollback como fuente de datos +/// también se persiste. No corre shell — `std::fs::write` directo. +pub(crate) fn apply_write(mut s: State, rest: &str) -> State { + let rest = rest.trim(); + if rest.is_empty() { + s.push_output(OutputLine::notice( + "uso: :write [%cN] — vuelca el stdout de un bloque a un archivo", + )); + return s; + } + let mut parts = rest.splitn(2, char::is_whitespace); + let first = parts.next().unwrap_or(""); + // Si el primer token es una ref `%cN`/`%pN`, ese es el bloque y el resto el + // archivo; si no, no hay ref → último bloque con stdout y todo es el path. + let first_is_ref = first.starts_with("%c") || first.starts_with("%p"); + let (target_ref, path) = if first_is_ref { + (parse_block_and_stage(&s, first), parts.next().unwrap_or("").trim().to_string()) + } else { + (parse_block_and_stage(&s, ""), rest.to_string()) + }; + let Some((block, stage)) = target_ref else { + s.push_output(OutputLine::notice( + "✘ :write — no hay salida de un bloque previo para volcar", + )); + return s; + }; + if path.is_empty() { + s.push_output(OutputLine::notice("✘ :write — falta el archivo destino")); + return s; + } + let label = target_label(block, stage); + let data = gather_target_text(&s, block, stage); + if data.is_empty() { + s.push_output(OutputLine::notice(format!( + "✘ :write — {label} no tiene salida" + ))); + return s; + } + let target = resolve_write_path(&path, &s); + match std::fs::write(&target, data.as_bytes()) { + Ok(()) => s.push_output(OutputLine::notice(format!( + "✔ {label}: {} bytes → {}", + data.len(), + target.display() + ))), + Err(e) => s.push_output(OutputLine::notice(format!( + "✘ :write {} — {e}", + target.display() + ))), + } + s +} + +/// `:yank [%cN]` (alias `:copy`) — copia el stdout de un bloque al clipboard +/// del SO (el par de `:write`, pero a portapapeles). Sin ref usa el último +/// bloque con salida. Reusa `set_clipboard` (best-effort: no-op silencioso sin +/// display server, igual que el copy de la selección). +pub(crate) fn apply_yank(mut s: State, rest: &str) -> State { + let first = rest.trim().split_whitespace().next().unwrap_or(""); + let target_ref = if first.starts_with("%c") || first.starts_with("%p") { + parse_block_and_stage(&s, first) + } else { + parse_block_and_stage(&s, "") + }; + let Some((block, stage)) = target_ref else { + s.push_output(OutputLine::notice( + "✘ :yank — no hay salida de un bloque previo para copiar", + )); + return s; + }; + let label = target_label(block, stage); + let data = gather_target_text(&s, block, stage); + if data.is_empty() { + s.push_output(OutputLine::notice(format!( + "✘ :yank — {label} no tiene salida" + ))); + return s; + } + set_clipboard(&data); + s.push_output(OutputLine::notice(format!( + "✔ {label}: {} bytes ({} líneas) → clipboard", + data.len(), + data.lines().count() + ))); + s +} + +/// `:diff %cN %cM` — compara el stdout de dos bloques y vuelca el diff por +/// líneas (sólo los cambios: `-` quitada, `+` agregada) + un resumen. Útil +/// para "¿qué cambió entre estas dos corridas?". Usa `similar::TextDiff` +/// (Myers). Capado a [`DIFF_VISIBLE_CAP`] líneas visibles. +pub(crate) fn apply_diff(mut s: State, rest: &str) -> State { + let refs: Vec<&str> = rest.split_whitespace().collect(); + if refs.len() < 2 { + s.push_output(OutputLine::notice( + "uso: :diff %cN %cM — compara el stdout de dos bloques", + )); + return s; + } + let (Some(a), Some(b)) = (parse_block_ref(&s, refs[0]), parse_block_ref(&s, refs[1])) else { + s.push_output(OutputLine::notice( + "✘ :diff — refs inválidas (esperado `%cN %cM`)", + )); + return s; + }; + let ta = gather_block_stdout(&s, a); + let tb = gather_block_stdout(&s, b); + if ta.is_empty() && tb.is_empty() { + s.push_output(OutputLine::notice( + "✘ :diff — ninguno de los bloques tiene stdout", + )); + return s; + } + s.push_output(OutputLine::notice(format!("≡ diff %c{a} → %c{b}"))); + const DIFF_VISIBLE_CAP: usize = 200; + const CONTEXT: usize = 3; + let diff = similar::TextDiff::from_lines(&ta, &tb); + // Totales (sobre todos los cambios) para el resumen. + let mut adds = 0usize; + let mut dels = 0usize; + for c in diff.iter_all_changes() { + match c.tag() { + similar::ChangeTag::Insert => adds += 1, + similar::ChangeTag::Delete => dels += 1, + similar::ChangeTag::Equal => {} + } + } + if adds == 0 && dels == 0 { + s.push_output(OutputLine::notice("✔ idénticos")); + return s; + } + // Hunks con `CONTEXT` líneas de contexto alrededor de cada cambio: ubica + // qué cambió sin volcar el archivo entero. `⋮` separa hunks no contiguos. + let mut shown = 0usize; + let mut capped = false; + for (hi, group) in diff.grouped_ops(CONTEXT).iter().enumerate() { + if shown >= DIFF_VISIBLE_CAP { + capped = true; + break; + } + if hi > 0 { + s.push_output(OutputLine::notice(" ⋮")); + } + for op in group { + for change in diff.iter_changes(op) { + if shown >= DIFF_VISIBLE_CAP { + capped = true; + break; + } + let sign = match change.tag() { + similar::ChangeTag::Delete => '-', + similar::ChangeTag::Insert => '+', + similar::ChangeTag::Equal => ' ', + }; + let text = change.value().trim_end_matches('\n'); + s.push_output(OutputLine::notice(format!("{sign} {text}"))); + shown += 1; + } + } + } + if capped { + s.push_output(OutputLine::notice(format!( + " … (cap a {DIFF_VISIBLE_CAP} líneas visibles)" + ))); + } + s.push_output(OutputLine::notice(format!( + "✔ {adds}+ / {dels}- ({} cambios)", + adds + dels + ))); + s +} + +/// Una fila del cotejo con los textos ya resueltos — lo que `:compara` pinta. +#[derive(Debug, Clone, PartialEq)] +pub(crate) struct CotejoRow { + pub clase: pluma_cotejo::ClaseCambio, + pub similitud: f32, + pub izq: Option, + pub der: Option, +} + +/// Corre el **cotejo de pluma** sobre dos listas de líneas (un átomo por línea) +/// y devuelve `(conteos, filas)` con los textos resueltos. Puro: construye dos +/// `Cuerpo` efímeros, los alinea con `pluma_cotejo::cotejar` (Needleman–Wunsch +/// sobre similitud léxica) y aplana las secciones. Testeable sin UI ni disco. +pub(crate) fn cotejo_rows(izq: &[String], der: &[String]) -> (pluma_cotejo::Conteos, Vec) { + use pluma_core::NarrativeAtom; + use pluma_cuerpo::{Cuerpo, Intencion}; + let mk = |textos: &[String], branch: &str| -> (Cuerpo, Vec) { + let mut c = Cuerpo::nuevo(branch, branch, Intencion::Original, 0); + let atoms: Vec = + textos.iter().map(|t| NarrativeAtom::new(t.clone(), branch)).collect(); + for a in &atoms { + c.agregar(a.id, 0); + } + (c, atoms) + }; + let (ci, ai) = mk(izq, "izq"); + let (cd, ad) = mk(der, "der"); + let mut idx: pluma_cotejo::IndiceAtoms = pluma_cotejo::IndiceAtoms::new(); + for a in ai.iter().chain(ad.iter()) { + idx.insert(a.id, a); + } + let cot = pluma_cotejo::cotejar(&ci, &cd, &idx, &pluma_cotejo::ParamsCotejo::default(), 0); + let texto = |id: &uuid::Uuid| idx.get(id).map(|a| a.content.as_str().to_string()); + let rows = cot + .secciones + .iter() + .map(|sec| CotejoRow { + clase: sec.clase, + similitud: sec.similitud, + izq: sec.izq.as_ref().and_then(|id| texto(id)), + der: sec.der.as_ref().and_then(|id| texto(id)), + }) + .collect(); + (cot.conteos(), rows) +} + +/// Pad/trunca un texto a `w` columnas (asume monospace; cuenta chars). Trunca +/// con `…`. Para la columna izquierda del side-by-side de `:compara`. +fn pad_cell(text: &str, w: usize) -> String { + let one = text.replace('\t', " "); + let n = one.chars().count(); + if n > w { + let mut t: String = one.chars().take(w.saturating_sub(1)).collect(); + t.push('…'); + t + } else { + format!("{one}{}", " ".repeat(w - n)) + } +} + +/// `:compara %cN %cM` (`:cotejar`/`:vs`) — compara la salida de dos bloques con +/// el **cotejo de pluma**: alineación párrafo-a-párrafo por similitud léxica +/// (Needleman–Wunsch), no un diff de líneas exacto. Empareja líneas parecidas +/// aunque difieran en wording/orden y clasifica cada sección — idéntica (≡), +/// similar (≈), divergente (✗), agregada (+), eliminada (-). La respuesta abre +/// su propio bloque con un side-by-side `izquierda │ derecha`. Acepta refs con +/// etapa del tee (`%cN.K`). +pub(crate) fn apply_compare(mut s: State, rest: &str) -> State { + let refs: Vec<&str> = rest.split_whitespace().collect(); + if refs.len() < 2 { + s.push_output(OutputLine::notice( + "uso: :compara %cN %cM — coteja la salida de dos bloques (estilo pluma)", + )); + return s; + } + let (Some((ba, sa)), Some((bb, sb))) = + (parse_block_and_stage(&s, refs[0]), parse_block_and_stage(&s, refs[1])) + else { + s.push_output(OutputLine::notice( + "✘ :compara — refs inválidas (esperado `%cN %cM`)", + )); + return s; + }; + let (la, lb) = (target_label(ba, sa), target_label(bb, sb)); + let lineas = |t: String| -> Vec { + t.lines().filter(|l| !l.trim().is_empty()).map(|l| l.to_string()).collect() + }; + let li = lineas(gather_target_text(&s, ba, sa)); + let ld = lineas(gather_target_text(&s, bb, sb)); + if li.is_empty() && ld.is_empty() { + s.push_output(OutputLine::notice( + "✘ :compara — ninguno de los bloques tiene salida", + )); + return s; + } + let (conteos, rows) = cotejo_rows(&li, &ld); + + // Bloque propio para el cotejo (referenciable, re-filtrable). + s.push_output(OutputLine::prompt(format!("≡ :compara {la} ↔ {lb}"))); + s.push_output(OutputLine::notice(format!( + "{} idénticas · {} similares · {} divergentes · {} agregadas · {} eliminadas", + conteos.identicas, + conteos.similares, + conteos.divergentes, + conteos.agregadas, + conteos.eliminadas + ))); + // Ancho de la columna izquierda: el de la línea izq más larga, capado. + const COL_CAP: usize = 56; + let wl = rows + .iter() + .filter_map(|r| r.izq.as_ref().map(|t| t.chars().count())) + .max() + .unwrap_or(0) + .min(COL_CAP); + // Glifos single-width (BMP) para no romper la alineación monospace de las + // columnas; el prefijo «glifo + %» ocupa 8 columnas fijas. + use pluma_cotejo::ClaseCambio as K; + for r in &rows { + let izq = r.izq.as_deref().unwrap_or(""); + let der = r.der.as_deref().unwrap_or(""); + let pct = (r.similitud * 100.0).round() as i32; + let line = match r.clase { + // Idéntica: una sola columna (el texto es igual a ambos lados). + K::Identica => format!("{:<8}{izq}", "≡"), + K::Similar => format!("≈ {pct:>3}% {} │ {der}", pad_cell(izq, wl)), + K::Divergente => format!("✗ {pct:>3}% {} │ {der}", pad_cell(izq, wl)), + K::Agregada => format!("{:<8}{} │ {der}", "+", pad_cell("", wl)), + K::Eliminada => format!("{:<8}{} │", "-", pad_cell(izq, wl)), + }; + // Eliminadas en rojo (señal de "se fue"); el resto stdout (glifo+% guían). + if r.clase == K::Eliminada { + s.push_output(OutputLine::stderr(line)); + } else { + s.push_output(OutputLine::stdout(line)); + } + } + s +} + +pub(crate) fn gather_block_stdout(s: &State, block: u64) -> String { + let mut out = String::new(); + for l in &s.output { + if l.block == block && l.kind == OutputKind::Stdout && l.stage.is_none() { + out.push_str(&l.text); + out.push('\n'); + } + } + out +} + +/// Texto **completo** de un bloque para redirigir/analizar: stdout + stderr + +/// respuestas de IA (`OutputKind::Ai`), en orden del buffer, sin Prompt/notice/ +/// etapas. Es lo que consumen `:write`/`:yank`/`:explica`/`:resume`/`:filtra` — +/// así una respuesta de IA o un volcado de errores también se guardan y se +/// vuelven a filtrar. El pipeline crudo (`%cN` inject, `:diff`) sigue usando +/// [`gather_block_stdout`] (sólo stdout) para no contaminar los datos. +pub(crate) fn gather_block_text(s: &State, block: u64) -> String { + let mut out = String::new(); + for l in &s.output { + if l.block == block + && l.stage.is_none() + && matches!(l.kind, OutputKind::Stdout | OutputKind::Stderr | OutputKind::Ai) + { + out.push_str(&l.text); + out.push('\n'); + } + } + out +} + +/// Índice de grupo (0-based) para F1..F8; `None` para cualquier otra tecla. +pub(crate) fn fkey_index(key: &Key) -> Option { + match key { + Key::Named(NamedKey::F1) => Some(0), + Key::Named(NamedKey::F2) => Some(1), + Key::Named(NamedKey::F3) => Some(2), + Key::Named(NamedKey::F4) => Some(3), + Key::Named(NamedKey::F5) => Some(4), + Key::Named(NamedKey::F6) => Some(5), + Key::Named(NamedKey::F7) => Some(6), + Key::Named(NamedKey::F8) => Some(7), + _ => None, + } +} + +/// Ejecuta el grupo de índice `idx` (0-based) como una sola línea +/// (`l1 && l2 && …`). No-op si no existe ese grupo. +pub(crate) fn run_group(s: State, idx: usize) -> State { + let Some(joined) = s + .groups + .get(idx) + .map(|g| g.lines.join(" && ")) + .filter(|j| !j.is_empty()) + else { + return s; + }; + let mut s = s; + s.input.set_text(joined); + run_submitted(s) +} + +#[cfg(test)] +mod e1_macro_tests { + use super::*; + + #[test] + fn sustituye_huecos_posicionales() { + assert_eq!( + substitute_macro_params("deploy %1 to %2", &["app", "prod"]), + "deploy app to prod" + ); + // %* = todos los args. + assert_eq!( + substitute_macro_params("run %*", &["a", "b", "c"]), + "run a b c" + ); + // Hueco sin argumento → vacío. + assert_eq!(substitute_macro_params("x %1 %2", &["uno"]), "x uno "); + // `%` literal (sin dígito válido detrás) se conserva. + assert_eq!(substitute_macro_params("50%% done", &[]), "50%% done"); + assert_eq!(substitute_macro_params("%0 no es hueco", &["z"]), "%0 no es hueco"); + } + + #[test] + fn instancia_macro_multipaso() { + let m = shuma_intent::Macro::new("deploy") + .step("cargo build --release --bin %1") + .step("scp target/release/%1 %2:/srv"); + let line = instantiate_macro(&m, &["app", "host"]); + assert_eq!( + line, + "cargo build --release --bin app && scp target/release/app host:/srv" + ); + } + + #[test] + fn run_de_macro_inexistente_avisa_y_no_corre() { + let mut s = State::new(shuma_module::Source::Local); + s = apply_macro(s, "run no_existe foo"); + assert!(s.output.iter().any(|l| l.text.contains("no existe"))); + assert!(!s.is_running()); + } +} + +#[cfg(test)] +mod e6_stats_tests { + use super::*; + use shuma_history::Entry; + + /// Entry con exit + duración (el `finalize` real las setea aparte). + fn ent(line: &str, started: u64, exit: i32, dur: u64) -> Entry { + let mut e = Entry::new(line, "/tmp", started); + e.exit = Some(exit); + e.duration_ms = Some(dur); + e + } + + #[test] + fn agrega_por_binario_con_fallos_y_percentiles() { + let entries = vec![ + ent("cargo build", 100, 0, 1000), + ent("cargo test", 200, 1, 3000), + ent("cargo build", 300, 0, 2000), + ent("git status", 400, 0, 50), + // builtin: se ignora. + Entry::new(":stats", "/tmp", 500), + ]; + let out = compute_stats(&entries, "", 1_000).expect("hay binarios"); + // Resumen + header + 2 filas (cargo, git). + assert!(out[0].contains("5 comandos en historial")); + assert!(out[0].contains("2 binarios distintos")); + assert_eq!(out[1], "comando\tveces\tfallos\t%fallo\tp50ms\tp95ms\túltimo"); + // cargo va primero (3 usos > 1 de git). + let cargo = &out[2]; + let cols: Vec<&str> = cargo.split('\t').collect(); + assert_eq!(cols[0], "cargo"); + assert_eq!(cols[1], "3"); // veces + assert_eq!(cols[2], "1"); // fallos + assert_eq!(cols[3], "33"); // %fallo (1/3) + } + + #[test] + fn filtro_restringe_a_binarios_que_contienen() { + let entries = vec![ent("cargo build", 100, 0, 10), ent("git log", 200, 0, 10)]; + let out = compute_stats(&entries, "car", 1_000).expect("matchea cargo"); + // Sólo cargo: resumen + header + 1 fila. + assert_eq!(out.len(), 3); + assert!(out[2].starts_with("cargo\t")); + // Filtro que no matchea nada → None. + assert!(compute_stats(&entries, "zzz", 1_000).is_none()); + } + + #[test] + fn humaniza_hace_en_tramos() { + assert_eq!(humanizar_hace(0), "ahora"); + assert_eq!(humanizar_hace(59), "ahora"); + assert_eq!(humanizar_hace(120), "2m"); + assert_eq!(humanizar_hace(7200), "2h"); + assert_eq!(humanizar_hace(172_800), "2d"); + } +} diff --git a/02_ruway/shuma/sandbox/shuma-module-shell/src/update/clipboard.rs b/02_ruway/shuma/sandbox/shuma-module-shell/src/update/clipboard.rs new file mode 100644 index 0000000..6ba19f8 --- /dev/null +++ b/02_ruway/shuma/sandbox/shuma-module-shell/src/update/clipboard.rs @@ -0,0 +1,179 @@ +use super::*; +use crate::view::{VIM_CHAR_W, VIM_LINE_H, vim_px_to_cell}; + +/// Lee el clipboard del SO (vía `arboard`). Devuelve `None` si no hay +/// display server, está vacío, o el contenido no es texto. No cachea — +/// el sistema tiene su propio TTL. +pub(crate) fn read_clipboard() -> Option { + let mut clip = arboard::Clipboard::new().ok()?; + clip.get_text().ok() +} + +/// Limpia texto pegado al editor de línea. A diferencia del shell GPUI +/// (que colapsaba todo a una línea unida por `; `), este input es +/// **multilínea** —editar construcciones abiertas, pegar scripts—, así que +/// los saltos se **preservan**. Lo que sí hacemos: +/// +/// - normalizar `\r\n` y `\r` a `\n` (pastes de Windows / terminales), +/// - tab → espacio (el line editor no tabula columnas), +/// - descartar caracteres de control peligrosos (ESC, BEL, …) que un paste +/// de terminal puede arrastrar y que corromperían el render del input, +/// - recortar **un** salto final, para que pegar `"ls -la\n"` no deje una +/// línea vacía colgando bajo el comando. +pub(crate) fn sanitize_paste(s: &str) -> String { + let normalized = s.replace("\r\n", "\n").replace('\r', "\n"); + let cleaned: String = normalized + .chars() + .map(|c| if c == '\t' { ' ' } else { c }) + .filter(|c| *c == '\n' || !c.is_control()) + .collect(); + cleaned + .strip_suffix('\n') + .map(str::to_string) + .unwrap_or(cleaned) +} + +/// Escribe texto al clipboard del SO. No-op silencioso sin display server. +pub(crate) fn set_clipboard(text: &str) { + if let Ok(mut clip) = arboard::Clipboard::new() { + let _ = clip.set_text(text.to_string()); + } +} + +/// El **cuasi-clipboard PRIMARY**: el buffer que se llena solo al seleccionar +/// (copy-on-select) y que pega el **botón medio** — la selección PRIMARY estilo +/// X11/Wayland, separada del portapapeles principal (Ctrl+C/V). Fuente de verdad +/// interna al proceso, para que funcione siempre dentro de shuma aunque el +/// compositor no exponga `zwp_primary_selection`. +fn primary_buf() -> &'static std::sync::Mutex { + static P: std::sync::OnceLock> = std::sync::OnceLock::new(); + P.get_or_init(|| std::sync::Mutex::new(String::new())) +} + +/// Deja `text` en el cuasi-clipboard PRIMARY. Actualiza el buffer interno +/// (fuente de verdad) y **espeja** a la selección PRIMARY del sistema +/// (best-effort vía `arboard`), así el botón medio también pega en otras apps +/// el día que mirada exponga primary-selection. Si el espejo falla, el buffer +/// interno queda igual — la pega dentro de shuma nunca depende del sistema. +pub(crate) fn set_primary(text: &str) { + if let Ok(mut g) = primary_buf().lock() { + *g = text.to_string(); + } + #[cfg(target_os = "linux")] + { + use arboard::SetExtLinux; + if let Ok(mut clip) = arboard::Clipboard::new() { + let _ = clip + .set() + .clipboard(arboard::LinuxClipboardKind::Primary) + .text(text.to_string()); + } + } +} + +/// Lee el cuasi-clipboard PRIMARY para pegarlo (botón medio). Prefiere la +/// selección PRIMARY del sistema si trae algo —así el botón medio pega lo +/// último seleccionado en OTRA app (Brave, etc.)—, y cae al buffer interno de +/// shuma si el sistema no la expone o está vacía. +pub(crate) fn get_primary() -> String { + #[cfg(target_os = "linux")] + { + use arboard::GetExtLinux; + if let Ok(mut clip) = arboard::Clipboard::new() { + if let Ok(t) = clip + .get() + .clipboard(arboard::LinuxClipboardKind::Primary) + .text() + { + if !t.is_empty() { + return t; + } + } + } + } + primary_buf().lock().map(|g| g.clone()).unwrap_or_default() +} + +/// Extrae el texto de la selección del card de vim sobre el screen +/// actual del PTY y lo copia al clipboard. Selección lineal por filas +/// (estilo terminal), cada fila recortada de espacios al final. +pub(crate) fn copy_vim_selection(s: &State) { + let Some(vs) = s.vim_sel else { return }; + let Some(arc) = s.running.as_ref() else { + return; + }; + let guard = match arc.lock() { + Ok(g) => g, + Err(p) => p.into_inner(), + }; + let Some(tui) = guard.tui.as_ref() else { + return; + }; + let screen = tui.parser.screen(); + let (rows, cols) = screen.size(); + let mut grid: Vec> = Vec::with_capacity(rows as usize); + for r in 0..rows { + let mut line: Vec = Vec::with_capacity(cols as usize); + for c in 0..cols { + let ch = match screen.cell(r, c) { + Some(cell) if cell.has_contents() => cell.contents().chars().next().unwrap_or(' '), + _ => ' ', + }; + line.push(ch); + } + grid.push(line); + } + let (cw, lh) = match s.vim_metrics.lock() { + Ok(g) if g.0 > 1.0 && g.1 > 1.0 => (g.0 as f64, g.1 as f64), + _ => (VIM_CHAR_W, VIM_LINE_H), + }; + let (r0, c0) = vim_px_to_cell(vs.ax as f64, vs.ay as f64, cw, lh); + let (r1, c1) = vim_px_to_cell(vs.hx as f64, vs.hy as f64, cw, lh); + let (sr, sc, er, ec) = if (r0, c0) <= (r1, c1) { + (r0, c0, r1, c1) + } else { + (r1, c1, r0, c0) + }; + if sr >= grid.len() { + return; + } + let er = er.min(grid.len() - 1); + let mut out = String::new(); + for r in sr..=er { + let line = &grid[r]; + let lo = if r == sr { sc.min(line.len()) } else { 0 }; + let hi = if r == er { + (ec + 1).min(line.len()) + } else { + line.len() + }; + if hi > lo { + let seg: String = line[lo..hi].iter().collect(); + out.push_str(seg.trim_end()); + } + if r != er { + out.push('\n'); + } + } + if !out.trim().is_empty() { + set_clipboard(&out); + } +} + +/// El portapapeles del SO como [`Clipboard`] para el motor de edición +/// compartido. Con él, Ctrl+C/X/V dentro del input los resuelve el propio +/// motor (sobre la selección real, con undo) en vez de un camino aparte de +/// shuma que tenía que replicar la misma lógica. +pub(crate) struct ClipboardSistema; + +impl llimphi_widget_text_input::Clipboard for ClipboardSistema { + fn get(&mut self) -> Option { + // Lo pegado se sanea igual que siempre: `\r\n` → `\n`, tabs a espacio y + // fuera los controles que corromperían el render del input. + read_clipboard().as_deref().map(sanitize_paste) + } + + fn set(&mut self, s: &str) { + set_clipboard(s); + } +} diff --git a/02_ruway/shuma/sandbox/shuma-module-shell/src/update/completion.rs b/02_ruway/shuma/sandbox/shuma-module-shell/src/update/completion.rs new file mode 100644 index 0000000..a2b34d9 --- /dev/null +++ b/02_ruway/shuma/sandbox/shuma-module-shell/src/update/completion.rs @@ -0,0 +1,525 @@ +use super::*; + +use crate::types::{SugKind, Suggestion}; + +/// Cuántas líneas completas del historial (tier 2) ofrecer como máximo. +const MAX_LINE_SUGGESTIONS: usize = 5; +/// Cuántos grupos / coreografías (tier 3) ofrecer como máximo. +const MAX_GROUP_SUGGESTIONS: usize = 4; +// `LINE_SUGGEST_WINDOW` (2.000 entradas crudas) se retiró el 2026-07-25: las +// líneas completas salen del corpus deduplicado de [`super::corpus`], que cubre +// todo el historial. El costo por keystroke lo acota el corpus, no una ventana. + +/// Total de filas navegables del popup (tier 0 apps + tier 1 tokens + tiers 2/3). +pub(crate) fn completion_total(s: &State) -> usize { + s.completion.as_ref().map(|c| c.candidates.len()).unwrap_or(0) + s.completion_extra.len() +} + +/// Una fila del popup en el **orden global** de navegación, apuntando a su +/// origen (un candidato de token o una sugerencia de `completion_extra`). +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum CompRow { + /// Índice dentro de `completion.candidates` (tier 1). + Token(usize), + /// Índice dentro de `completion_extra` (tier 0 apps / 2 líneas / 3 grupos). + Extra(usize), +} + +/// Filas del popup en el orden en que se pintan y navegan: **apps primero** +/// (tier 0, con su ícono), luego los candidatos de token (tier 1), y al final +/// las líneas/grupos del historial (tiers 2/3). `completion_index` indexa sobre +/// esta lista; todos los consumidores (aceptar, resaltar, pintar) pasan por aquí +/// para no desalinearse. +pub fn completion_rows(s: &State) -> Vec { + let mut rows: Vec = Vec::new(); + // Tier 0 — apps (con ícono), arriba de todo. + for (i, sug) in s.completion_extra.iter().enumerate() { + if sug.kind == SugKind::App { + rows.push(CompRow::Extra(i)); + } + } + // Tier 1 — candidatos de token (binarios del PATH, paths, flags). + if let Some(c) = &s.completion { + for i in 0..c.candidates.len() { + rows.push(CompRow::Token(i)); + } + } + // Tiers 2/3 — líneas completas y grupos del historial. + for (i, sug) in s.completion_extra.iter().enumerate() { + if sug.kind != SugKind::App { + rows.push(CompRow::Extra(i)); + } + } + rows +} + +/// El texto que quedaría en la línea si se aceptara la fila `row` (cursor al +/// final): para un token, el prefijo + candidato; para una línea/grupo/app, su +/// `insert` sobre el rango propio. Se usa para alinear la preselección del +/// popup con el ghost visible. +fn row_resultado(s: &State, row: CompRow) -> Option { + let texto_input = s.input.text(); + let text = texto_input.as_str(); + match row { + CompRow::Token(i) => { + let comp = s.completion.as_ref()?; + let cand = comp.candidates.get(i)?; + let rs = comp.replace_start.min(text.len()); + let re = comp.replace_end.min(text.len()); + Some(format!("{}{}{}", &text[..rs], cand, &text[re..])) + } + CompRow::Extra(i) => { + let sug = s.completion_extra.get(i)?; + let rs = sug.replace_start.min(text.len()); + let re = sug.replace_end.min(text.len()); + Some(format!("{}{}{}", &text[..rs], sug.insert, &text[re..])) + } + } +} + +/// Índice global de la fila preseleccionada por default: la que coincide con el +/// ghost inline (misma continuación predicha por historial + cwd). `0` si no hay +/// ghost o ninguna fila lo iguala. No marca `completion_navegado` — sigue siendo +/// sólo el default sugerido, no una elección deliberada. +fn default_completion_index(s: &State) -> usize { + let texto_input = s.input.text(); + let text = texto_input.as_str(); + if s.input.cursor() != text.len() { + return 0; + } + let Some(suffix) = current_ghost(s) else { + return 0; + }; + if suffix.is_empty() { + return 0; + } + let objetivo = format!("{text}{suffix}"); + completion_rows(s) + .into_iter() + .position(|row| row_resultado(s, row).as_deref() == Some(objetivo.as_str())) + .unwrap_or(0) +} + +/// Aplica un Tab: +/// - popup abierto: cicla al siguiente candidato (no toca el texto, así el +/// rango de reemplazo del `Completion` guardado sigue válido). +/// - popup cerrado: lo abre con el completado **en capas** (tokens + +/// líneas completas + grupos). Si hay exactamente un candidato, lo inserta +/// directo; con ≥2, abre el popup con el primero resaltado. +pub(crate) fn apply_completion_msg(mut s: State) -> State { + if s.completion.is_some() { + return cycle_completion(s, 1); + } + // Tab fuerza la apertura del popup en capas aunque no haya token a + // completar (para que las líneas/grupos aparezcan a pedido). + let abrio = populate_completion(&mut s, true); + // Un ÚNICO candidato (token/app/línea) es inequívoco: se aplica directo. + if abrio && completion_total(&s) == 1 { + return accept_completion(s); + } + // Con varios candidatos (o ninguno) pero UNA sugerencia inline visible (el + // "ghost": la continuación predicha por historial/coreografía que ya se ve + // tenue tras el cursor), Tab acepta **lo que se ve** y cierra el menú. Era + // la mitad que faltaba: antes Tab abría un menú de rutas/binarios que no era + // lo mostrado, o quedaba en un Tab muerto ("sale un comando para completar + // pero Tab no lo autocompleta"). → / End siguen aceptando el ghost también. + if s.input.cursor() == s.input.text().len() { + if let Some(suffix) = current_ghost(&s) { + if !suffix.is_empty() { + s.input.insert(&suffix); + close_completion(&mut s); + return s; + } + } + } + // Sin ghost pero con ≥2 candidatos, el menú queda abierto para navegarlo. + s +} + +/// Cierra el popup de completado sin aplicar nada. +pub(crate) fn close_completion(s: &mut State) { + s.completion = None; + s.completion_extra.clear(); + s.completion_index = 0; + s.completion_navegado = false; +} + +/// Refresca el popup de completado **en vivo** (as-you-type): lo abre cuando +/// hay un prefijo de token a completar, anexando bajo los candidatos las +/// líneas completas del historial y los grupos que extienden lo tipeado. No +/// fuerza la apertura por líneas/grupos solos — eso es a pedido (Tab). +pub(crate) fn refresh_completion(s: &mut State) { + populate_completion(s, false); +} + +/// Núcleo compartido por `refresh_completion` (as-you-type) y +/// `apply_completion_msg` (Tab). Calcula el tier 1 (tokens) y, si abre, los +/// tiers 2/3 (líneas/grupos). `force` abre el popup aunque el tier 1 esté +/// vacío (Tab). Devuelve `true` si quedó un popup abierto. +fn populate_completion(s: &mut State, force: bool) -> bool { + // MODO CONSOLA (PTY inline vivo, canvas a la vista, sin alt-screen): el + // completado normal (comandos/apps/grupos) no aplica — lo tipeado va al + // programa. Sugerimos el HISTORIAL de esta sesión de consola (líneas ya + // enviadas que extienden lo tipeado). + // Espejos sin lock (`tui_skin_vivo`/`tui_altscreen_vivo`): el try_lock + // de is_tui_active/is_tui_fullscreen perdía bajo streaming y este gate + // caía al completado NORMAL (apps/comandos) en pleno modo consola — el + // "autocomplete fantasma" que hacía retemblar los paneles. + if s.tui_skin_vivo.is_some() && s.canvas_visible && !s.tui_altscreen_vivo { + let texto = s.input.text().to_string(); + if texto.is_empty() { + close_completion(s); + return false; + } + let span = (0usize, texto.len()); + let sugerencias: Vec = s + .consola_historial + .iter() + .rev() + .filter(|l| l.starts_with(&texto) && l.as_str() != texto) + .take(6) + .map(|l| Suggestion { + display: l.clone(), + insert: l.clone(), + replace_start: span.0, + replace_end: span.1, + kind: SugKind::Line, + icon: None, + }) + .collect(); + if sugerencias.is_empty() { + close_completion(s); + return false; + } + s.completion = Some(shuma_line::Completion { + kind: shuma_line::CompletionKind::Command, + candidates: Vec::new(), + replace_start: span.0, + replace_end: span.1, + }); + s.completion_extra = sugerencias; + s.completion_index = 0; + s.completion_navegado = false; + return true; + } + let mut comp = s.input.complete(s.completion_source.as_ref()); + let has_token = !comp.candidates.is_empty() && comp.replace_end > comp.replace_start; + if has_token { + rank_completion_by_usage(s, &mut comp); + } else { + comp.candidates.clear(); + } + // Tier 0 — apps lanzables que matchean lo tipeado (con su ícono). Abren el + // popup por sí solas as-you-type: escribir "plu" muestra Pluma con su ícono + // aunque no haya un binario del PATH que complete. Es el "modo búsqueda" de + // apps del escritorio. + let mut extra = build_app_suggestions(s); + // El tier 1 (o las apps) manda la apertura as-you-type; con `force` (Tab) + // basta que haya algo en cualquier tier. + if has_token || force || !extra.is_empty() { + extra.extend(build_extra_suggestions(s)); + } + if comp.candidates.is_empty() && extra.is_empty() { + close_completion(s); + return false; + } + s.completion = Some(comp); + s.completion_extra = extra; + // Default preseleccionado: la fila que COINCIDE con el ghost (la + // continuación predicha por historial + cwd que ya se ve inline). Así "lo + // que se ve más cerca" —un comando frecuente o una app— es lo que queda + // marcado por default, en vez de forzar siempre la primera app del tier 0. + // Si no hay ghost o ninguna fila lo iguala, cae al 0 de siempre. + s.completion_index = default_completion_index(s); + // Rebuild en vivo (as-you-type): el resaltado vuelve al default — ya no + // hay una elección deliberada vigente. + s.completion_navegado = false; + true +} + +/// Cuántos candidatos-app ofrecer como máximo (tier 0). +const MAX_APP_SUGGESTIONS: usize = 6; + +/// Tier 0 del completado: apps lanzables cuyo nombre (o binario) **empieza con** +/// lo tipeado. Sólo cuando el texto es una sola palabra (lanzar una app = primer +/// token) y el cursor está al final — igual espíritu que el launcher sin +/// prefijo, pero mostrando candidatos con ícono en vez de exigir match exacto. +pub(crate) fn build_app_suggestions(s: &State) -> Vec { + let texto_input = s.input.text(); + let text = texto_input.as_str(); + let t = text.trim(); + if t.is_empty() + || t.chars().any(char::is_whitespace) + || s.input.cursor() != text.len() + || s.apps.is_empty() + { + return Vec::new(); + } + let q = t.to_lowercase(); + let span = (0usize, text.len()); + let mut out: Vec = Vec::new(); + let mut seen: std::collections::HashSet = std::collections::HashSet::new(); + for app in &s.apps { + if out.len() >= MAX_APP_SUGGESTIONS { + break; + } + // Nombre "limpio": sin glifos/decoración no-alfanumérica al frente. + let name_clean = app + .nombre + .trim_start_matches(|c: char| !c.is_alphanumeric()) + .trim(); + let name_l = name_clean.to_lowercase(); + let bin = app + .comando + .split_whitespace() + .next() + .and_then(|p| p.rsplit('/').next()) + .unwrap_or("") + .to_lowercase(); + let matches = name_l.starts_with(&q) + || bin.starts_with(&q) + || name_l.split_whitespace().any(|w| w.starts_with(&q)); + if matches && seen.insert(app.comando.clone()) { + out.push(Suggestion { + // El nombre limpio (sin el glifo viejo): el host pinta el ícono. + display: if name_clean.is_empty() { + app.nombre.clone() + } else { + name_clean.to_string() + }, + insert: app.comando.clone(), + replace_start: span.0, + replace_end: span.1, + kind: SugKind::App, + icon: app.icon.clone(), + }); + } + } + out +} + +/// `true` si la fila resaltada del popup es un candidato-app (aceptar = lanzar, +/// no ejecutar el texto tal cual). Lo usa el manejador de Enter. +pub(crate) fn highlighted_is_app(s: &State) -> bool { + matches!( + completion_rows(s).get(s.completion_index), + Some(CompRow::Extra(i)) if s.completion_extra.get(*i).is_some_and(|sug| sug.kind == SugKind::App) + ) +} + +/// `true` si Enter debe **aceptar** el resaltado del popup en vez de ejecutar lo +/// tipeado. Cierto cuando: +/// · el usuario ya navegó (elección deliberada por flecha/Tab/click), o +/// · el resaltado es una app cuyo nombre/binario **extiende** lo tipeado — o +/// sea, todavía estás completando ("nah" → «nahual»), no cuando ya escribiste +/// el comando entero ("vim" no secuestra al binario `vim` con su .desktop). +/// +/// Sin esto, escribir "nah" resaltaba «nahual» pero Enter ejecutaba "nah" tal +/// cual y había que forzar la aceptación con las flechas — el "paso extra" +/// reportado. Ahora "está marcado ⟺ Enter lo aplica" también para el default. +pub(crate) fn enter_acepta_completion(s: &State) -> bool { + if s.completion.is_none() { + return false; + } + if s.completion_navegado { + return true; + } + if let Some(CompRow::Extra(i)) = completion_rows(s).get(s.completion_index).copied() { + if let Some(sug) = s.completion_extra.get(i) { + if sug.kind == SugKind::App { + return app_sug_extiende(sug, &s.input.text()); + } + } + } + false +} + +/// `true` si lo tipeado es un **prefijo propio** del nombre o binario de la app +/// (aún se está completando), no cuando coincide exacto (comando ya escrito). +/// Mismo criterio de match que [`build_app_suggestions`], pero exigiendo que +/// extienda (`w != q`) para no secuestrar un comando entero tipeado a mano. +fn app_sug_extiende(sug: &Suggestion, text: &str) -> bool { + let q = text.trim().to_lowercase(); + if q.is_empty() { + return false; + } + let name_l = sug.display.to_lowercase(); + let bin = sug + .insert + .split_whitespace() + .next() + .and_then(|p| p.rsplit('/').next()) + .unwrap_or("") + .to_lowercase(); + let extiende = |w: &str| w.starts_with(&q) && w != q; + extiende(&name_l) || extiende(&bin) || name_l.split_whitespace().any(extiende) +} + +/// Tiers 2 y 3 del completado en capas: líneas completas del historial que +/// extienden lo tipeado, y grupos / coreografías cuya secuencia empieza con el +/// texto. Vacío si el cursor no está al final o el texto está vacío (no hay +/// prefijo de línea que extender). +pub(crate) fn build_extra_suggestions(s: &State) -> Vec { + let texto_input = s.input.text(); + let text = texto_input.as_str(); + if text.is_empty() || s.input.cursor() != text.len() { + return Vec::new(); + } + let mut out: Vec = Vec::new(); + let mut seen: std::collections::HashSet = std::collections::HashSet::new(); + seen.insert(text.to_string()); + let span = (0usize, text.len()); + + // ── Tier 3 — grupos guardados + coreografías emergentes ────────────── + // (Van primero: un grupo entero es la sugerencia de mayor "altura".) + let mut groups: Vec = Vec::new(); + for g in &s.groups { + let line = g.lines.join(" && "); + let matches = g.name.starts_with(text) + || line.starts_with(text) + || g.lines.first().is_some_and(|l| l.starts_with(text)); + if matches && line != text && seen.insert(line.clone()) { + groups.push(Suggestion { + display: format!("⊞ {} · {} comando{}", g.name, g.lines.len(), + if g.lines.len() == 1 { "" } else { "s" }), + insert: line, + replace_start: span.0, + replace_end: span.1, + kind: SugKind::Group, + icon: None, + }); + } + } + for (name, line, occ) in applicable_sequences(s) { + if groups.len() >= MAX_GROUP_SUGGESTIONS { + break; + } + let matches = name.starts_with(text) + || line.starts_with(text) + || line.split(" && ").next().is_some_and(|l| l.starts_with(text)); + if matches && line != text && seen.insert(line.clone()) { + groups.push(Suggestion { + display: format!("↻ {name} · ×{occ}"), + insert: line, + replace_start: span.0, + replace_end: span.1, + kind: SugKind::Group, + icon: None, + }); + } + } + groups.truncate(MAX_GROUP_SUGGESTIONS); + out.extend(groups); + + // ── Tier 2 — líneas completas del historial ────────────────────────── + // Local al cwd antes que global, y dentro de cada tramo lo más reciente + // primero (mismo orden de prioridad que el ghost). + // El corpus ya viene deduplicado, filtrado por prefijo y ordenado + // local-antes-que-global (Fase 1 de `SDD-HISTORIAL.md`): acá sólo hay que + // saltear lo que ya ofrecieron los tiers de arriba y acotar la salida. + let mut lineas: Vec = Vec::new(); + for line in super::corpus::matches_por_prioridad(s, text) { + if lineas.len() >= MAX_LINE_SUGGESTIONS { + break; + } + if !seen.insert(line.clone()) { + continue; + } + lineas.push(Suggestion { + display: format!("↪ {line}"), + insert: line, + replace_start: span.0, + replace_end: span.1, + kind: SugKind::Line, + icon: None, + }); + } + out.extend(lineas); + out +} + +/// Reordena los candidatos de comando por frecuencia de uso en el historial +/// (desc), con desempate alfabético — "ordenado por prioridad y uso". Sólo +/// aplica a completados de comando; paths/flags quedan como vienen. +pub(crate) fn rank_completion_by_usage(s: &State, comp: &mut shuma_line::Completion) { + if comp.kind != shuma_line::CompletionKind::Command { + return; + } + let mut freq: std::collections::HashMap = std::collections::HashMap::new(); + if let Ok(h) = s.history.lock() { + for e in h.entries() { + if let Some(w) = e.line.split_whitespace().next() { + *freq.entry(w.to_string()).or_insert(0) += 1; + } + } + } + comp.candidates.sort_by(|a, b| { + let fa = freq.get(a).copied().unwrap_or(0); + let fb = freq.get(b).copied().unwrap_or(0); + fb.cmp(&fa).then_with(|| a.cmp(b)) + }); +} + +/// Cicla el candidato resaltado del popup (`delta` ±1, con wrap) sobre el total +/// en capas. No-op si el popup está cerrado. +pub(crate) fn cycle_completion(mut s: State, delta: i32) -> State { + let n = completion_total(&s) as i32; + if s.completion.is_some() && n > 0 { + s.completion_index = (s.completion_index as i32 + delta).rem_euclid(n) as usize; + // Navegar = elegir: ahora Enter sí puede lanzar un candidato-app. + s.completion_navegado = true; + } + s +} + +/// Acepta el candidato resaltado del popup, lo inserta y cierra el popup. El +/// índice global elige entre un candidato de token (tier 1) o una sugerencia +/// de línea/grupo (tiers 2/3), cada una con su propio rango de reemplazo. +pub(crate) fn accept_completion(mut s: State) -> State { + let row = completion_rows(&s).get(s.completion_index).copied(); + match row { + Some(CompRow::Token(i)) => { + if let Some(comp) = s.completion.take() { + if let Some(candidate) = comp.candidates.get(i) { + s.input.apply_completion(&comp, candidate); + } + } + } + Some(CompRow::Extra(i)) => { + if let Some(sug) = s.completion_extra.get(i).cloned() { + if sug.kind == SugKind::App { + // Un candidato-app no se inserta: se **lanza**. Deja el + // pedido para que el host lo spawnee (detached) y limpia la + // línea — igual que el launcher sin prefijo, pero del popup. + s.app_launch = Some(sug.insert.clone()); + s.input.clear(); + } else { + // Reusa la maquinaria de reemplazo de `LineState` armando un + // `Completion` sintético con el rango propio de la sugerencia. + let synthetic = shuma_line::Completion { + kind: shuma_line::CompletionKind::Command, + candidates: vec![sug.insert.clone()], + replace_start: sug.replace_start, + replace_end: sug.replace_end, + }; + s.input.apply_completion(&synthetic, &sug.insert); + } + } + } + None => {} + } + close_completion(&mut s); + s +} + +/// Elige la fila `idx` (índice global sobre el total en capas) y la acepta — +/// para el click en una fila del popup (host). No-op si el índice desborda. +pub(crate) fn pick_completion(mut s: State, idx: usize) -> State { + if idx < completion_total(&s) { + s.completion_index = idx; + // Click en la fila = elección explícita. + s.completion_navegado = true; + return accept_completion(s); + } + s +} diff --git a/02_ruway/shuma/sandbox/shuma-module-shell/src/update/containers.rs b/02_ruway/shuma/sandbox/shuma-module-shell/src/update/containers.rs new file mode 100644 index 0000000..6f83ee7 --- /dev/null +++ b/02_ruway/shuma/sandbox/shuma-module-shell/src/update/containers.rs @@ -0,0 +1,437 @@ +use super::*; + +// --- Mounts de contenedor (espejo mínimo de containers.json) ---------------- + +#[derive(serde::Deserialize)] +pub(crate) struct ContainerCfgJson { + pub(crate) name: String, + #[serde(default)] + pub(crate) mounts: Vec, +} + +#[derive(serde::Deserialize, Clone)] +pub(crate) struct MountJson { + pub(crate) host: String, + pub(crate) target: String, + #[serde(default)] + pub(crate) readonly: bool, +} + +/// Mounts configurados para el rootfs en `rootfs_path` (su basename es la clave +/// en `containers.json`). Vacío si no hay config. +pub(crate) fn container_mounts(rootfs_path: &str) -> Vec { + let name = match std::path::Path::new(rootfs_path).file_name() { + Some(n) => n.to_string_lossy().to_string(), + None => return Vec::new(), + }; + let Some(base) = std::env::var_os("XDG_CONFIG_HOME") + .map(PathBuf::from) + .or_else(|| std::env::var_os("HOME").map(|h| PathBuf::from(h).join(".config"))) + else { + return Vec::new(); + }; + let path = base.join("shuma").join("containers.json"); + let Ok(txt) = std::fs::read_to_string(&path) else { + return Vec::new(); + }; + let Ok(cfgs) = serde_json::from_str::>(&txt) else { + return Vec::new(); + }; + cfgs.into_iter() + .find(|c| c.name == name) + .map(|c| c.mounts) + .unwrap_or_default() +} + +/// Si `line` es un pipe «simple» de ≥2 etapas —sólo `Command`/`Argument`/ +/// `Flag`/`Pipe`/espacio, sin comillas, variables, redirecciones, +/// operadores, globs (`* ? [ ] { }`) ni `~`— devuelve sus etapas como +/// [`StageSpec`] para correrlo por `Exec::Direct`. Si no, `None` (cae a +/// `sh -c`, que sí absorbe esa sintaxis). Un único comando también cae a +/// `sh -c`: el modo directo sólo aporta cuando hay tubería que interceptar. +/// +/// Sólo toca la primera ocurrencia al principio del line — pipes / `&&` / +/// `;` van por su cuenta (el shell del PTY los maneja). +pub(crate) fn inject_askpass(line: &str) -> String { + let trimmed = line.trim_start(); + let lead_len = line.len() - trimmed.len(); + let Some(rest_after_sudo) = trimmed.strip_prefix("sudo") else { + return line.to_string(); + }; + // Exigir que `sudo` sea palabra completa (siguiente char espacio / EOL). + let next = rest_after_sudo.chars().next(); + if !matches!(next, None | Some(' ') | Some('\t')) { + return line.to_string(); + } + // Heurística simple: si los tokens del comando contienen -A/-S/--askpass/ + // --stdin antes de cualquier `;|&` o salto de pipe, dejarlo como está. + for tok in rest_after_sudo.split_whitespace() { + if tok == "-A" || tok == "-S" || tok == "--askpass" || tok == "--stdin" { + return line.to_string(); + } + // Llegamos a un argumento que no es flag → dejamos de buscar (es + // el comando ejecutado por sudo y sus flags son suyos). + if !tok.starts_with('-') { + break; + } + } + let lead = &line[..lead_len]; + format!("{lead}sudo -A{rest_after_sudo}") +} + +/// Envuelve `spec` en la invocación del **engine de aislamiento** elegido. +/// +/// - `engine = "podman"` / `"docker"`: `name` es el nombre del container ya +/// creado; corremos ` exec -i bash -c `. +/// - `engine = "bwrap"`: `name` es el PATH al rootfs en disco +/// (`~/.local/share/shuma/rootfs/`); corremos `bwrap` con los +/// binds estándar y `bash -c ` adentro. No requiere config +/// global — sólo el binario `bwrap` instalado. +/// +/// En ambos casos el proceso hijo que ve `shuma-exec` sigue siendo local — +/// Comilla simple POSIX-segura: envuelve `s` en `'…'` escapando comillas +/// internas (`'` → `'\''`). Para componer el comando que viaja por SSH. +pub(crate) fn sh_squote(s: &str) -> String { + format!("'{}'", s.replace('\'', "'\\''")) +} + +/// Comando a ejecutar **en el host remoto** para correr `line` dentro de un +/// contenedor de ese host. Para podman/docker entra con ` exec`; para +/// un rootfs (unshare/bwrap) hace `chroot` al path. El `cwd` interior se aplica +/// con un `cd` dentro del shell del contenedor (no del host) — por eso +/// `start_run` le pasa "~" a `run_ssh`, para no anteponer un `cd` del host. +pub(crate) fn remote_container_command(line: &str, engine: &str, name: &str, cwd: &str) -> String { + // El cwd interior sólo tiene sentido si es absoluto; si el dir no existe, + // el `2>/dev/null` evita romper el comando. + let inner = if cwd.starts_with('/') { + format!("cd {} 2>/dev/null; {line}", sh_squote(cwd)) + } else { + line.to_string() + }; + match engine { + // rootfs en el remoto: chroot al path (requiere privilegios allá). + "unshare" | "bwrap" => format!( + "chroot {} /bin/sh -lc {}", + sh_squote(name), + sh_squote(&inner) + ), + // podman/docker: exec contra el contenedor vivo. + eng => format!( + "{eng} exec -i {} /bin/sh -lc {}", + sh_squote(name), + sh_squote(&inner) + ), + } +} + +/// reusamos la maquinaria de PTY / capture / kill de `Source::Local`. +pub(crate) fn wrap_spec_for_container(mut spec: CommandSpec, engine: &str, name: &str) -> CommandSpec { + if engine == "unshare" { + return wrap_spec_for_unshare(spec, name); + } + if engine == "bwrap" { + return wrap_spec_for_bwrap(spec, name); + } + let eng = engine.to_string(); + let nm = name.to_string(); + spec.exec = match spec.exec { + Exec::Shell { line, program } => { + // bash local que dispara `engine exec` con `program -c "line"` + // adentro. Mantenemos Exec::Shell para preservar captura por + // líneas (no PTY). + let inner = format!( + "{eng} exec -i {nm} {prog} -c {q}", + eng = shell_quote(&eng), + nm = shell_quote(&nm), + prog = shell_quote(&program), + q = shell_quote(&line), + ); + Exec::Shell { + line: inner, + program: "bash".into(), + } + } + Exec::Pty { program, args, cols, rows } => { + // PTY local que ejecuta `engine exec -it name `. + let mut new_args = vec!["exec".to_string(), "-it".into(), nm, program]; + new_args.extend(args); + Exec::Pty { + program: eng, + args: new_args, + cols, + rows, + } + } + Exec::Direct { stages } => { + // Reconstruimos la pipe como una sola line bash y la disparamos + // dentro del contenedor; perdemos la captura de etapas (tee) — + // tradeoff aceptable para el MVP del cableo container. + let mut line = String::new(); + for (i, st) in stages.iter().enumerate() { + if i > 0 { + line.push_str(" | "); + } + line.push_str(&shell_quote(&st.program)); + for a in &st.args { + line.push(' '); + line.push_str(&shell_quote(a)); + } + } + let inner = format!( + "{eng} exec -i {nm} bash -c {q}", + eng = shell_quote(&eng), + nm = shell_quote(&nm), + q = shell_quote(&line), + ); + Exec::Shell { + line: inner, + program: "bash".into(), + } + } + }; + spec +} + +/// Script `sh` (`$1 = rootfs_path`, `$2 = línea bash`) que aísla por `unshare` +/// + `chroot`. Monta `/proc`, `/dev`, `/sys` y bind-mountea `/etc/resolv.conf` +/// del host (para que apt/pacman alcancen la red) + los directorios que el +/// usuario configuró en el gestor, cada uno en su `target` (ro o rw). +/// +/// El `|| true` tras cada `mount` evita abortar si ya había algo montado +/// (re-entry tras crash) o un dir no existe. +pub(crate) fn unshare_script(mounts: &[MountJson]) -> String { + let mut s = String::from( + "mount -t proc proc \"$1/proc\" 2>/dev/null || true; \ + mount --bind /dev \"$1/dev\" 2>/dev/null || true; \ + mount --bind /sys \"$1/sys\" 2>/dev/null || true; \ + mount --bind /etc/resolv.conf \"$1/etc/resolv.conf\" 2>/dev/null || true; ", + ); + for m in mounts { + if m.host.trim().is_empty() || m.target.trim().is_empty() { + continue; + } + let hq = shell_quote(&m.host); + let tq = shell_quote(&m.target); + s.push_str(&format!("mkdir -p \"$1\"{tq} 2>/dev/null || true; ")); + s.push_str(&format!("mount --bind {hq} \"$1\"{tq} 2>/dev/null || true; ")); + if m.readonly { + s.push_str(&format!( + "mount -o remount,bind,ro \"$1\"{tq} 2>/dev/null || true; " + )); + } + } + s.push_str("exec chroot \"$1\" /bin/bash -c \"$2\""); + s +} + +/// Variante de [`wrap_spec_for_container`] para `engine = "unshare"`. El +/// `rootfs_path` es un filesystem extraído en disco local; `unshare -r` +/// + `chroot` lo activan sin necesidad de root ni bwrap ni podman — sólo +/// requiere `util-linux` + `coreutils` (instalados en todo Linux moderno). +/// +/// Funciona en distros con `kernel.unprivileged_userns_clone = 1` (default +/// en kernels >= 5.10 mayoritarios). Si está deshabilitado, el `unshare -r` +/// falla con "Operation not permitted" y el caller verá el stderr en el +/// notice. +pub(crate) fn wrap_spec_for_unshare(mut spec: CommandSpec, rootfs_path: &str) -> CommandSpec { + fn base_args(rootfs: &str, inner_line: &str, script: &str) -> Vec { + vec![ + "-r".into(), // map root in user ns + "-m".into(), // mount ns (para mount -t proc etc.) + "-u".into(), // uts ns + "-i".into(), // ipc ns + "-p".into(), // pid ns + "-f".into(), // fork (necesario con -p) + "--kill-child".into(), // los hijos mueren con el padre + "--".into(), + "/bin/sh".into(), "-c".into(), script.to_string(), + "_".into(), // $0 + rootfs.to_string(), // $1 + inner_line.to_string(), // $2 + ] + } + let rootfs = rootfs_path.to_string(); + // Script con los binds estándar + los directorios montados por el usuario + // (de containers.json). El basename del rootfs es la clave de config. + let script = unshare_script(&container_mounts(rootfs_path)); + // Prefijo común para TODO comando dentro del contenedor: HOME del root y + // `cd` al cwd interior que trackea shuma (`spec.cwd`). Sin esto el comando + // corría en `/` con PWD heredado del host → `pwd`/`ls`/el prompt se + // contradecían. `|| true` para no abortar si el dir no existe (el comando + // igual reporta su propio error). + let prelude = format!( + "export HOME=/root; cd {} 2>/dev/null || true; ", + shell_quote(&spec.cwd) + ); + spec.exec = match spec.exec { + Exec::Shell { line, program: _ } => { + // No-TUI: corremos `unshare` como UNA etapa `Exec::Direct` para + // capturar stdout/stderr por líneas y renderizarlas como bloques, + // igual que un comando local. (Antes se forzaba `Exec::Pty`, pero + // sin `TuiSession` el drenado descartaba los `Bytes` del PTY → el + // comando corría sin mostrar NADA, con la card en verde/✘ sin + // motivo. Los TUI fullscreen sí van por la rama `Exec::Pty` de + // abajo, que sí trae su emulador.) + let inner = format!("{prelude}{line}"); + let args = base_args(&rootfs, &inner, &script); + Exec::Direct { stages: vec![StageSpec { program: "unshare".into(), args }] } + } + Exec::Pty { program, args, cols, rows } => { + // Para Exec::Pty (TUI fullscreen tipo vim) armamos el `bash -c` + // con el program + args ya quoteados. + let mut inner = prelude.clone(); + inner.push_str(&shell_quote(&program)); + for a in &args { + inner.push(' '); + inner.push_str(&shell_quote(a)); + } + let args = base_args(&rootfs, &inner, &script); + Exec::Pty { program: "unshare".into(), args, cols, rows } + } + Exec::Direct { stages } => { + let mut line = String::new(); + for (i, st) in stages.iter().enumerate() { + if i > 0 { + line.push_str(" | "); + } + line.push_str(&shell_quote(&st.program)); + for a in &st.args { + line.push(' '); + line.push_str(&shell_quote(a)); + } + } + // Pipe simple → también por `Exec::Direct` (captura por líneas). + let inner = format!("{prelude}{line}"); + let args = base_args(&rootfs, &inner, &script); + Exec::Direct { stages: vec![StageSpec { program: "unshare".into(), args }] } + } + }; + spec +} + +/// Args base de bwrap para correr un comando dentro de `rootfs_path`. La +/// idea: aislar mount/pid/uts/ipc pero **compartir net** del host (para +/// que `apt update`, `pacman -Sy`, etc. lleguen al mundo). El `/work` +/// queda como bind del cwd del host cuando aplica. +pub(crate) fn bwrap_args(rootfs_path: &str) -> Vec { + let mut a: Vec = vec![ + // Root del container. + "--bind".into(), rootfs_path.into(), "/".into(), + // Filesystems internos. + "--proc".into(), "/proc".into(), + "--dev".into(), "/dev".into(), + "--tmpfs".into(), "/tmp".into(), + // DNS funcional: copia el resolv.conf del host (ro). + "--ro-bind-try".into(), "/etc/resolv.conf".into(), "/etc/resolv.conf".into(), + // Aislamiento: namespaces propios menos net (compartido). + "--unshare-pid".into(), + "--unshare-uts".into(), + "--unshare-ipc".into(), + // El process tree muere si el padre muere — no quedan zombies. + "--die-with-parent".into(), + // Env mínimo razonable para un shell vacío. + "--setenv".into(), "HOME".into(), "/root".into(), + "--setenv".into(), "USER".into(), "root".into(), + "--setenv".into(), "PATH".into(), + "/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin".into(), + "--setenv".into(), "TERM".into(), "xterm-256color".into(), + ]; + // Directorios montados por el usuario (containers.json): `--ro-bind` o + // `--bind` del host a su `target` dentro del contenedor. + for m in container_mounts(rootfs_path) { + if m.host.trim().is_empty() || m.target.trim().is_empty() { + continue; + } + a.push(if m.readonly { "--ro-bind".into() } else { "--bind".into() }); + a.push(m.host.clone()); + a.push(m.target.clone()); + } + // Si existe ~/work en el rootfs, lo usamos como cwd; sino /. + a.push("--chdir".into()); + a.push("/".into()); + a +} + +/// Variante de [`wrap_spec_for_container`] para `engine = "bwrap"`. El +/// `rootfs_path` es el filesystem extraído (LXC image) en disco local. +pub(crate) fn wrap_spec_for_bwrap(mut spec: CommandSpec, rootfs_path: &str) -> CommandSpec { + let base = bwrap_args(rootfs_path); + // Igual que unshare: HOME del root + `cd` al cwd interior trackeado. + let prelude = format!( + "export HOME=/root; cd {} 2>/dev/null || true; ", + shell_quote(&spec.cwd) + ); + spec.exec = match spec.exec { + Exec::Shell { line, program: _ } => { + // No-TUI → `Exec::Direct` (una etapa bwrap) para capturar + // stdout/stderr por líneas y renderizar como bloques. Forzar PTY + // sin TuiSession descartaba el output (ver wrap_spec_for_unshare). + // Los TUI fullscreen van por la rama `Exec::Pty` de abajo. + let mut args = base; + args.push("--".into()); + args.push("bash".into()); + args.push("-c".into()); + args.push(format!("{prelude}{line}")); + Exec::Direct { stages: vec![StageSpec { program: "bwrap".into(), args }] } + } + Exec::Pty { program, args, cols, rows } => { + // TUI: envolvemos en `bash -c` para poder hacer el `cd` interior. + let mut inner = prelude.clone(); + inner.push_str("exec "); + inner.push_str(&shell_quote(&program)); + for a in &args { + inner.push(' '); + inner.push_str(&shell_quote(a)); + } + let mut new_args = base; + new_args.push("--".into()); + new_args.push("bash".into()); + new_args.push("-c".into()); + new_args.push(inner); + Exec::Pty { + program: "bwrap".into(), + args: new_args, + cols, + rows, + } + } + Exec::Direct { stages } => { + // Serialize pipe as a single bash line (mismo tradeoff que podman). + let mut line = String::new(); + for (i, st) in stages.iter().enumerate() { + if i > 0 { + line.push_str(" | "); + } + line.push_str(&shell_quote(&st.program)); + for a in &st.args { + line.push(' '); + line.push_str(&shell_quote(a)); + } + } + let mut args = base; + args.push("--".into()); + args.push("bash".into()); + args.push("-c".into()); + args.push(format!("{prelude}{line}")); + Exec::Direct { stages: vec![StageSpec { program: "bwrap".into(), args }] } + } + }; + spec +} + +/// Quote básico estilo Bourne para envolver en `'…'`. Sustituye `'` por +/// `'\''`. Suficiente para inyectar paths/comandos del usuario al wrap del +/// container; no pretende ser un parser POSIX completo. +pub(crate) fn shell_quote(s: &str) -> String { + let mut out = String::with_capacity(s.len() + 2); + out.push('\''); + for ch in s.chars() { + if ch == '\'' { + out.push_str("'\\''"); + } else { + out.push(ch); + } + } + out.push('\''); + out +} diff --git a/02_ruway/shuma/sandbox/shuma-module-shell/src/update/copymode.rs b/02_ruway/shuma/sandbox/shuma-module-shell/src/update/copymode.rs new file mode 100644 index 0000000..4a1a913 --- /dev/null +++ b/02_ruway/shuma/sandbox/shuma-module-shell/src/update/copymode.rs @@ -0,0 +1,172 @@ +//! **Copy-mode** del panel de output — la puerta de *teclado* a la selección, +//! estilo `copy-mode` de tmux / "scrollback mode" de kitty. +//! +//! Se entra con `Ctrl+Shift+Espacio`: aparece un caret sólido (no parpadea, el +//! panel es readonly) que se mueve con **flechas y hjkl**, salto por palabra +//! (`Ctrl+←/→`, `w`/`b`), `Home`/`End` (`0`/`$`), página (`PgUp`/`PgDn`), tope +//! y fondo (`g`/`G`). `Shift`+movimiento —o el **modo visual** `v`/Espacio— +//! extiende la selección, que se copia sola al cuasi-clipboard PRIMARY mientras +//! la extendés (pega el botón medio). `y`/`Enter` la copia al portapapeles +//! principal y sale; `Esc`/`q` sale sin copiar de más. +//! +//! Todo opera contra el snapshot de layout que la `view` publica cada frame +//! (`surf_layout`): la misma geometría que usa el drag de mouse. + +use super::*; +use llimphi_ui::{Key, KeyEvent, NamedKey}; +use llimphi_widget_terminal::{caret_reveal_scroll, Motion, Point, SelectionRange}; + +/// `true` si el evento es el atajo para **entrar** a copy-mode: +/// `Ctrl+Shift+Espacio`. Gana por encima del reenvío al PTY (como la salida de +/// emergencia), así funciona incluso con una consola viva comiéndose el teclado. +pub(crate) fn es_entrar_copy_mode(ev: &KeyEvent) -> bool { + // Con Ctrl+Shift apretados casi todos los backends de winit/wayland + // entregan la barra como `Named(Space)` (no como `Character(" ")`, que sólo + // llega sin modifiers) — aceptamos ambas para no depender del backend. + let es_espacio = matches!(&ev.key, Key::Character(c) if c.as_str() == " ") + || matches!(&ev.key, Key::Named(NamedKey::Space)); + ev.modifiers.ctrl && ev.modifiers.shift && !ev.modifiers.alt && es_espacio +} + +/// Clona el snapshot de layout publicado por la `view` el frame previo. +fn snapshot(s: &State) -> Option { + match s.surf_layout.lock() { + Ok(g) => g.clone(), + Err(p) => p.into_inner().clone(), + } +} + +/// Entra a copy-mode: ancla el caret al inicio de la última línea (donde cae el +/// output fresco) y limpia el modo visual. No-op si no hay nada que explorar. +pub(crate) fn enter_copy_mode(mut s: State) -> State { + let Some(snap) = snapshot(&s) else { + return s; + }; + let n = snap.store.len(); + if n == 0 { + return s; + } + s.surf_selection = Some(SelectionRange::collapsed(Point::new(n - 1, 0))); + s.surf_copy_mode = true; + s.surf_copy_visual = false; + s +} + +/// Sale de copy-mode y limpia el highlight (lo copiado ya quedó en el +/// clipboard/PRIMARY). Paridad con tmux: al salir la selección se deshace. +pub(crate) fn exit_copy_mode(mut s: State) -> State { + s.surf_copy_mode = false; + s.surf_copy_visual = false; + s.surf_selection = None; + s +} + +/// Filas por página para `PgUp`/`PgDn` (`Ctrl+u/d`): una página menos dos filas +/// de solape, mínimo 1, a partir del alto del viewport del snapshot. +fn page_rows(snap: &crate::SurfLayout) -> usize { + let row_h = snap.metrics.line_height.max(1.0); + ((snap.viewport_h / row_h).floor() as usize) + .saturating_sub(2) + .max(1) +} + +/// Traduce un `KeyEvent` a un [`Motion`], con doble alfabeto (flechas + vim). +/// `None` = la tecla no es un movimiento. +fn motion_de(ev: &KeyEvent) -> Option { + let ctrl = ev.modifiers.ctrl; + match &ev.key { + Key::Named(NamedKey::ArrowLeft) => { + Some(if ctrl { Motion::WordLeft } else { Motion::CharLeft }) + } + Key::Named(NamedKey::ArrowRight) => { + Some(if ctrl { Motion::WordRight } else { Motion::CharRight }) + } + Key::Named(NamedKey::ArrowUp) => Some(Motion::LineUp), + Key::Named(NamedKey::ArrowDown) => Some(Motion::LineDown), + Key::Named(NamedKey::Home) => Some(Motion::LineHome), + Key::Named(NamedKey::End) => Some(Motion::LineEnd), + Key::Named(NamedKey::PageUp) => Some(Motion::PageUp(0)), // n se resuelve fuera + Key::Named(NamedKey::PageDown) => Some(Motion::PageDown(0)), + Key::Character(c) => match c.as_str() { + "h" => Some(Motion::CharLeft), + "l" => Some(Motion::CharRight), + "j" => Some(Motion::LineDown), + "k" => Some(Motion::LineUp), + "w" => Some(Motion::WordRight), + "b" => Some(Motion::WordLeft), + "0" => Some(Motion::LineHome), + "$" => Some(Motion::LineEnd), + "g" => Some(Motion::DocTop), + "G" => Some(Motion::DocBottom), + _ => None, + }, + _ => None, + } +} + +/// Maneja una tecla mientras copy-mode está activo. Siempre consume el evento +/// (devuelve el `State` ya resuelto) — el gate del caller no debe reenviarlo. +pub(crate) fn copy_mode_key(mut s: State, ev: &KeyEvent) -> State { + let is_char = |name: &str| matches!(&ev.key, Key::Character(c) if c.as_str() == name); + + // Salir sin copiar de más. + if matches!(&ev.key, Key::Named(NamedKey::Escape)) || is_char("q") { + return exit_copy_mode(s); + } + // Copiar al portapapeles principal (con comando si arranca en un bloque) y salir. + if matches!(&ev.key, Key::Named(NamedKey::Enter)) || is_char("y") { + copy_surf_selection(&s); + return exit_copy_mode(s); + } + // Modo visual: `v` o Espacio anclan/desanclan la extensión (la barra puede + // llegar como `Named(Space)` o como `Character(" ")` según el backend). + if is_char("v") || is_char(" ") || matches!(&ev.key, Key::Named(NamedKey::Space)) { + s.surf_copy_visual = !s.surf_copy_visual; + return s; + } + + let Some(mut motion) = motion_de(ev) else { + return s; // tecla irrelevante: se ignora, sin reenviar al PTY + }; + let Some(snap) = snapshot(&s) else { + return s; + }; + // Resolver el tamaño de página ahora que tenemos el snapshot. + let pr = page_rows(&snap); + motion = match motion { + Motion::PageUp(_) => Motion::PageUp(pr), + Motion::PageDown(_) => Motion::PageDown(pr), + other => other, + }; + let extend = ev.modifiers.shift || s.surf_copy_visual; + if let Some(sel) = s.surf_selection.as_mut() { + sel.move_caret(&snap.store, motion, extend); + } + // Copy-on-select: si la selección no está vacía, va sola al PRIMARY. + if let Some(sel) = s.surf_selection { + if !sel.is_empty() { + let text = sel.slice_text(&snap.store); + if !text.is_empty() { + clipboard::set_primary(&text); + } + } + } + // Auto-scroll: dejar el caret a la vista (sube/baja lo justo). + if let Some(sel) = s.surf_selection { + let cur_scroll_y = if s.scroll_px <= 0.5 { + f32::MAX // pinned al fondo: reveal lo clampa al overflow + } else { + (s.surf_scroll_anchor - s.scroll_px).max(0.0) + }; + let (sy, overflow) = caret_reveal_scroll( + &snap.items_geo, + cur_scroll_y, + snap.viewport_h, + snap.metrics, + sel.head.line, + ); + s.surf_scroll_anchor = overflow; + s.scroll_px = (overflow - sy).max(0.0); + } + s +} diff --git a/02_ruway/shuma/sandbox/shuma-module-shell/src/update/corpus.rs b/02_ruway/shuma/sandbox/shuma-module-shell/src/update/corpus.rs new file mode 100644 index 0000000..8d4d762 --- /dev/null +++ b/02_ruway/shuma/sandbox/shuma-module-shell/src/update/corpus.rs @@ -0,0 +1,241 @@ +//! Corpus de sugerencias — **Fase 1 de `SDD-HISTORIAL.md`** (L4 de `COLA-SHUMA.md`). +//! +//! El problema que resuelve: el ghost y las sugerencias de línea completa +//! recortaban el historial por **antigüedad cruda** (`GHOST_CORPUS_WINDOW` / +//! `LINE_SUGGEST_WINDOW`, 2.000 entradas). Con eso, un comando que el usuario usa +//! seguido desaparecía del autocompletado por el mero hecho de haber tecleado +//! mucho **después** — medido: el último uso del comando más frecuente estaba en la +//! entrada 28.883 de 32.851, fuera de alcance. Subir el número no era la solución: +//! el camino del tecleo corre por frame y no puede recorrer el crudo. +//! +//! La forma correcta es un corpus **deduplicado y cacheado**: las líneas +//! DISTINTAS, la más reciente primero. Deduplicar es lo que cambia el orden de +//! magnitud — un historial de decenas de miles de entradas tiene unos pocos miles +//! de líneas distintas, y ahí ya entra todo lo que el usuario usa de verdad, sin +//! ventana. Se reconstruye cuando el historial **crece** (al abrir la sesión y al +//! cerrar cada comando), nunca por pulsación. + +use super::*; + +/// Tope de líneas distintas que el corpus mantiene en memoria. No es la ventana +/// vieja disfrazada: es un techo de memoria muy por encima de lo que tiene un +/// historial real (~2–4 k líneas distintas en el del usuario), para que un +/// historial patológico no crezca sin límite. Se recorta por el final, o sea se +/// tira lo más viejo. +pub(crate) const CORPUS_MAX: usize = 20_000; + +/// Reconstruye el corpus entero desde el historial crudo: recorre de la entrada +/// más nueva a la más vieja y se queda con la **primera** aparición de cada línea +/// (o sea, su uso más reciente, con el `cwd` de ese uso). O(N) una sola vez. +fn rebuild(entries: &[shuma_history::Entry]) -> Vec { + let mut seen: std::collections::HashSet<&str> = std::collections::HashSet::new(); + let mut out: Vec = Vec::new(); + for e in entries.iter().rev() { + if e.line.trim().is_empty() { + continue; + } + if seen.insert(e.line.as_str()) { + out.push(crate::CorpusLine { + line: e.line.clone(), + cwd: e.cwd.clone(), + }); + if out.len() >= CORPUS_MAX { + break; + } + } + } + out +} + +/// Mete una entrada nueva al frente, quitando la copia vieja de la misma línea si +/// había (así el uso más reciente manda, con su `cwd`). Lineal en el corpus, pero +/// corre una vez por comando ejecutado — no por pulsación. +fn push_front(corpus: &mut Vec, e: &shuma_history::Entry) { + if e.line.trim().is_empty() { + return; + } + if let Some(pos) = corpus.iter().position(|c| c.line == e.line) { + corpus.remove(pos); + } + corpus.insert( + 0, + crate::CorpusLine { + line: e.line.clone(), + cwd: e.cwd.clone(), + }, + ); + corpus.truncate(CORPUS_MAX); +} + +/// Pone el corpus al día si el historial creció (o se rehízo). **Es el punto +/// único de mantenimiento** y es O(1) cuando no cambió nada: compara la marca de +/// agua contra el largo del historial y sale. +/// +/// Toma `&State` (no `&mut`) porque también lo llama el camino de lectura, que +/// corre por frame con el estado prestado — de ahí el `Mutex` del caché. Se llama: +/// al construir el `State` (así el ghost sirve desde el primer comando de la +/// sesión), en `refresh_patterns` (al cerrar cada comando) y perezosamente al +/// consultarlo, que cubre las vías que no pasan por ninguno de los dos (la +/// importación de zsh, los tests que escriben el historial a mano). +/// +/// Los locks se toman SIEMPRE en este orden —historial, después caché— y con +/// `try_lock` en el historial: si otro hilo lo tiene, se sale y se sirve el corpus +/// como está (una sugerencia un frame vieja no es un problema; un deadlock en el +/// camino del tecleo sí). +pub(crate) fn ensure(s: &State) { + let Ok(history) = s.history.try_lock() else { + return; + }; + let Ok(mut cache) = s.corpus.lock() else { + return; + }; + let entries = history.entries(); + let total = entries.len(); + if total == cache.seen && ancla_valida(&cache, entries) { + return; + } + if total < cache.seen || cache.lines.is_empty() || !ancla_valida(&cache, entries) { + // Primer llenado, historial recortado, o historial REEMPLAZADO por otro + // (el ancla no coincide): rehacer entero. Extender desde la marca de agua + // en ese caso saltearía el prefijo del historial nuevo. + cache.lines = rebuild(entries); + } else { + for e in &entries[cache.seen..] { + push_front(&mut cache.lines, e); + } + } + cache.seen = total; + cache.ancla = entries.last().map(|e| e.line.clone()); +} + +/// `true` si el historial que tenemos delante es el mismo que alimentó el caché: +/// la entrada en `seen - 1` sigue siendo la que anclamos. Con el caché vacío +/// (`seen == 0`) no hay nada que validar. +fn ancla_valida(cache: &crate::CorpusCache, entries: &[shuma_history::Entry]) -> bool { + if cache.seen == 0 { + return true; + } + match (&cache.ancla, entries.get(cache.seen - 1)) { + (Some(ancla), Some(e)) => *ancla == e.line, + _ => false, + } +} + +/// Las líneas del corpus que **extienden** `text`, en el orden de prioridad que ya +/// tenía el ghost: primero las del cwd actual (y sus hijos), después el resto, y +/// dentro de cada tramo lo más reciente primero. +/// +/// Filtra por prefijo ACÁ a propósito: el consumidor corre por frame, y así lo que +/// se clona es el puñado de candidatos, no el corpus entero. La semántica es la +/// misma que aplica `shuma_line::ghost_suggestion` (`len > text.len()` y +/// `starts_with`), así que el resultado no cambia por filtrar antes. +pub(crate) fn matches_por_prioridad(s: &State, text: &str) -> Vec { + ensure(s); + let base = s.cwd.to_string_lossy(); + let mut local: Vec = Vec::new(); + let mut global: Vec = Vec::new(); + let Ok(cache) = s.corpus.lock() else { + return Vec::new(); + }; + for c in &cache.lines { + if c.line.len() <= text.len() || !c.line.starts_with(text) { + continue; + } + if super::patterns::cwd_within(&c.cwd, &base) { + local.push(c.line.clone()); + } else { + global.push(c.line.clone()); + } + } + local.extend(global); + local +} + +#[cfg(test)] +mod tests { + use super::*; + + fn entry(line: &str, cwd: &str) -> shuma_history::Entry { + shuma_history::Entry { + line: line.to_string(), + cwd: cwd.to_string(), + exit: None, + started: 0, + duration_ms: None, + } + } + + /// El caso que originó L4: un comando muy usado queda **fuera** de la ventana + /// cruda de 2.000 por haber tecleado mucho después, y el corpus deduplicado lo + /// sigue ofreciendo. + #[test] + fn el_comando_viejo_y_muy_usado_sobrevive_a_3000_lineas_despues() { + let mut entries = vec![entry("claude --dangerously-skip-permissions", "/repo")]; + for i in 0..3000 { + entries.push(entry(&format!("echo relleno {i}"), "/repo")); + } + let corpus = rebuild(&entries); + assert!( + corpus + .iter() + .any(|c| c.line == "claude --dangerously-skip-permissions"), + "el corpus deduplicado tiene que alcanzar más allá de la ventana vieja" + ); + } + + #[test] + fn deduplica_y_deja_el_uso_mas_reciente_al_frente() { + let entries = vec![ + entry("cargo test", "/viejo"), + entry("git status", "/repo"), + entry("cargo test", "/nuevo"), + ]; + let corpus = rebuild(&entries); + assert_eq!(corpus.len(), 2, "«cargo test» no puede aparecer dos veces"); + assert_eq!(corpus[0].line, "cargo test"); + assert_eq!( + corpus[0].cwd, "/nuevo", + "el cwd que queda es el del uso más reciente" + ); + } + + #[test] + fn el_incremental_da_el_mismo_corpus_que_rehacer_de_cero() { + let todas = vec![ + entry("uno", "/a"), + entry("dos", "/b"), + entry("uno", "/c"), + entry("tres", "/a"), + ]; + let mut incremental = rebuild(&todas[..2]); + for e in &todas[2..] { + push_front(&mut incremental, e); + } + let de_cero = rebuild(&todas); + let l = |v: &Vec| { + v.iter() + .map(|c| (c.line.clone(), c.cwd.clone())) + .collect::>() + }; + assert_eq!(l(&incremental), l(&de_cero)); + } + + #[test] + fn saltea_lineas_vacias() { + let entries = vec![entry(" ", "/a"), entry("", "/a"), entry("ls", "/a")]; + assert_eq!(rebuild(&entries).len(), 1); + } + + #[test] + fn el_tope_recorta_lo_mas_viejo() { + let mut entries = Vec::new(); + for i in 0..(CORPUS_MAX + 50) { + entries.push(entry(&format!("cmd {i}"), "/a")); + } + let corpus = rebuild(&entries); + assert_eq!(corpus.len(), CORPUS_MAX); + // La más nueva está; la más vieja se fue. + assert_eq!(corpus[0].line, format!("cmd {}", CORPUS_MAX + 49)); + assert!(!corpus.iter().any(|c| c.line == "cmd 0")); + } +} diff --git a/02_ruway/shuma/sandbox/shuma-module-shell/src/update/find.rs b/02_ruway/shuma/sandbox/shuma-module-shell/src/update/find.rs new file mode 100644 index 0000000..87c5777 --- /dev/null +++ b/02_ruway/shuma/sandbox/shuma-module-shell/src/update/find.rs @@ -0,0 +1,108 @@ +use super::*; + +/// Edita la query y re-busca. Resetea `current` al primer match (lo más +/// natural cuando uno está tipeando — el resaltado salta a la primera +/// ocurrencia conforme se escribe). +pub(crate) fn apply_find_edit(mut s: State, mutate: impl FnOnce(&mut String)) -> State { + if let Some(f) = s.find.as_mut() { + mutate(&mut f.query); + } else { + return s; + } + recompute_find(s) +} + +/// Re-corre `find_matches` con la query/política vigentes y arma +/// `surf_selection` con el match `current` (o el primero si recién hubo +/// edición). Si la nueva query no matchea nada, `current = None` y la +/// selección se limpia. +pub(crate) fn recompute_find(mut s: State) -> State { + use llimphi_widget_terminal::{find_matches, FindOpts}; + let Some(f) = s.find.as_mut() else { + return s; + }; + let snap = match s.surf_layout.lock() { + Ok(g) => g.clone(), + Err(p) => p.into_inner().clone(), + }; + let Some(snap) = snap else { + // Sin layout publicado, no hay nada que buscar. Mantenemos la query + // pero matches vacíos; al primer render volvemos a entrar. + f.matches.clear(); + f.current = None; + s.surf_selection = None; + return s; + }; + f.matches = find_matches( + &snap.store, + &f.query, + FindOpts { case_insensitive: f.case_insensitive }, + ); + if f.matches.is_empty() { + f.current = None; + s.surf_selection = None; + s + } else { + f.current = Some(0); + apply_current_match(s, &snap) + } +} + +/// Avanza/retrocede el match actual (cíclico) y refleja como selección. +pub(crate) fn step_find(mut s: State, forward: bool) -> State { + use llimphi_widget_terminal::{next_match, prev_match}; + let snap = match s.surf_layout.lock() { + Ok(g) => g.clone(), + Err(p) => p.into_inner().clone(), + }; + let Some(snap) = snap else { + return s; + }; + let Some(f) = s.find.as_mut() else { + return s; + }; + if f.matches.is_empty() { + return s; + } + f.current = if forward { + next_match(&f.matches, f.current) + } else { + prev_match(&f.matches, f.current) + }; + apply_current_match(s, &snap) +} + +/// Refleja el match `current` de `find` como `surf_selection` y ajusta +/// `scroll_px` para traerlo a la vista (centrado en el viewport, clampeado +/// al overflow). Toma `snap` aparte para no doble-lockear `surf_layout`. +pub(crate) fn apply_current_match(mut s: State, snap: &crate::SurfLayout) -> State { + use llimphi_widget_terminal::{line_top_in_content, Point, SelectionRange}; + let Some(f) = s.find.as_ref() else { + return s; + }; + let Some(i) = f.current else { + return s; + }; + let Some(m) = f.matches.get(i).copied() else { + return s; + }; + // Selección = el span del match (mismo painter del overlay; ya + // copiable con SurfCopySelection). + s.surf_selection = Some(SelectionRange { + anchor: Point::new(m.line, m.start), + head: Point::new(m.line, m.end), + }); + // Auto-scroll: lleva la línea del match a la mitad del viewport. + if let Some(line_top) = line_top_in_content(&snap.items_geo, snap.metrics.line_height, m.line) { + let centered = (line_top - snap.viewport_h * 0.5).max(0.0); + // Convertir scroll_y (desde arriba) a scroll_px (desde abajo) — el + // modelo del shell usa esta convención para anclar al fondo en + // ausencia de scroll manual. + let overflow = s.out_overflow.lock().map(|g| *g).unwrap_or(0.0); + s.scroll_px = (overflow - centered).clamp(0.0, overflow); + // Anchor del scroll para que el find sobreviva appends sucesivos + // (Fase 5: anclaje estable). Si quedó pinned al fondo, anchor=0. + s.surf_scroll_anchor = if s.scroll_px > 0.5 { overflow } else { 0.0 }; + } + s +} diff --git a/02_ruway/shuma/sandbox/shuma-module-shell/src/update/history.rs b/02_ruway/shuma/sandbox/shuma-module-shell/src/update/history.rs new file mode 100644 index 0000000..29921cb --- /dev/null +++ b/02_ruway/shuma/sandbox/shuma-module-shell/src/update/history.rs @@ -0,0 +1,102 @@ +use super::*; + +/// Navega el historial por Up/Down. +pub(crate) fn navigate_history(mut s: State, dir: shuma_history::Nav) -> State { + let next = { + let history = s.history.lock().unwrap(); + history + .navigate(s.history_cursor, dir) + .map(|(i, e)| (i, e.line.clone())) + }; + if let Some((i, line)) = next { + s.history_cursor = Some(i); + s.input.set_text(line); + } else if matches!(dir, shuma_history::Nav::Newer) { + // Salir del historial al final: línea vacía. + s.history_cursor = None; + s.input.clear(); + } + s +} + +/// Maneja teclas mientras el overlay Ctrl-R está abierto. +pub(crate) fn handle_search_key(mut s: State, ev: &KeyEvent) -> State { + let Some(mut search) = s.history_search.take() else { + return s; + }; + match &ev.key { + Key::Named(NamedKey::Escape) => { + // Salida sin aceptar. + return s; + } + Key::Named(NamedKey::Enter) => { + // Acepta el seleccionado: pasa a la línea (sin ejecutar). + let pick = { + let history = s.history.lock().unwrap(); + history + .fuzzy_search(&search.query, 50) + .get(search.selected) + .map(|e| e.line.clone()) + }; + if let Some(line) = pick { + s.input.set_text(line); + } + return s; + } + Key::Named(NamedKey::Backspace) => { + search.query.pop(); + search.selected = 0; + } + Key::Named(NamedKey::ArrowDown) => { + let history = s.history.lock().unwrap(); + let max = history.fuzzy_search(&search.query, 50).len(); + if max > 0 && search.selected + 1 < max { + search.selected += 1; + } + } + Key::Named(NamedKey::ArrowUp) => { + search.selected = search.selected.saturating_sub(1); + } + _ => { + if let Some(text) = &ev.text { + if !text.is_empty() && !text.chars().any(|c| c.is_control()) { + search.query.push_str(text); + search.selected = 0; + } + } + } + } + s.history_search = Some(search); + s +} + +/// Maneja teclas mientras la barra de find del cuerpo de output está +/// abierta (Ctrl+F). Esc cierra; Enter avanza (Shift+Enter retrocede); +/// Backspace borra; cualquier char visible se concatena a la query y +/// re-busca. F3/Shift+F3 son atajos alternativos para next/prev. +pub(crate) fn handle_find_key(s: State, ev: &KeyEvent) -> State { + if s.find.is_none() { + return s; + } + match &ev.key { + Key::Named(NamedKey::Escape) => update(s, Msg::FindClose), + Key::Named(NamedKey::Enter) | Key::Named(NamedKey::F3) => { + let msg = if ev.modifiers.shift { Msg::FindPrev } else { Msg::FindNext }; + update(s, msg) + } + Key::Named(NamedKey::Backspace) => update(s, Msg::FindBackspace), + _ => { + if let Some(text) = &ev.text { + let mut s = s; + for c in text.chars() { + if !c.is_control() { + s = update(s, Msg::FindChar(c)); + } + } + s + } else { + s + } + } + } +} diff --git a/02_ruway/shuma/sandbox/shuma-module-shell/src/update/mod.rs b/02_ruway/shuma/sandbox/shuma-module-shell/src/update/mod.rs new file mode 100644 index 0000000..15d8f54 --- /dev/null +++ b/02_ruway/shuma/sandbox/shuma-module-shell/src/update/mod.rs @@ -0,0 +1,948 @@ +use super::*; + +mod find; +mod scroll; +mod surface; +mod completion; +mod history; +pub(crate) mod pty; +mod clipboard; +mod copymode; +mod body_editor; +mod patterns; +pub(crate) mod corpus; +pub(crate) mod run_exec; +pub(crate) mod builtins; +// El host llama `auto_reattach` UNA vez al construir el módulo: re-monta la +// sesión persistente del daemon que quedó adjunta antes del reinicio. Un host +// con tabs usa `montadas_vivas` + `reattach_en` para re-montarlas TODAS (una +// tab por sesión) en vez de sólo la última. +pub use builtins::{ + auto_reattach, listar_sesiones, matar_sesion, mirar_sesion, montadas_vivas, olvidar_montada, + reattach_en, +}; +mod ssh_auth; +mod ssh_tab; +mod containers; +mod spec_builder; +mod utils; + +pub(crate) use find::*; +pub(crate) use scroll::*; +pub(crate) use surface::*; +pub(crate) use completion::*; +/// El orden de filas del popup y su tipo — el host (pata) los usa para pintar su +/// surface flotante de completado alineada con `completion_index`. +pub use completion::{completion_rows, CompRow}; +pub(crate) use history::*; +pub(crate) use pty::*; +pub(crate) use clipboard::*; +pub(crate) use copymode::*; +pub(crate) use body_editor::*; +pub(crate) use patterns::*; +pub(crate) use run_exec::*; +pub(crate) use builtins::*; +pub(crate) use ssh_auth::*; +pub(crate) use ssh_tab::*; +pub(crate) use containers::*; +pub(crate) use spec_builder::*; +pub(crate) use utils::*; + +/// Mapea `action_id` de `ShortcutAction::ModuleAction` al `Msg`. +pub fn dispatch(action_id: &str) -> Option { + match action_id { + "shell.clear" => Some(Msg::Clear), + "shell.cancel" => Some(Msg::Cancel), + _ => None, + } +} + +/// Combos que **sólo** tienen sentido como edición de la línea, y que por eso +/// se quedan en el input aunque haya un programa PTY inline vivo. +/// +/// El gate del modo consola manda al programa todo lo que lleve Ctrl o Alt, +/// porque claude usa esos atajos para menús y cancelar. Pero eso se llevaba +/// puesto el salto por palabra (`Ctrl+←/→`) y el salto por palabra **con +/// selección** (`Ctrl+Shift+←/→`): el usuario los tecleaba y no pasaba nada +/// visible — no porque el input no supiera hacerlos (el motor compartido los +/// hace), sino porque la tecla nunca le llegaba. Iban al stdin de claude, que +/// no hace nada con ellas. +/// +/// La lista es corta a propósito: navegación y borrado por palabra, que ningún +/// programa de terminal usa para otra cosa. Ctrl+C (interrumpir), Ctrl+Z +/// (suspender) y el resto siguen yendo al programa, que es donde se esperan. +pub(crate) fn es_edicion_de_linea(ev: &KeyEvent) -> bool { + if !ev.modifiers.ctrl || ev.modifiers.alt { + return false; + } + matches!( + &ev.key, + Key::Named( + NamedKey::ArrowLeft + | NamedKey::ArrowRight + | NamedKey::Home + | NamedKey::End + | NamedKey::Backspace + | NamedKey::Delete + ) + ) +} + +/// Los combos del **portapapeles**, que también se quedan en shuma aunque haya +/// un programa PTY vivo comiéndose el teclado. +/// +/// `Ctrl+Shift+C/V/X` no son atajos de aplicación: son los del *terminal*. En +/// cualquier terminal moderna copian y pegan, y `Ctrl+C` pelado sigue mandando +/// SIGINT — por eso la variante con Shift existe. El gate del modo consola los +/// mandaba al programa junto con todo lo que lleva Ctrl, y el efecto era que +/// dentro de claude **no había forma de copiar ni pegar**: el handler de copiado +/// existía intacto unas líneas más abajo y no se alcanzaba nunca. +pub(crate) fn es_portapapeles(ev: &KeyEvent) -> bool { + if !ev.modifiers.ctrl || !ev.modifiers.shift || ev.modifiers.alt { + return false; + } + matches!(&ev.key, Key::Character(c) if { + let c = c.as_str(); + c.eq_ignore_ascii_case("c") || c.eq_ignore_ascii_case("v") || c.eq_ignore_ascii_case("x") + }) +} + +/// Manda la línea del input al stdin de la consola inline (claude): el TEXTO ya, +/// y el `\r` PENDIENTE para el próximo tick (lectura separada del PTY → claude/Ink +/// lo lee como Enter real, no como fin de pegado en ráfaga: el bug del +/// doble-Enter). Vacío = Enter pelado inmediato (confirmaciones/menús: «1. Yes», +/// «Enter to confirm»…). Registra en el historial de consola, despacha la +/// sugerencia `➜` vigente y limpia el input. Es el cuerpo compartido por el Enter +/// del gate (consola visible) y por el `Msg::Submit` con el cajón plegado (L1). +fn enviar_linea_a_consola(mut s: State) -> State { + let linea = s.input.text().to_string(); + // Enviar despacha la sugerencia vigente — la haya aceptado con `→` o la haya + // ignorado escribiendo otra cosa. Sin esto la marca `➜`, que sigue en + // pantalla, la re-ofrecería. + if let Some(sug) = s.claude_sugerencia.take() { + s.sugerencia_consumida = Some(sug); + } + if linea.is_empty() { + send_consola_cr(&s); + s.cr_pendiente = false; + } else { + send_consola_texto(&s, &linea); + s.cr_pendiente = true; + } + if !linea.is_empty() { + if s.consola_historial.last() != Some(&linea) { + s.consola_historial.push(linea); + } + s.input.set_text(""); + s.close_completion(); + } + s.reanclar_caret(); + s +} + +/// Aplica un `KeyEvent` al input. Devuelve `true` si tocó el estado. +/// +/// Delega en el **motor compartido** (`llimphi-widget-text-input`), que es el +/// que trae salto de palabra con selección, Home/End inteligente, undo/redo, +/// copiar-cortar-pegar sobre la selección viva e IME. Antes esto era una tabla +/// de teclas escrita a mano acá: funcionaba, pero cada capacidad nueva había que +/// escribirla otra vez y sólo la estrenaba shuma. +/// +/// No maneja Enter, Tab, ↑/↓ ni Ctrl-C: esos los intercepta el `update` del +/// módulo ANTES de llegar acá (ejecutar, completar, navegar historial, cancelar). +pub(crate) fn apply_key_to_line(input: &mut crate::InputShuma, ev: &KeyEvent) -> bool { + let mut clip = ClipboardSistema; + input + .ed_mut() + .handle_area(llimphi_widget_text_input::TextAreaEvent::Key(ev.clone()), &mut clip) +} + +pub fn update(state: State, msg: Msg) -> State { + let mut s = state; + match msg { + Msg::Key(ev) => { + if ev.state != KeyState::Pressed { + return s; + } + // COPY-MODE (puerta de teclado a la selección del output). Gana por + // encima de todo, incluso del PTY vivo: una vez adentro, las teclas + // mueven el caret y seleccionan; `Esc`/`q`/`y` salen. Y la entrada + // (`Ctrl+Shift+Espacio`) también gana sobre el reenvío al PTY. + if s.surf_copy_mode { + return copy_mode_key(s, &ev); + } + if es_entrar_copy_mode(&ev) { + return enter_copy_mode(s); + } + // Ctrl+Shift+C: copia la selección viva del output al portapapeles + // principal (con el comando del bloque si arranca en uno). Gana sobre + // el reenvío al PTY —donde Ctrl+C es SIGINT, por eso el copiar terminal + // es Ctrl+**Shift**+C— pero SÓLO si hay algo seleccionado; sin + // selección la tecla cae al PTY/input normal. + if ev.modifiers.ctrl + && ev.modifiers.shift + && matches!(&ev.key, llimphi_ui::Key::Character(c) if c.eq_ignore_ascii_case("c")) + && s.surf_selection.as_ref().is_some_and(|x| !x.is_empty()) + { + copy_surf_selection(&s); + return s; + } + // Si hay un TUI activo Y su canvas está a la vista, las teclas + // van al stdin del PTY (no al input). El usuario sale tipeando + // dentro del TUI (`:q` en vim, `q` en less, etc.). Con el canvas + // OCULTO (drawer plegado en pata) el PTY sigue corriendo pero NO + // se come el tipeo: la barra vuelve a ser un input normal. + // OJO: espejo `tui_skin_vivo`, NO `is_tui_active()` (try_lock): + // bajo streaming el gate perdía el lock y una tecla iba al PTY y + // la siguiente al editor de línea — input desfasado + autocomplete + // fantasma retemblando el panel. + if s.tui_skin_vivo.is_some() && s.canvas_visible { + // L2 — SALIDA DE EMERGENCIA del modo consola. `Ctrl+Shift+Esc` + // corta el run (SIGKILL) por encima del programa, antes de + // reenviar NADA al PTY — incluso en alt-screen (vim/htop). Sin + // esto, un programa que se queda todo el teclado —o un modal que + // no coopera— deja al usuario encerrado sin tecla de vuelta al + // shell (le pasó a sergio 3 veces en un día). Es el ÚNICO atajo + // que el gate NO reenvía; el resto de Ctrl/Esc son del programa. + if ev.modifiers.ctrl + && ev.modifiers.shift + && matches!(&ev.key, Key::Named(NamedKey::Escape)) + { + return cancel_running(s); + } + // Shift+Insert siempre pega. Ctrl-V también — en TUIs + // tipo less/vim no suele ser un binding (vim usa Ctrl-V + // para visual-block en normal mode; al editar dentro + // de insert mode tampoco). Si choca con un usuario + // específico, en el futuro lo gateamos por allowlist. + let paste = (ev.modifiers.ctrl + && matches!(&ev.key, Key::Character(c) if c.eq_ignore_ascii_case("v"))) + || (ev.modifiers.shift && matches!(&ev.key, Key::Named(NamedKey::Insert))); + if paste { + forward_paste_to_pty(&s); + return s; + } + // ALT-SCREEN (vim/htop): todo al PTY, como siempre. (Espejo + // del drain, no el try_lock — misma razón que el gate.) + if s.tui_altscreen_vivo { + forward_key_to_pty(&s, &ev); + return s; + } + // PTY INLINE (claude): modo CONSOLA — el input de shuma es el + // prompt (chip «● programa ↵»). El tipeo se edita en el input; + // Enter manda la línea entera al stdin; las teclas de CONTROL + // (Esc, Tab, ↑↓, PgUp/Dn, F*, y todo Ctrl/Alt) van directo al + // programa (claude las usa para menús/cancelar/atajos). + let de_control = !es_edicion_de_linea(&ev) + && !es_portapapeles(&ev) + && (ev.modifiers.ctrl + || ev.modifiers.alt + || matches!( + &ev.key, + Key::Named( + NamedKey::Escape + | NamedKey::Tab + | NamedKey::ArrowUp + | NamedKey::ArrowDown + | NamedKey::PageUp + | NamedKey::PageDown + | NamedKey::F1 + | NamedKey::F2 + | NamedKey::F3 + | NamedKey::F4 + | NamedKey::F5 + | NamedKey::F6 + | NamedKey::F7 + | NamedKey::F8 + | NamedKey::F9 + | NamedKey::F10 + | NamedKey::F11 + | NamedKey::F12 + ) + )); + if de_control { + forward_key_to_pty(&s, &ev); + return s; + } + if matches!(&ev.key, Key::Named(NamedKey::Enter)) && !ev.modifiers.shift { + // Enter: la línea viaja al stdin del programa (ver + // `enviar_linea_a_consola`). + return enviar_linea_a_consola(s); + } + // Resto (chars, backspace, ←→, Home/End, Shift+Enter): cae al + // camino normal — edita el INPUT de shuma, no el PTY. + } + // Cualquier tecla del input reancla el parpadeo del caret (queda + // sólido un instante y luego titila) — el input se siente vivo. + s.reanclar_caret(); + // Si la barra de find del cuerpo de output está abierta, las + // teclas van ahí (focus-grabbing). Esc cierra, Enter avanza, + // Shift+Enter retrocede, Backspace borra, chars editan la query. + if s.find.is_some() { + return handle_find_key(s, &ev); + } + // Si el overlay de búsqueda está abierto, las teclas van ahí. + if s.history_search.is_some() { + return handle_search_key(s, &ev); + } + // Ctrl+F: abre la barra de find del cuerpo de output (sólo en + // modo superficie; la barra se ignora en el camino viejo). + if ev.modifiers.ctrl + && matches!(&ev.key, Key::Character(c) if c.eq_ignore_ascii_case("f")) + { + s.find = Some(FindState::default()); + return s; + } + // Ctrl-A: seleccionar toda la línea del input. + if ev.modifiers.ctrl + && matches!(&ev.key, Key::Character(c) if c.eq_ignore_ascii_case("a")) + && !s.input.is_empty() + { + s.input.select_all(); + s.reanclar_caret(); + return s; + } + // Ctrl-Shift-C: copiar SIEMPRE (nunca cancela) — la selección del + // input o, si no hay, la del output (superficie). Paridad con las + // terminales modernas, donde Shift+C copia y Ctrl+C pelado manda + // SIGINT. Es la vía sin ambigüedad para «copiar el output». + if ev.modifiers.ctrl + && ev.modifiers.shift + && matches!(&ev.key, Key::Character(c) if c.eq_ignore_ascii_case("c")) + { + if let Some(sel) = s.input.selected_text() { + set_clipboard(&sel); + } else { + copy_surf_selection(&s); + } + return s; + } + // Ctrl-Shift-X: cortar la selección del input. La misma convención de + // terminal que el par C/V — y sobre el output no hace nada, porque el + // output es historia: no se corta lo que ya pasó. + if ev.modifiers.ctrl + && ev.modifiers.shift + && matches!(&ev.key, Key::Character(c) if c.eq_ignore_ascii_case("x")) + { + if let Some(sel) = s.input.selected_text() { + set_clipboard(&sel); + // `insert` reemplaza la selección viva y va por el motor, así + // que el corte entra al undo como una sola operación. + s.input.insert(""); + s.reanclar_caret(); + } + return s; + } + // Ctrl-C: si hay selección en el input, copiarla (no cancela). Si no, + // y hay una selección viva en el output y NO corre ningún comando, + // copiar el output (recuperar el «copiar del output»). Si corre algo, + // SIGTERM. Si no hay nada, no-op. + if ev.modifiers.ctrl + && matches!(&ev.key, Key::Character(c) if c.eq_ignore_ascii_case("c")) + { + if let Some(sel) = s.input.selected_text() { + set_clipboard(&sel); + return s; + } + if s.running.is_none() + && s.surf_selection.as_ref().map(|r| !r.is_empty()).unwrap_or(false) + { + copy_surf_selection(&s); + return s; + } + if s.running.is_some() { + return cancel_running(s); + } + } + // Ctrl-V (o Shift+Insert): pega del clipboard al input. + // (Si hay TUI, lo intercepta `is_tui_active` arriba; ese + // camino tiene su propio paste.) + let is_paste = (ev.modifiers.ctrl + && matches!(&ev.key, Key::Character(c) if c.eq_ignore_ascii_case("v"))) + || (ev.modifiers.shift && matches!(&ev.key, Key::Named(NamedKey::Insert))); + if is_paste { + if let Some(text) = read_clipboard() { + s.input.insert(&sanitize_paste(&text)); + refresh_completion(&mut s); + } + return s; + } + // Ctrl-R: abrir overlay de búsqueda de historial. + if ev.modifiers.ctrl + && matches!(&ev.key, Key::Character(c) if c.eq_ignore_ascii_case("r")) + { + s.history_search = Some(HistorySearch::default()); + return s; + } + // Popup de completado abierto (vivo, mientras tecleas): las teclas + // lo navegan. Tab/→ aceptan el resaltado; las flechas ↑↓ y + // Shift+Tab ciclan; Enter NO acepta — ejecuta el comando como + // está (el popup es una sugerencia, no un modal). + if s.completion.is_some() { + match &ev.key { + Key::Named(NamedKey::Tab) if ev.modifiers.shift => { + return cycle_completion(s, -1); + } + Key::Named(NamedKey::Tab) | Key::Named(NamedKey::ArrowRight) => { + return accept_completion(s); + } + Key::Named(NamedKey::ArrowDown) => return cycle_completion(s, 1), + Key::Named(NamedKey::ArrowUp) => return cycle_completion(s, -1), + Key::Named(NamedKey::Escape) => { + close_completion(&mut s); + return s; + } + // Enter acepta el resaltado (inserta el candidato o lanza la + // app) cuando es una elección efectiva: navegaste el popup + // (flechas/Tab/click) O el default resaltado es una app que + // aún estás completando ("nah" → «nahual»). El realce fuerte + // (acento) se pinta en esos mismos casos, así que "está + // marcado ⟺ Enter lo aplica". Escribir el comando entero + // ("vim"/"claude") NO extiende → Enter ejecuta lo tipeado tal + // cual y no secuestra el binario con su .desktop. + Key::Named(NamedKey::Enter) if enter_acepta_completion(&s) => { + return accept_completion(s); + } + // Enter cae al manejador de abajo (ejecuta); el resto de + // teclas cierra el popup y se procesa normal (lo + // reabriremos vivo tras la edición). + Key::Named(NamedKey::Enter) => close_completion(&mut s), + _ => close_completion(&mut s), + } + } + // F1..F8: ejecuta el grupo guardado de esa posición (`:save`). + // (F12 lo reserva el chasis para cerrar.) + if let Some(idx) = fkey_index(&ev.key) { + return run_group(s, idx); + } + // Enter: ejecuta — pero si el texto deja una construcción + // abierta (quote, paren, heredoc, `\` final, pipe pendiente), + // insertamos un salto de línea y seguimos editando. + // Shift+Enter fuerza salto de línea siempre. + if let Key::Named(NamedKey::Enter) = ev.key { + // Override de intención (cierra el ruteo sin-prefijos): Alt+Enter + // fuerza IA, Ctrl+Enter fuerza shell. Siempre SUBMITEA (no meten + // salto de línea, aunque haya construcción abierta). + let forzar_ia = ev.modifiers.alt; + let forzar_shell = ev.modifiers.ctrl; + let pending = shuma_line::needs_continuation(&s.input.text()); + if (pending || ev.modifiers.shift) && !forzar_ia && !forzar_shell { + s.input.insert("\n"); + s.history_cursor = None; + return s; + } + s.forced_intent = if forzar_ia { + Some(crate::intent::Intencion::Preguntar) + } else if forzar_shell { + Some(crate::intent::Intencion::Ejecutar) + } else { + None + }; + s.history_cursor = None; + s = run_submitted(s); + return s; + } + // Tab: completion. + if let Key::Named(NamedKey::Tab) = ev.key { + return apply_completion_msg(s); + } + // Up/Down: navegación de historial. + if let Key::Named(NamedKey::ArrowUp) = ev.key { + return navigate_history(s, shuma_history::Nav::Older); + } + if let Key::Named(NamedKey::ArrowDown) = ev.key { + return navigate_history(s, shuma_history::Nav::Newer); + } + // Flecha derecha al final de línea con ghost visible: acepta ghost. + // (Con shift extiende selección, así que no acepta.) + if let Key::Named(NamedKey::ArrowRight) = ev.key { + if !ev.modifiers.ctrl + && !ev.modifiers.shift + && s.input.cursor() == s.input.text().len() + { + if let Some(suffix) = current_ghost(&s) { + if !suffix.is_empty() { + s.input.insert(&suffix); + return s; + } + } + } + } + apply_key_to_line(&mut s.input, &ev); + // Cualquier edición rompe el cursor de navegación de historial. + s.history_cursor = None; + // Refresco vivo del popup de completado (as-you-type, estilo el + // shuma viejo): aparece solo mientras hay un prefijo a completar. + refresh_completion(&mut s); + } + Msg::FocusInput => { + s.focused = true; + // Volver a la "línea": el Enter arranca comandos nuevos, ya no + // alimenta el stdin de un job. + s.input_focus = None; + } + Msg::InputArea(ev) => { + // Todo gesto de mouse sobre el texto lo resuelve el motor compartido: + // press (posa el caret, escala a palabra/línea por doble/triple click), + // arrastre (extiende la selección) y click derecho. El mapeo + // click→carácter usa la MISMA métrica con la que se pintó, así que el + // caret cae entre los glifos que se ven — que es justo lo que un + // mapeo propio, calculado con un ancho de carácter supuesto, no podía + // garantizar al cambiar la fuente o el zoom. + let es_press = matches!( + ev, + llimphi_widget_text_input::TextAreaEvent::Press(_, _) + ); + if es_press { + s.focused = true; + // Volver a la "línea": el Enter arranca comandos nuevos, ya no + // alimenta el stdin de un job. + s.input_focus = None; + } + let mut clip = ClipboardSistema; + if s.input.ed_mut().handle_area(ev, &mut clip) { + // Caret sólido un instante al posarlo (misma ancla del tipeo). + s.reanclar_caret(); + } + } + Msg::BlurInput => { + // Apaga el cue de foco. `focused` es puramente visual (caret + marco + // brillante) + ruteo del Enter; no gatea el consumo de teclas, así + // que apagarlo no rompe nada si aún llegan teclas — sólo deja de + // mostrarse "activo" cuando ya no lo está. + s.focused = false; + s.input_focus = None; + } + Msg::ToggleMic => { + // Alterna: si escucha, pedir apagar; si no, pedir encender. El estado + // real (Esperando/Oyendo/…) lo fija el host según arranque cpal. + s.mic_intent = Some(!s.escucha.activo()); + } + Msg::Submit => { + // L1 — con un run de consola INLINE vivo (claude), el envío va a + // claude, no se encola como comando. Con el cajón PLEGADO, la tecla + // Enter llega como `Msg::Submit` (`press_key` de pata la arma así), y + // antes caía en `run_submitted`, que con un run vivo ENCOLA la línea + // como comando: el texto para claude se perdía en silencio (le pasó a + // sergio). Decisión de sergio (22-jul): mandar a claude y + // auto-expandir — pata ya despliega el drawer al ver este mismo + // `Msg::Submit` (`msg_is_submit`), así que acá sólo enrutamos. + if s.tui_skin_vivo.is_some() && !s.tui_altscreen_vivo { + return enviar_linea_a_consola(s); + } + // El botón «enviar» del input: submitea igual que Enter sin combos, + // sin meter salto aunque quede una construcción abierta (envío + // explícito). No-op con el input vacío (el botón ni siquiera aparece). + if !s.input.text().trim().is_empty() { + s.forced_intent = None; + s.history_cursor = None; + s = run_submitted(s); + } + } + Msg::PickCompletion(idx) => { + // Click en una fila del popup (host): resalta y acepta esa fila. + s = pick_completion(s, idx); + } + Msg::FocusJob(block) => { + s.focused = true; + // Sólo dirigimos el input a un comando que siga vivo; si ya + // cerró, el foco se queda en la línea (no apuntamos a un muerto). + if s.block_has_live_job(block) { + s.input_focus = Some(block); + } else if s.input_focus == Some(block) { + s.input_focus = None; + } + } + Msg::Clear => { + s.clear_output(); + } + Msg::ToggleBlock(id) => { + if !s.collapsed.remove(&id) { + s.collapsed.insert(id); + } + } + Msg::PushNotice(text) => { + s.push_output(OutputLine::notice(text)); + } + Msg::ZoomBy(factor) => { + if factor > 0.0 && factor.is_finite() { + let old = s.font_zoom; + s.font_zoom = (s.font_zoom * factor).clamp(0.5, 3.0); + if (s.font_zoom - old).abs() > f32::EPSILON { + // Notice visible para que el usuario confirme que el + // atajo le llegó. Sin esto, si el render no respeta + // el cambio (path TUI fullscreen) parece no funcionar. + s.push_output(OutputLine::notice(format!( + "🔍 zoom {:.0}% → {:.0}%", + old * 100.0, + s.font_zoom * 100.0 + ))); + } + } + } + Msg::ColaAlto(dpx) => { + // Arrastrar hacia arriba (dpx<0 del drag) agranda la cola. + s.cola_filas = (s.cola_filas + dpx / 17.0).clamp(5.0, 40.0); + return s; + } + Msg::ZoomReset => { + let old = s.font_zoom; + s.font_zoom = 1.0; + s.surf_scroll_x = 0.0; + if (old - 1.0).abs() > f32::EPSILON { + s.push_output(OutputLine::notice(format!( + "🔍 zoom {:.0}% → 100%", + old * 100.0 + ))); + } + } + Msg::ScrollHoriz(dx) => { + s.surf_scroll_x = (s.surf_scroll_x + dx).max(0.0); + } + Msg::ToggleSection { block, idx } => { + let key = (block, idx); + if !s.section_collapsed.remove(&key) { + s.section_collapsed.insert(key); + } + } + Msg::SortSectionColumn { block, section, col } => { + let key = (block, section); + // Cicla: ninguno → asc(col) → desc(col) → ninguno; + // si se clickeó otra columna, arranca asc en esa. + let next = match s.section_sort.get(&key).copied() { + None => Some((col, true)), + Some((prev_col, asc)) if prev_col == col => { + if asc { + Some((col, false)) + } else { + None + } + } + Some(_) => Some((col, true)), + }; + match next { + Some(v) => { + s.section_sort.insert(key, v); + } + None => { + s.section_sort.remove(&key); + } + } + } + Msg::Scroll(delta) => { + s = apply_scroll_delta(s, delta); + // Captura la última velocidad para el scroll inercial: el Tick + // sigue aplicando el delta con decay hasta epsilon (Fase 5.2). + s.surf_scroll_velocity = delta; + } + Msg::RunLine(line) => { + s.input.set_text(line); + s = run_submitted(s); + } + Msg::ToggleStage { block, stage } => { + let key = (block, stage); + if !s.expanded_stages.remove(&key) { + s.expanded_stages.insert(key); + } + } + Msg::SetReprocess(block) => { + // Toggle: re-armar el mismo bloque lo desarma. + if s.reprocess_source == Some(block) { + s.reprocess_source = None; + } else { + s.reprocess_source = Some(block); + s.focused = true; + } + } + Msg::RunGroup(idx) => { + s = run_group(s, idx); + } + Msg::AcceptChoreography(signature) => { + s = accept_choreography(s, &signature); + } + Msg::DismissChoreography(signature) => { + s.dismissed_choreo.insert(signature); + } + Msg::AcceptAlias(line) => { + s = accept_alias(s, &line); + } + Msg::DismissAlias(line) => { + s.dismissed_alias.insert(line); + } + Msg::AcceptDidYouMean(block) => { + if let Some(corregida) = s.did_you_mean.remove(&block) { + s.input.set_text(&corregida); + } + } + Msg::PrefillInput(text) => { + s.input.set_text(&text); + s.focused = true; + s.input_focus = None; // el Enter arranca un comando nuevo, no va a un job + } + Msg::InsertBlockRef(block) => { + // Apila la ref al final de lo ya tipeado: `grep error ` + `%c12`. + let actual = s.input.text().to_string(); + let sep = if actual.is_empty() || actual.ends_with(' ') { + "" + } else { + " " + }; + s.input.set_text(&format!("{actual}{sep}%c{block} ")); + } + Msg::CopyCommandBlock(block) => { + copy_command_block(&s, block); + } + Msg::CopyCommandOnly(block) => { + if let Some(cmd) = s.block_command.get(&block) { + set_clipboard(cmd); + } + } + Msg::CompareWith(block) => { + match s.compare_anchor { + None => s.compare_anchor = Some(block), // primer pick + Some(a) if a == block => s.compare_anchor = None, // toggle off + Some(a) => { + s.compare_anchor = None; + s.input.set_text(&format!(":compara %c{a} %c{block}")); + s = run_submitted(s); + } + } + } + Msg::Tick => { + // CR pendiente de un submit de consola: se manda AHORA (tick + // posterior al que mandó el texto) → claude lo lee como Enter real + // en una lectura separada, no como fin de pegado. Antes de drenar, + // para que el drain ya vea la respuesta que dispara el Enter. + if s.cr_pendiente { + send_consola_cr(&s); + s.cr_pendiente = false; + } + s = drain_run(s); + // Cierra el bin del cava: el caudal que entró en estos 100 ms pasa + // al anillo. Va DESPUÉS del drenaje (que es quien suma los bytes) y + // en cada tick, corra o no algo — el silencio también es señal. + s.pulso.muestrear(); + // Scroll inercial: si quedó velocidad de la última entrada del + // usuario, aplicar un paso y decaer por fricción (Fase 5.2 del + // SDD-TERMINAL). Hitting bottom (re-pin) detiene la inercia. + s = step_scroll_inertia(s); + } + Msg::Cancel => { + if s.running.is_some() { + s = cancel_running(s); + } + } + Msg::OpenDecoration(kind) => { + s = open_decoration(s, kind); + } + Msg::InsertAtCursor(text) => { + // Cerramos cualquier overlay activo para que el texto + // pegado quede visible sin tener que cerrar el Ctrl-R a mano. + s.history_search = None; + s.history_cursor = None; + s.input.insert(&text); + s.focused = true; + } + Msg::VimPaste => { + // Sólo aplica si hay un TUI vivo; `forward_paste_to_pty` es + // no-op silencioso si no. + forward_paste_to_pty(&s); + } + Msg::PrimaryPaste => { + // Botón medio: pega el cuasi-clipboard PRIMARY (lo último + // seleccionado). Si hay consola viva y visible, va al stdin del + // PTY; si no, al input de shuma (saneado como cualquier paste). + let text = clipboard::get_primary(); + if !text.is_empty() { + if s.tui_skin_vivo.is_some() && s.canvas_visible { + forward_text_to_pty(&s, &text); + } else { + s.input.insert(&clipboard::sanitize_paste(&text)); + s.focused = true; + } + } + } + Msg::VimDrag { + end, + dx, + dy, + ax, + ay, + } => { + let fresh = s.vim_sel.map_or(true, |v| !v.active); + if fresh { + s.vim_sel = Some(VimSel { + ax, + ay, + hx: ax + dx, + hy: ay + dy, + active: !end, + }); + } else if let Some(v) = s.vim_sel.as_mut() { + v.hx += dx; + v.hy += dy; + if end { + v.active = false; + } + } + if end { + // Umbral mínimo de drag: un click (o jitter sub-celda) no + // selecciona ni copia. Exige cruzar ~una celda para contar. + let dragged = s.vim_sel.is_some_and(|v| { + let (dx, dy) = (v.hx - v.ax, v.hy - v.ay); + (dx * dx + dy * dy).sqrt() >= crate::view::VIM_CHAR_W as f32 + }); + if dragged { + copy_vim_selection(&s); + } else { + s.vim_sel = None; + } + } + } + Msg::TuiMouseClick { button, lx, ly, rect_w, rect_h } => { + forward_tui_click_to_pty(&s, button, lx, ly, rect_w, rect_h); + } + Msg::TuiMouseWheel { dy, lx, ly, rect_w, rect_h } => { + forward_tui_wheel_to_pty(&s, dy, lx, ly, rect_w, rect_h); + } + Msg::SurfSelectDrag { phase, dx, dy, ax, ay } => { + s = apply_surf_select_drag(s, phase, dx, dy, ax, ay); + } + Msg::SurfClearSelection => { + s.surf_selection = None; + s.surf_selecting = false; + } + Msg::SurfCopySelection => { + copy_surf_selection(&s); + } + Msg::SurfDoubleClick { lx, ly, rect_w, rect_h } => { + // Auto-detect de triple-click: si llega otro double-click + // dentro de ~350 ms del previo, lo tratamos como triple + // (select-line). Paridad con xterm: tap-tap = word, + // tap-tap-tap-tap = line. La ventana de 350 ms cubre clicks + // humanos sin pisar interacciones reales. + const TRIPLE_WINDOW_MS: u64 = 350; + let now = now_unix_millis(); + let recent = now.saturating_sub(s.surf_last_dblclick_ms) < TRIPLE_WINDOW_MS; + s.surf_last_dblclick_ms = now; + if recent { + s = apply_surf_triple_click(s, lx, ly); + } else { + s = apply_surf_double_click(s, lx, ly, rect_w, rect_h); + } + // Copy-on-select estilo kitty: la palabra/línea recién seleccionada + // se copia sola (crudo, sin comando). + copy_surf_selection_raw(&s); + } + Msg::SurfOpenMenu { x, y } => { + s.surf_menu = Some((x, y)); + } + Msg::SurfMenuDismiss => { + s.surf_menu = None; + } + Msg::SurfMenuPick(idx) => { + s = apply_surf_menu_pick(s, idx); + } + Msg::FindOpen => { + s.find = Some(FindState::default()); + } + Msg::FindClose => { + if s.find.as_ref().and_then(|f| f.current).is_some() { + // Si la selección era el match resaltado, la limpiamos al + // cerrar — un Esc no debería dejar selección residual. + s.surf_selection = None; + } + s.find = None; + } + Msg::FindChar(c) => { + s = apply_find_edit(s, |q| q.push(c)); + } + Msg::FindBackspace => { + s = apply_find_edit(s, |q| { + q.pop(); + }); + } + Msg::FindNext => { + s = step_find(s, true); + } + Msg::FindPrev => { + s = step_find(s, false); + } + Msg::FindToggleCase => { + if let Some(f) = s.find.as_mut() { + f.case_insensitive = !f.case_insensitive; + } + s = recompute_find(s); + } + Msg::LlmResult { kind, ok, text } => { + s.llm_inflight = false; + if !ok { + // Una petición fallida descarta el header pendiente del bloque IA. + s.llm_block_label = None; + s.push_output(OutputLine::notice(format!("🜲 llm · {text}"))); + } else { + match kind { + LlmKind::Command => { + // Una sola línea, sin backticks/markdown que el modelo + // pueda colar. Va al input — el usuario revisa y Enter. + let line = text + .lines() + .map(|l| l.trim().trim_matches('`')) + .find(|l| !l.is_empty()) + .unwrap_or("") + .to_string(); + if line.is_empty() { + s.push_output(OutputLine::notice("🜲 llm · sin propuesta")); + } else { + s.input.set_text(&line); + s.focused = true; + s.push_output(OutputLine::notice( + "🜲 llm · propuesta en el input — revisa y Enter (no se ejecutó)", + )); + } + } + LlmKind::Text => { + // La respuesta es salida de primera clase: abre su propio + // bloque referenciable (`%cM`) y aterriza como `Ai`, para + // poder re-filtrarla, guardarla o encadenarla. + if text.trim().is_empty() { + s.llm_block_label = None; + s.push_output(OutputLine::notice("🜲 llm · sin respuesta")); + } else { + if let Some(header) = s.llm_block_label.take() { + s.push_output(OutputLine::prompt(header)); + } + for l in text.lines() { + s.push_output(OutputLine::ai(l)); + } + } + } + LlmKind::Atipay => s = resolver_atipay(s, &text), + } + } + } + Msg::SemanticResult { ok, hits } => { + s.semantic_inflight = false; + if !ok { + let msg = hits.first().map(|(t, _)| t.clone()).unwrap_or_default(); + s.push_output(OutputLine::notice(format!("🔎 búsqueda semántica · {msg}"))); + } else if hits.is_empty() { + s.push_output(OutputLine::notice("🔎 sin coincidencias por significado")); + } else { + s.push_output(OutputLine::notice("🔎 comandos por significado:")); + for (line, score) in hits { + s.push_output(OutputLine::stdout(format!(" {:>3}% {line}", (score * 100.0).round() as i32))); + } + } + } + } + s +} + +pub(crate) fn push_line(buf: &mut Vec, line: OutputLine) { + buf.push(line); + let len = buf.len(); + if len > MAX_OUTPUT_LINES { + buf.drain(0..len - MAX_OUTPUT_LINES); + } +} diff --git a/02_ruway/shuma/sandbox/shuma-module-shell/src/update/patterns.rs b/02_ruway/shuma/sandbox/shuma-module-shell/src/update/patterns.rs new file mode 100644 index 0000000..1396f98 --- /dev/null +++ b/02_ruway/shuma/sandbox/shuma-module-shell/src/update/patterns.rs @@ -0,0 +1,935 @@ +use super::*; + +/// Rama de git activa para `cwd` — `None` si no estamos en un repo (o si +/// HEAD está detached). Implementación minimalista por archivo: sube por +/// los padres buscando `.git`, lee `HEAD` y extrae `refs/heads/`. No +/// usa libgit2 ni lanza procesos (barato de llamar por frame). +pub(crate) fn git_branch(cwd: &std::path::Path) -> Option { + let mut dir = cwd.to_path_buf(); + let git_dir = loop { + let candidate = dir.join(".git"); + if candidate.exists() { + break candidate; + } + if !dir.pop() { + return None; + } + }; + // `.git` puede ser un archivo (worktrees/submódulos) con `gitdir: …`, + // o un directorio con `HEAD` dentro. + let head_path = if git_dir.is_file() { + let s = std::fs::read_to_string(&git_dir).ok()?; + let target = s.strip_prefix("gitdir:")?.trim(); + std::path::PathBuf::from(target).join("HEAD") + } else { + git_dir.join("HEAD") + }; + let head = std::fs::read_to_string(head_path).ok()?; + head.trim() + .strip_prefix("ref: refs/heads/") + .map(|b| b.to_string()) +} + +/// Marcadores de proyecto: archivos/dirs que identifican la "forma" de un +/// directorio. Gatean la predicción por estructura (no sugerir `cargo` sin +/// `Cargo.toml`). +pub(crate) const PROJECT_MARKERS: &[&str] = &[ + ".git", + "Cargo.toml", + "package.json", + "go.mod", + "Makefile", + "pyproject.toml", + "pom.xml", + "build.gradle", +]; + +/// Marcadores de proyecto presentes en `dir`. +fn markers_in(dir: &str) -> Vec { + let base = std::path::Path::new(dir); + PROJECT_MARKERS + .iter() + .filter(|m| base.join(m).exists()) + .map(|m| m.to_string()) + .collect() +} + +/// Ventana de historial reciente que alimenta la detección de patrones. La +/// minería corre en CADA submit (sincrónica dentro de `update`), y su costo +/// crece con el corpus —`detect_patterns` es O(N·len) en ventaneo + O(Q²) en el +/// filtro maximal—. Sobre un historial real grande (~5 k entradas) eso eran +/// ~150 ms por comando, que bloqueaban la UI. La predicción del próximo comando +/// es por naturaleza sobre el hábito RECIENTE: acotamos a las últimas +/// `PATTERN_HISTORY_WINDOW` entradas. Hábitos viejos que no recurren hace rato +/// dejan de predecir (deseable), y el costo queda acotado sin importar cuánto +/// crezca el historial total. +const PATTERN_HISTORY_WINDOW: usize = 1500; + +/// Cola de la sesión que mira `predict_next` para anticipar la próxima línea. +/// Las firmas de patrón llegan a `max_len` (5), así que con la última decena +/// alcanza; parsear sólo esto evita reconstruir el corpus entero en cada render +/// (el ghost se recalcula por frame). +const PREDICT_TAIL: usize = 12; + +// La ventana `GHOST_CORPUS_WINDOW` (2.000 entradas crudas) se retiró el +// 2026-07-25: el ghost ahora lee el corpus **deduplicado** de +// [`super::corpus`] (Fase 1 de `SDD-HISTORIAL.md`), que cubre el historial +// entero sin recortar por antigüedad. El motivo del recorte —no clonar N líneas +// por frame— lo cubre el filtrado por prefijo del corpus. + +/// Construye los `CommandRecord` de `shuma-infer` a partir de las últimas +/// `window` entradas del historial (éxito = exit 0). Acotar el `window` mantiene +/// el costo constante por más que el historial total crezca; los consumidores +/// piden el corpus que necesitan (la minería de patrones, [`PATTERN_HISTORY_WINDOW`]; +/// la predicción de la próxima línea, sólo la cola). +fn infer_records(s: &State, window: usize) -> Vec { + let Ok(history) = s.history.lock() else { + return Vec::new(); + }; + let entries = history.entries(); + let recent = &entries[entries.len().saturating_sub(window)..]; + recent + .iter() + // El historial Llimphi aún no graba el exit (siempre `None`): + // tratamos lo desconocido como éxito para no descartar todo el + // corpus. Si más adelante se registra el exit, los fallos + // (`Some(c!=0)`) quedan excluidos automáticamente. + .map(|e| { + let ok = e.exit.map_or(true, |c| c == 0); + shuma_infer::CommandRecord::parse(&e.line, e.cwd.clone(), ok) + }) + .collect() +} + +/// Recalcula los patrones emergentes del historial y los cachea en el +/// state. Se llama al cerrar cada comando (cuando el historial creció). +pub(crate) fn refresh_patterns(s: &mut State) { + let records = infer_records(s, PATTERN_HISTORY_WINDOW); + s.patterns = shuma_infer::detect_patterns(&records, &shuma_infer::InferConfig::default()); + // Mismo disparo para el corpus de sugerencias: el historial acaba de crecer. + // Es O(1) si no creció nada (compara la marca de agua y sale). + super::corpus::ensure(s); +} + +/// Condición de disparo de un patrón: los marcadores de proyecto comunes a +/// todos los directorios donde corrió. +fn pattern_trigger(p: &shuma_infer::EmergingPattern) -> Vec { + let mut dirs = p.directories.iter(); + let Some(first) = dirs.next() else { + return Vec::new(); + }; + let mut common = markers_in(first); + for d in dirs { + let here = markers_in(d); + common.retain(|m| here.contains(m)); + } + common +} + +/// Umbral de ocurrencias por defecto para ofrecer una coreografía como grupo +/// (A1): la hiciste al menos esto seguido para que valga la pena guardarla. +/// El shumarc lo gobierna con `[rules].on_pattern_score` (E3); `0` = nunca. +pub(crate) const CHOREO_OFFER_THRESHOLD: usize = 3; + +/// A1 — la coreografía que vale la pena ofrecer como grupo: el patrón de +/// mayor score (los `patterns` vienen ordenados desc) con ≥ umbral +/// ocurrencias que el usuario no descartó y que todavía no está guardado como +/// grupo (mismas líneas). El umbral sale de `[rules].on_pattern_score` (E3); +/// `0` lo apaga. `None` si no hay ninguno. El shell propone, el usuario acepta +/// con un click o ignora. +pub(crate) fn choreography_suggestion(s: &State) -> Option<&shuma_infer::EmergingPattern> { + let threshold = s.config.rules.on_pattern_score as usize; + if threshold == 0 { + return None; // regla apagada por el shumarc + } + s.patterns.iter().find(|p| { + p.occurrences >= threshold + && !s.dismissed_choreo.contains(&p.signature) + && !s.groups.iter().any(|g| g.lines == p.example) + }) +} + +/// A1 — promueve una coreografía emergente a grupo ejecutable: busca el patrón +/// por su `signature`, lo guarda como [`CommandGroup`] con su nombre sugerido y +/// las líneas reales de la última ocurrencia (`example`), y lo marca como +/// descartado para no re-ofrecerlo. Reemplaza un grupo homónimo si existe. La +/// firma queda en `dismissed_choreo` también para que el chip no reaparezca el +/// frame intermedio antes del próximo `refresh_patterns`. +pub(crate) fn accept_choreography(mut s: State, signature: &[String]) -> State { + let Some(p) = s.patterns.iter().find(|p| p.signature == signature) else { + return s; + }; + let name = p.suggested_name(); + let lines = p.example.clone(); + let n = lines.len(); + if let Some(g) = s.groups.iter_mut().find(|g| g.name == name) { + g.lines = lines; + } else { + s.groups.push(CommandGroup { name: name.clone(), lines }); + } + let fkey = s + .groups + .iter() + .position(|g| g.name == name) + .map(|i| i + 1) + .unwrap_or(0); + s.dismissed_choreo.insert(signature.to_vec()); + s.push_output(OutputLine::notice(format!( + "✔ coreografía «{name}» guardada como grupo ({n} comandos) — F{fkey} la ejecuta" + ))); + s +} + +/// A2 — una línea larga repetida que vale la pena acortar a un alias. Es el +/// gemelo de la coreografía (A1) pero sobre **una sola línea** en vez de una +/// secuencia: si tecleaste lo mismo, largo, varias veces, el shell ofrece +/// bautizarlo. +#[derive(Debug, Clone)] +pub(crate) struct AliasSuggestion { + /// La línea completa que se acortaría (el cuerpo del alias). + pub line: String, + /// Cuántas veces apareció idéntica en el historial. + pub count: usize, + /// El nombre corto propuesto (mnemónico de las iniciales, único). + pub name: String, +} + +/// A2 — largo mínimo de línea para que valga ofrecer un alias. Por debajo de +/// esto, el alias no ahorra teclas que importen. +pub(crate) const ALIAS_MIN_LEN: usize = 40; + +/// A2 — repeticiones idénticas mínimas para ofrecer el alias (mismo espíritu +/// que `CHOREO_OFFER_THRESHOLD`: lo hiciste suficiente para que valga un nombre). +pub(crate) const ALIAS_OFFER_THRESHOLD: usize = 3; + +/// A2 — mnemónico corto para una línea: las **iniciales** de sus tokens +/// significativos (saltea opciones `-x`/`--y` y operadores de shell), en +/// minúscula y sólo alfanuméricas. `git push origin main` → `gpom`. Si no +/// junta al menos 2 letras (línea de puras flags), cae al primer token entero. +/// Garantiza unicidad contra `taken` agregando un sufijo numérico. +pub(crate) fn suggest_alias_name(line: &str, taken: &dyn Fn(&str) -> bool) -> String { + let mut base: String = line + .split_whitespace() + .filter(|t| { + !t.starts_with('-') && !matches!(*t, "&&" | "||" | "|" | ";" | ">" | ">>" | "<") + }) + .filter_map(|t| t.chars().find(|c| c.is_ascii_alphanumeric())) + .map(|c| c.to_ascii_lowercase()) + .collect(); + if base.chars().count() < 2 { + // Línea de puras flags: usa el primer token entero (sólo alfanum). + base = line + .split_whitespace() + .next() + .unwrap_or("alias") + .chars() + .filter(char::is_ascii_alphanumeric) + .map(|c| c.to_ascii_lowercase()) + .collect(); + } + if base.is_empty() { + base = "alias".to_string(); + } + if !taken(&base) { + return base; + } + // Colisión: sufijo numérico determinista. + for n in 2.. { + let cand = format!("{base}{n}"); + if !taken(&cand) { + return cand; + } + } + unreachable!("la sucesión de sufijos es infinita") +} + +/// A2 — cuenta de líneas idénticas en el historial, sólo las externas (los +/// builtins `:x` no se aliasan). Devuelve `(línea, veces)` por línea distinta. +fn line_frequencies(s: &State) -> Vec<(String, usize)> { + let Ok(history) = s.history.lock() else { + return Vec::new(); + }; + let mut counts: std::collections::HashMap = std::collections::HashMap::new(); + for e in history.entries() { + let line = e.line.trim(); + if line.is_empty() || line.starts_with(':') { + continue; + } + *counts.entry(line.to_string()).or_insert(0) += 1; + } + counts.into_iter().collect() +} + +/// A2 — la línea larga repetida que más conviene aliasar: longitud ≥ +/// [`ALIAS_MIN_LEN`], al menos [`ALIAS_OFFER_THRESHOLD`] repeticiones idénticas, +/// que el usuario no descartó y que **todavía no es** el cuerpo de un alias +/// existente. Empata por más repeticiones, luego línea más larga, luego orden +/// lexicográfico (determinista). `None` si no hay candidata. El shell propone, +/// el usuario acepta con un click o ignora — gemelo de A1. +pub(crate) fn alias_suggestion(s: &State) -> Option { + let already_body: std::collections::HashSet<&str> = + s.config.aliases.values().map(String::as_str).collect(); + let mut best: Option<(String, usize)> = None; + for (line, count) in line_frequencies(s) { + if count < ALIAS_OFFER_THRESHOLD + || line.chars().count() < ALIAS_MIN_LEN + || s.dismissed_alias.contains(&line) + || already_body.contains(line.as_str()) + { + continue; + } + let better = match &best { + None => true, + Some((bl, bc)) => { + count > *bc + || (count == *bc && line.chars().count() > bl.chars().count()) + || (count == *bc && line.chars().count() == bl.chars().count() && line < *bl) + } + }; + if better { + best = Some((line, count)); + } + } + let (line, count) = best?; + let name = suggest_alias_name(&line, &alias_name_taken(s)); + Some(AliasSuggestion { line, count, name }) +} + +/// A2 — predicado «ese nombre ya está tomado»: por un alias existente o por un +/// binario real del PATH (no pisar un comando del sistema con un alias homónimo). +fn alias_name_taken(s: &State) -> impl Fn(&str) -> bool + '_ { + move |name: &str| { + if s.config.aliases.contains_key(name) { + return true; + } + use shuma_line::CompletionSource; + s.completion_source.commands().iter().any(|c| c == name) + } +} + +/// A2 — núcleo puro de aceptar un alias: lo agrega a la config viva (se expande +/// desde el próximo submit) y marca la línea descartada para no re-ofrecerla. +/// Sin efectos de disco — la persistencia al shumarc la hace [`accept_alias`]. +pub(crate) fn learn_alias(mut s: State, name: &str, line: &str) -> State { + s.config.aliases.insert(name.to_string(), line.to_string()); + s.dismissed_alias.insert(line.to_string()); + s +} + +/// A2 — acepta el alias para `line`: recalcula el nombre (determinista), lo +/// aprende a la config viva ([`learn_alias`]) y lo **persiste al shumarc** +/// (`[aliases]` vía `upsert_key`, preservando comentarios). Reemplaza un alias +/// homónimo sólo si apuntaba a la misma línea (no pisa uno del usuario). +pub(crate) fn accept_alias(s: State, line: &str) -> State { + let name = suggest_alias_name(line, &alias_name_taken(&s)); + let mut s = learn_alias(s, &name, line); + let mut learned = true; + if let Some(rc) = shuma_config::Config::default_path() { + if let Err(e) = shuma_config::upsert_key(&rc, "aliases", &name, &shuma_config::toml_string(line)) + { + learned = false; + s.push_output(OutputLine::notice(format!( + "alias «{name}» activo esta sesión — pero no se pudo guardar al shumarc: {e}" + ))); + } + } else { + learned = false; + } + if learned { + s.push_output(OutputLine::notice(format!( + "✔ alias «{name}» = «{line}» aprendido al shumarc — tipealo y se expande" + ))); + } + s +} + +/// Secuencias/grupos aplicables al **contexto actual**: los patrones emergentes +/// cuyo disparo por marcadores de proyecto (`Cargo.toml`, `.git`…) se cumple en +/// el cwd, ordenados por score (frecuencia × largo) desc. Devuelve +/// `(nombre_sugerido, línea_ejecutable, ocurrencias)`. Un patrón sin disparo +/// (corrió en directorios sin forma común) se incluye siempre — no hay contexto +/// que lo contradiga. Filtra los que ya están guardados como grupo idéntico +/// (esos se listan aparte como F-keys). +pub(crate) fn applicable_sequences(s: &State) -> Vec<(String, String, usize)> { + let here = markers_in(&s.cwd.to_string_lossy()); + s.patterns + .iter() + .filter(|p| { + let trigger = pattern_trigger(p); + trigger.is_empty() || trigger.iter().all(|m| here.contains(m)) + }) + .filter(|p| !s.groups.iter().any(|g| g.lines == p.example)) + .map(|p| (p.suggested_name(), p.example.join(" && "), p.occurrences)) + .collect() +} + +/// La secuencia que el motor predice como continuación de la sesión, si la +/// hay y el cwd comparte la forma del patrón. +pub(crate) fn predicted_sequence(s: &State) -> Option { + if s.patterns.is_empty() { + return None; + } + // `predict_next` sólo mira la cola de la sesión (los últimos comandos): no + // hace falta reconstruir todo el corpus, sólo esa cola. + let tail = infer_records(s, PREDICT_TAIL); + let (pi, next) = shuma_infer::predict_next(&tail, &s.patterns)?; + if next.is_empty() { + return None; + } + // Disparo por estructura: no anticipar un patrón en un directorio que + // no comparte su forma (no sugerir `cargo` sin `Cargo.toml`). + let trigger = pattern_trigger(&s.patterns[pi]); + if !trigger.is_empty() { + let here = markers_in(&s.cwd.to_string_lossy()); + if !trigger.iter().all(|m| here.contains(m)) { + return None; + } + } + Some(next.join(" && ")) +} + +/// Distancia de Damerau-Levenshtein restringida (optimal string alignment) +/// entre `a` y `b`: inserción/borrado/sustitución **y transposición de dos +/// caracteres adyacentes**, todas costo 1. La transposición barata atrapa el +/// typo clásico (`cagro` → `cargo`, distancia 1; en Levenshtein plano serían +/// 2). DP O(|a|·|b|) sobre caracteres Unicode, sin dependencias. +pub(crate) fn damerau_levenshtein(a: &str, b: &str) -> usize { + let a: Vec = a.chars().collect(); + let b: Vec = b.chars().collect(); + let (na, nb) = (a.len(), b.len()); + if na == 0 { + return nb; + } + if nb == 0 { + return na; + } + // `d[i][j]` = distancia entre `a[..i]` y `b[..j]`. Necesitamos `i-2`/`j-2` + // para la transposición, así que mantenemos la matriz completa. + let mut d = vec![vec![0usize; nb + 1]; na + 1]; + for (i, row) in d.iter_mut().enumerate() { + row[0] = i; + } + for j in 0..=nb { + d[0][j] = j; + } + for i in 1..=na { + for j in 1..=nb { + let cost = if a[i - 1] == b[j - 1] { 0 } else { 1 }; + let mut m = (d[i - 1][j] + 1) + .min(d[i][j - 1] + 1) + .min(d[i - 1][j - 1] + cost); + if i > 1 && j > 1 && a[i - 1] == b[j - 2] && a[i - 2] == b[j - 1] { + m = m.min(d[i - 2][j - 2] + 1); + } + d[i][j] = m; + } + } + d[na][nb] +} + +/// El candidato más cercano a `bad` dentro de `cands` con distancia en +/// `1..=umbral` (excluye 0 = el mismo token). Empata por menor distancia y, +/// a igual distancia, por orden lexicográfico (determinista). `None` si +/// ninguno entra en el umbral. +fn closest_within<'a>(bad: &str, umbral: usize, cands: impl Iterator) -> Option { + let mut best: Option<(usize, &str)> = None; + for cand in cands { + if cand == bad || cand.is_empty() { + continue; + } + let dd = damerau_levenshtein(bad, cand); + if dd == 0 || dd > umbral { + continue; + } + let better = match best { + None => true, + Some((bd, bc)) => dd < bd || (dd == bd && cand < bc), + }; + if better { + best = Some((dd, cand)); + } + } + best.map(|(_, c)| c.to_string()) +} + +/// A4 — detecta el caso «¿quisiste decir…?» al cerrar un comando: si su salida +/// trae `command not found`, busca el binario más cercano al primer token de +/// la línea. **Prioriza el historial** (lo que el usuario realmente corre) +/// sobre el PATH crudo; ambos con umbral `max(1, len/3)`. Si hay candidato, +/// guarda en `s.did_you_mean[block]` la línea corregida. Sin modelo, sin red. +pub(crate) fn detect_did_you_mean(s: &mut State, block: u64) { + let has_cnf = s.output.iter().any(|l| { + l.block == block + && l.kind == OutputKind::Stderr + && l.text.to_ascii_lowercase().contains("command not found") + }); + if !has_cnf { + return; + } + // Línea original (sin el prefijo "$ " del header). + let Some(raw) = s.block_command.get(&block).cloned() else { + return; + }; + let cmd = raw.trim_start_matches("$ ").trim(); + let mut toks = cmd.splitn(2, char::is_whitespace); + let Some(bad) = toks.next() else { + return; + }; + let rest = toks.next().unwrap_or(""); + // Un path explícito (`./x`, `/usr/bin/x`) no es un typo de binario del PATH. + if bad.is_empty() || bad.contains('/') { + return; + } + let umbral = (bad.chars().count() / 3).max(1); + + // 1) Historial: primer token de cada línea (sin paths), señal fuerte. + let hist_bins: Vec = match s.history.lock() { + Ok(h) => h + .entries() + .iter() + .filter_map(|e| e.line.split_whitespace().next()) + .filter(|t| !t.contains('/')) + .map(String::from) + .collect(), + Err(_) => Vec::new(), + }; + let pick = closest_within(bad, umbral, hist_bins.iter().map(String::as_str)) + // 2) Fallback: binarios del PATH. + .or_else(|| { + use shuma_line::CompletionSource; + let path_bins = s.completion_source.commands(); + closest_within(bad, umbral, path_bins.iter().map(String::as_str)) + }); + + if let Some(cand) = pick { + let corregida = if rest.is_empty() { + cand + } else { + format!("{cand} {rest}") + }; + s.did_you_mean.insert(block, corregida); + } +} + +/// Una predicción de comando: la línea, su score combinado y los componentes +/// que lo explican (para mostrarle al usuario el porqué). +#[derive(Debug, Clone, PartialEq)] +pub(crate) struct CommandPrediction { + /// La línea de comando predicha. + pub line: String, + /// Score combinado (frecuencia + afinidad de cwd + recencia). + pub score: u64, + /// Veces totales en el historial. + pub freq: usize, + /// Veces en el cwd actual (o sus hijos) — el peso del "directorio actual". + pub cwd_freq: usize, +} + +/// Peso del directorio actual: una línea usada EN el cwd vale este múltiplo de +/// una usada en cualquier lado. Es el corazón del "depende del directorio". +const CWD_AFFINITY_WEIGHT: u64 = 3; + +/// Rankea las líneas del historial como predicción del próximo comando, +/// combinando tres señales que el usuario pidió: +/// - **frecuencia**: cuántas veces se usó (señal base); +/// - **directorio actual**: las corridas en el cwd (o sus hijos) pesan +/// [`CWD_AFFINITY_WEIGHT`]× (el "contexto" del lugar); +/// - **recencia**: un bono si la última corrida fue de las más recientes. +/// +/// Excluye builtins `:x` y líneas vacías. Determinista: empata por score, luego +/// por más reciente, luego lexicográfico. Puro sobre el slice del historial +/// (testeable sin disco). `cwd` es la ruta actual como string. +pub(crate) fn rank_command_predictions( + entries: &[shuma_history::Entry], + cwd: &str, + limit: usize, +) -> Vec { + use std::collections::HashMap; + // line → (freq, cwd_freq, índice de la última aparición). + let mut acc: HashMap<&str, (usize, usize, usize)> = HashMap::new(); + for (i, e) in entries.iter().enumerate() { + let line = e.line.trim(); + if line.is_empty() || line.starts_with(':') { + continue; + } + let entry = acc.entry(line).or_insert((0, 0, 0)); + entry.0 += 1; + if cwd_within(&e.cwd, cwd) { + entry.1 += 1; + } + entry.2 = i; + } + let n = entries.len(); + let mut preds: Vec = acc + .into_iter() + .map(|(line, (freq, cwd_freq, last_idx))| { + // Recencia: 0 = la más reciente del historial. + let recency_rank = n.saturating_sub(1).saturating_sub(last_idx); + let recency_bonus: u64 = if recency_rank < 10 { + 2 + } else if recency_rank < 50 { + 1 + } else { + 0 + }; + let score = cwd_freq as u64 * CWD_AFFINITY_WEIGHT + freq as u64 + recency_bonus; + CommandPrediction { + line: line.to_string(), + score, + freq, + cwd_freq, + } + }) + .collect(); + // Orden: score desc, luego más reciente (mayor last_idx implícito en el + // recency, lo recomputamos por línea para el desempate), luego lexicográfico. + // Para el desempate por recencia reusamos el score que ya lo incorpora; a + // score igual, lexicográfico estable. + preds.sort_by(|a, b| { + b.score + .cmp(&a.score) + .then_with(|| b.cwd_freq.cmp(&a.cwd_freq)) + .then_with(|| a.line.cmp(&b.line)) + }); + preds.truncate(limit); + preds +} + +/// `true` si `entry_cwd` cae dentro de `base` (es el mismo directorio o un +/// hijo) — el criterio de "contexto" del ghost por cwd (A3). +pub(crate) fn cwd_within(entry_cwd: &str, base: &str) -> bool { + entry_cwd == base + || entry_cwd + .strip_prefix(base) + .is_some_and(|rest| rest.starts_with('/')) +} + +/// Sugerencia "ghost" para la línea actual — la secuencia predicha por el +/// motor de patrones (si aplica) y, tras ella, el prefijo histórico más +/// reciente que extiende el texto que ya está tipeado. +/// +/// A3 — **ghost contextual por cwd:** el historial se rankea en dos tramos, +/// primero las entradas del directorio actual (y sus hijos), después lo +/// global. En un monorepo el ghost deja de sugerir comandos de otro proyecto: +/// `cargo b…` en `cosmos/` completa al último build de cosmos, no al de wawa. +/// Dentro de cada tramo, lo más reciente primero. +pub(crate) fn current_ghost(s: &State) -> Option { + let texto_input = s.input.text(); + let text = texto_input.as_str(); + // Con el input VACÍO y una respuesta SUGERIDA por claude a la vista, el ghost + // es esa sugerencia entera; `→` la acepta (la mete en la barra) y Enter la + // envía — la misma función que el → del CLI, pero acá arriba. + if text.is_empty() { + return s.claude_sugerencia.clone(); + } + // En la consola de claude se escribe PROSA, no comandos: completar por + // prefijo desde el historial proponía la cola del mensaje anterior y se + // mezclaba con lo que el usuario estaba escribiendo (reportado 2026-07-21, + // dos mensajes llegaron con fragmentos del anterior incrustados). Ahí el + // único fantasma legítimo es la respuesta sugerida entera, y esa sólo se + // ofrece con el input vacío (arriba). + if matches!(s.tui_skin_vivo, Some(crate::AppSkin::Claude)) { + return None; + } + if s.input.cursor() != text.len() { + return None; + } + // Corpus por prioridad: secuencia predicha primero, luego el corpus + // deduplicado del historial (local al cwd antes que global). Ya viene + // filtrado por prefijo, así que esto es un puñado de líneas y no una copia + // del historial por frame — antes se clonaba la ventana de 2.000. + let mut corpus: Vec = Vec::new(); + if let Some(seq) = predicted_sequence(s) { + corpus.push(seq); + } + corpus.extend(super::corpus::matches_por_prioridad(s, text)); + shuma_line::ghost_suggestion(text, &corpus) +} + +#[cfg(test)] +mod a1_choreo_tests { + use super::*; + + /// Construye un State con la coreografía `git pull → cargo build → cargo + /// test` repetida 3 veces, **separada por comandos distintos** (como en el + /// uso real) para que las ventanas largas solapadas no subsuman el patrón + /// base. Queda ya inferida en `s.patterns`. + fn state_con_patron() -> State { + let mut s = State::new(shuma_module::Source::Local); + let rec = |l: &str| shuma_infer::CommandRecord::parse(l, "/repo", true); + let records = vec![ + rec("git pull"), + rec("cargo build"), + rec("cargo test"), + rec("ls"), // separador 1 + rec("git pull"), + rec("cargo build"), + rec("cargo test"), + rec("cd /tmp"), // separador 2 (distinto → corta ventanas largas) + rec("git pull"), + rec("cargo build"), + rec("cargo test"), + ]; + s.patterns = + shuma_infer::detect_patterns(&records, &shuma_infer::InferConfig::default()); + s + } + + #[test] + fn ofrece_coreografia_sobre_umbral() { + let s = state_con_patron(); + let sug = choreography_suggestion(&s).expect("hay sugerencia"); + assert!(sug.occurrences >= CHOREO_OFFER_THRESHOLD); + assert_eq!(sug.suggested_name(), "git+cargo+cargo"); + } + + #[test] + fn aceptar_crea_grupo_y_calla_la_oferta() { + let mut s = state_con_patron(); + let sig = choreography_suggestion(&s).unwrap().signature.clone(); + s = accept_choreography(s, &sig); + // Quedó un grupo con las líneas reales de la última ocurrencia. + assert_eq!(s.groups.len(), 1); + assert_eq!( + s.groups[0].lines, + vec!["git pull", "cargo build", "cargo test"] + ); + // Y ya no se vuelve a ofrecer (guardado + descartado). + assert!(choreography_suggestion(&s).is_none()); + } + + #[test] + fn descartar_calla_la_oferta() { + let mut s = state_con_patron(); + let sig = choreography_suggestion(&s).unwrap().signature.clone(); + s.dismissed_choreo.insert(sig); + assert!(choreography_suggestion(&s).is_none()); + } +} + +#[cfg(test)] +mod a2_alias_tests { + use super::*; + + /// State con un historial **aislado** (sobre `/dev/null`, in-memory) — el + /// `State::new` normal abre el historial real del disco; en tests eso lo + /// contamina y, peor, persiste los `append`. Aquí cada State arranca vacío. + fn state_aislado() -> State { + let mut s = State::new(shuma_module::Source::Local); + s.history = Arc::new(Mutex::new( + shuma_history::History::open(std::path::PathBuf::from("/dev/null")) + .expect("/dev/null como history vacío"), + )); + s + } + + /// Una línea larga (≥ 40 chars) tecleada `n` veces en el historial, + /// **separada por otro comando** (el historial deduplica consecutivos — + /// como en el uso real: corres algo, haces otra cosa, lo vuelves a correr). + fn state_con_linea_repetida(line: &str, n: usize) -> State { + let mut s = state_aislado(); + { + let mut h = s.history.lock().unwrap(); + for i in 0..n { + let _ = h.append(shuma_history::Entry::new(line, "/repo", (2 * i) as u64)); + let _ = h.append(shuma_history::Entry::new("ls", "/repo", (2 * i + 1) as u64)); + } + } + s + } + + #[test] + fn ofrece_alias_para_linea_larga_repetida() { + let line = "git push origin feature/inteligencia-shuma --force-with-lease"; + assert!(line.chars().count() >= ALIAS_MIN_LEN); + let s = state_con_linea_repetida(line, ALIAS_OFFER_THRESHOLD); + let sug = alias_suggestion(&s).expect("hay alias que ofrecer"); + assert_eq!(sug.line, line); + assert_eq!(sug.count, ALIAS_OFFER_THRESHOLD); + // Iniciales de los tokens no-flag: git push origin feature… → gpof. + assert_eq!(sug.name, "gpof"); + } + + #[test] + fn no_ofrece_si_es_corta_o_poco_repetida() { + // Corta aunque repetida. + let s = state_con_linea_repetida("ls -la", 5); + assert!(alias_suggestion(&s).is_none()); + // Larga pero por debajo del umbral. + let larga = "kubectl get pods --all-namespaces -o wide --watch"; + let s = state_con_linea_repetida(larga, ALIAS_OFFER_THRESHOLD - 1); + assert!(alias_suggestion(&s).is_none()); + } + + #[test] + fn no_ofrece_builtins_ni_lo_descartado_ni_lo_ya_aliasado() { + // Los builtins `:x` no se aliasan, por largos que sean. + let builtin = ":macro save deploy cargo build --bin %1 && scp %1 %2:/srv/app"; + let s = state_con_linea_repetida(builtin, 5); + assert!(alias_suggestion(&s).is_none()); + + // Descartada → no se vuelve a ofrecer. + let line = "docker run --rm -it -v $PWD:/work -w /work rust:latest cargo test"; + let mut s = state_con_linea_repetida(line, 4); + assert!(alias_suggestion(&s).is_some()); + s.dismissed_alias.insert(line.to_string()); + assert!(alias_suggestion(&s).is_none()); + + // Ya es cuerpo de un alias → tampoco. + let mut s = state_con_linea_repetida(line, 4); + s.config.aliases.insert("dt".into(), line.to_string()); + assert!(alias_suggestion(&s).is_none()); + } + + #[test] + fn aceptar_aprende_a_la_config_viva_y_descarta() { + // Núcleo puro (sin tocar el shumarc del usuario): `learn_alias` es lo + // que `accept_alias` hace antes de persistir. + let line = "cargo build --release --target x86_64-unknown-none -Zbuild-std"; + let mut s = state_con_linea_repetida(line, 3); + let name = alias_suggestion(&s).unwrap().name; + s = learn_alias(s, &name, line); + // El alias quedó en la config viva (se expande desde el próximo submit)… + assert_eq!(s.config.aliases.get(&name).map(String::as_str), Some(line)); + // …y ya no se vuelve a ofrecer. + assert!(alias_suggestion(&s).is_none()); + } + + #[test] + fn nombre_evita_colisiones() { + // Iniciales de los tokens no-flag: grep · TODO · src → "gts". + let line = "grep --color=always -rn TODO --include='*.rs' src/"; + assert_eq!(suggest_alias_name(line, &|_| false), "gts"); + // Si "gts" ya está tomado (alias homónimo), debe sufijar sin pisarlo. + let mut s = State::new(shuma_module::Source::Local); + s.config.aliases.insert("gts".into(), "otra cosa".into()); + let name = suggest_alias_name(line, &alias_name_taken(&s)); + assert_ne!(name, "gts"); + assert!(name.starts_with("gts")); + } + + #[test] + fn nombre_para_linea_de_puras_flags_cae_al_primer_token() { + // Sin tokens significativos para iniciales → usa el primer token entero. + let line = "tar -czvf backup-2026-06-13.tar.gz --exclude=target ./proyecto"; + let name = suggest_alias_name(line, &|_| false); + // El primer token con letra es "tar" (tcp… serían las iniciales reales: + // tar backup proyecto → "tbp"); verificamos que sea no vacío y alfanum. + assert!(!name.is_empty()); + assert!(name.chars().all(|c| c.is_ascii_alphanumeric())); + } +} + +#[cfg(test)] +mod a3_ghost_cwd_tests { + use super::*; + + #[test] + fn cwd_within_reconoce_mismo_dir_e_hijos() { + assert!(cwd_within("/repo", "/repo")); + assert!(cwd_within("/repo/sub", "/repo")); + assert!(cwd_within("/repo/a/b", "/repo")); + assert!(!cwd_within("/repo-otro", "/repo")); // prefijo de string, no de path + assert!(!cwd_within("/otro", "/repo")); + } + + #[test] + fn prediccion_pondera_cwd_sobre_frecuencia_pelada() { + // `git status` se usó 5× en /otro; `cargo test` 2× pero en /repo (el cwd). + // La afinidad de cwd (×3) debe poner a `cargo test` arriba. + let e = |line: &str, cwd: &str, t: u64| shuma_history::Entry::new(line, cwd, t); + let entries = vec![ + e("git status", "/otro", 1), + e("git status", "/otro", 2), + e("git status", "/otro", 3), + e("git status", "/otro", 4), + e("git status", "/otro", 5), + e("cargo test", "/repo", 6), + e("cargo test", "/repo/sub", 7), + ]; + let preds = rank_command_predictions(&entries, "/repo", 5); + assert_eq!(preds[0].line, "cargo test"); + // cargo test: cwd_freq 2 (×3) + freq 2 + recencia 2 = 10; git: 0 + 5 + 0 = 5. + assert!(preds[0].score > preds[1].score, "{preds:?}"); + assert_eq!(preds[0].cwd_freq, 2); + } + + #[test] + fn prediccion_excluye_builtins_y_vacios() { + let e = |line: &str| shuma_history::Entry::new(line, "/repo", 1); + let entries = vec![e(":stats"), e(" "), e("ls -la"), e("ls -la")]; + let preds = rank_command_predictions(&entries, "/repo", 5); + assert_eq!(preds.len(), 1); + assert_eq!(preds[0].line, "ls -la"); + assert_eq!(preds[0].freq, 2); + } + + #[test] + fn ghost_prefiere_el_cwd_actual_sobre_lo_mas_reciente() { + let mut s = State::new(shuma_module::Source::Local); + s.cwd = std::path::PathBuf::from("/repo"); + { + let mut h = s.history.lock().unwrap(); + // Local al cwd, más viejo. + let _ = h.append(shuma_history::Entry::new("cargo build --debug", "/repo", 1)); + // Global (otro proyecto), más reciente → ganaría por recencia. + let _ = h.append(shuma_history::Entry::new("cargo build --release", "/otro", 2)); + } + s.input.set_text("cargo bu"); + // A3: el del cwd actual manda, aunque sea más viejo. + assert_eq!(current_ghost(&s).as_deref(), Some("ild --debug")); + } +} + +#[cfg(test)] +mod a4_did_you_mean_tests { + use super::*; + + #[test] + fn damerau_atrapa_transposicion() { + assert_eq!(damerau_levenshtein("cagro", "cargo"), 1); // transposición + assert_eq!(damerau_levenshtein("cargo", "cargo"), 0); + assert_eq!(damerau_levenshtein("gti", "git"), 1); + assert_eq!(damerau_levenshtein("ls", "ls"), 0); + } + + fn state_con_fallo(cmd: &str) -> State { + let mut s = State::new(shuma_module::Source::Local); + // El usuario ya corrió el binario bueno antes (señal del historial). + { + let mut h = s.history.lock().unwrap(); + let _ = h.append(shuma_history::Entry::new("cargo build", "/repo", 1)); + } + // Bloque 5 con el comando tipeado y su stderr de "command not found". + s.block_command.insert(5, format!("$ {cmd}")); + let mut err = OutputLine::stderr("zsh: command not found: cagro"); + err.block = 5; + s.output.push(err); + s + } + + #[test] + fn ofrece_correccion_desde_historial() { + let mut s = state_con_fallo("cagro build --release"); + detect_did_you_mean(&mut s, 5); + assert_eq!(s.did_you_mean.get(&5).map(String::as_str), Some("cargo build --release")); + } + + #[test] + fn no_ofrece_sin_command_not_found() { + let mut s = State::new(shuma_module::Source::Local); + s.block_command.insert(5, "$ cagro build".to_string()); + let mut err = OutputLine::stderr("error: some other failure"); + err.block = 5; + s.output.push(err); + detect_did_you_mean(&mut s, 5); + assert!(s.did_you_mean.get(&5).is_none()); + } + + #[test] + fn no_ofrece_para_un_path_explicito() { + let mut s = state_con_fallo("./cagro build"); + detect_did_you_mean(&mut s, 5); + assert!(s.did_you_mean.get(&5).is_none()); + } +} diff --git a/02_ruway/shuma/sandbox/shuma-module-shell/src/update/pty.rs b/02_ruway/shuma/sandbox/shuma-module-shell/src/update/pty.rs new file mode 100644 index 0000000..22fb7ce --- /dev/null +++ b/02_ruway/shuma/sandbox/shuma-module-shell/src/update/pty.rs @@ -0,0 +1,569 @@ +use super::*; +use crate::mouse_xterm::{encode, local_to_cell, XBtn, XPhase}; + +/// `true` si hay un `ActiveRun` con PTY vivo. Las teclas van al stdin del +/// PTY mientras esto sea cierto (el programa es interactivo, esté o no en +/// pantalla completa). El **render** en cambio sigue a [`is_tui_fullscreen`]. +/// +/// **No-blocking**: usa `try_lock`. Si el lector del PTY tiene el mutex en +/// este instante (drenando una ráfaga grande de output, p. ej. `ls -alR`), +/// volvemos `false` antes que pasmar el thread de pintura: pintar `false` +/// un frame de más es indistinguible de "todavía no llegó el dato", pero +/// bloquear el render durante una ráfaga deja la pantalla negra. +pub(crate) fn is_tui_active(s: &State) -> bool { + let Some(arc) = s.running.as_ref() else { + return false; + }; + let g = match arc.try_lock() { + Ok(g) => g, + Err(_) => return false, + }; + g.tui.is_some() +} + +/// `true` si el PTY vivo entró a **alternate screen** (`ESC[?1049h`) — la +/// señal dura de una app TUI de pantalla completa (vim, htop, less, man…). +/// Es lo que decide pintar el panel full-screen (grid/vim) en vez de las +/// líneas. Al salir del alt-screen (`ESC[?1049l`) vuelve a modo líneas. +/// +/// Misma política `try_lock` que [`is_tui_active`]: ante contienda, `false` +/// — el render cae al pane de cards (que sí usa data ya volcada a +/// `state.output`) y nunca se pasma esperando al lector del PTY. +pub(crate) fn is_tui_fullscreen(s: &State) -> bool { + let Some(arc) = s.running.as_ref() else { + return false; + }; + let g = match arc.try_lock() { + Ok(g) => g, + Err(_) => return false, + }; + g.tui + .as_ref() + .map(|t| t.parser.screen().alternate_screen()) + .unwrap_or(false) +} + +/// El `AppSkin` del run vivo (si hay PTY/TUI), para el aviso visual. Misma +/// política `try_lock` que [`is_tui_fullscreen`]: ante contienda, `None`. +pub(crate) fn running_skin(s: &State) -> Option { + let arc = s.running.as_ref()?; + let g = arc.try_lock().ok()?; + g.tui.as_ref().map(|t| t.skin) +} + +/// Contenido de la pantalla del PTY vivo cuando está en **modo líneas** +/// (PTY presente, sin alt-screen). Devuelve las filas como texto (sin +/// formato), recortando las filas vacías del final. `None` si no hay PTY +/// o está en pantalla completa (ese caso lo pinta el panel full-screen). +/// Las salidas de programas que no toman la pantalla (p. ej. `watch`) se +/// leen así como texto normal en vez de una grilla apretada. +pub(crate) fn pty_line_text(s: &State) -> Option> { + let arc = s.running.as_ref()?; + let g = match arc.lock() { + Ok(g) => g, + Err(p) => p.into_inner(), + }; + let tui = g.tui.as_ref()?; + let screen = tui.parser.screen(); + if screen.alternate_screen() { + return None; + } + Some(screen_to_lines(screen)) +} + +/// Filas del PTY en modo líneas + los **spans de estilo** por fila (color +/// fg/bg + bold/italic desde las celdas del vt100) — para que claude se +/// pinte con sus colores en el panel de líneas. Misma semántica de recorte +/// que [`pty_line_text`]; `None` si no hay PTY o está en alt-screen. +pub(crate) fn pty_lines_estilizadas( + s: &State, + theme: &llimphi_theme::Theme, +) -> Option<(Vec, Vec>)> { + let arc = s.running.as_ref()?; + let g = match arc.lock() { + Ok(g) => g, + Err(p) => p.into_inner(), + }; + let tui = g.tui.as_ref()?; + let screen = tui.parser.screen(); + if screen.alternate_screen() { + return None; + } + let (rows, cols) = screen.size(); + let mut textos: Vec = Vec::with_capacity(rows as usize); + let mut estilos: Vec> = Vec::new(); + for r in 0..rows { + let mut texto = String::new(); + let mut spans: Vec = Vec::new(); + let mut col_char = 0usize; + let mut run: Option<(usize, vt100::Color, vt100::Color, bool, bool)> = None; + let mut cerrar = |run: &mut Option<(usize, vt100::Color, vt100::Color, bool, bool)>, + hasta: usize, + spans: &mut Vec| { + if let Some((desde, fg, bg, bold, italic)) = run.take() { + let es_default = matches!(fg, vt100::Color::Default) + && matches!(bg, vt100::Color::Default) + && !bold + && !italic; + if !es_default && hasta > desde { + spans.push(llimphi_widget_text_editor::StyledSpan { + start_col: desde, + end_col: hasta, + fg: (!matches!(fg, vt100::Color::Default)) + .then(|| crate::view::vt_color(fg, *theme, false)), + bg: (!matches!(bg, vt100::Color::Default)) + .then(|| crate::view::vt_color(bg, *theme, true)), + font_family: None, + size_px: None, + weight: bold.then_some(700.0), + italic: italic.then_some(true), + underline: None, + strikethrough: None, + }); + } + } + }; + for c in 0..cols { + let Some(cell) = screen.cell(r, c) else { continue }; + // Continuación de un glifo ancho: no avanza columnas de texto. + if cell.is_wide_continuation() { + continue; + } + let contents = cell.contents(); + // OJO: una celda NUNCA escrita devuelve "" pero SE MUESTRA como + // espacio — saltearla pegaba las palabras ("parrafosconpalabras"). + let contents: &str = if contents.is_empty() { " " } else { contents }; + let ancho = cell.is_wide(); + let clave = (cell.fgcolor(), cell.bgcolor(), cell.bold(), cell.italic()); + let coincide = run + .as_ref() + .is_some_and(|(_, f, b, bo, it)| *f == clave.0 && *b == clave.1 && *bo == clave.2 && *it == clave.3); + if !coincide { + cerrar(&mut run, col_char, &mut spans); + run = Some((col_char, clave.0, clave.1, clave.2, clave.3)); + } + texto.push_str(&contents); + col_char += contents.chars().count(); + } + cerrar(&mut run, col_char, &mut spans); + textos.push(texto.trim_end().to_string()); + estilos.push(spans); + } + while textos.last().is_some_and(|l| l.is_empty()) { + textos.pop(); + estilos.pop(); + } + if textos.is_empty() { + textos.push(String::new()); + estilos.push(Vec::new()); + } + Some((textos, estilos)) +} + +/// Filas de un `vt100::Screen` como texto sin formato, recortando las +/// filas vacías del final. Pura (sin State) para poder testearla con un +/// parser construido a mano. +pub(crate) fn screen_to_lines(screen: &vt100::Screen) -> Vec { + let (_rows, cols) = screen.size(); + let mut lines: Vec = screen + .rows(0, cols) + .map(|r| r.trim_end().to_string()) + .collect(); + while lines.last().map(|l| l.is_empty()).unwrap_or(false) { + lines.pop(); + } + lines +} + +/// Traduce una tecla a su secuencia de bytes para el PTY (xterm-compat), +/// **con modificadores**: flechas/Home/End/F-keys con Ctrl/Alt/Shift van como +/// `CSI 1;{mod}X` (mod = 1 + shift·1 + alt·2 + ctrl·4), Shift+Tab es `CSI Z`, +/// Alt+carácter lleva prefijo ESC (meta), Ctrl+Space es NUL. Las TUIs modernas +/// (claude, helix, btop) usan estos combos; el mapa viejo sólo cubría lo básico. +pub(crate) fn key_to_pty_bytes(ev: &KeyEvent) -> Vec { + let m = &ev.modifiers; + // Código de modificadores xterm: 1 + shift(1) + alt(2) + ctrl(4). + let modcode: u8 = 1 + (m.shift as u8) + ((m.alt as u8) << 1) + ((m.ctrl as u8) << 2); + // Flechas/Home/End (y F1–F4 con mods): `ESC[X` pelado o `ESC[1;{mod}X`. + let csi1 = |fin: char| -> Vec { + if modcode > 1 { + format!("\x1b[1;{modcode}{fin}").into_bytes() + } else { + format!("\x1b[{fin}").into_bytes() + } + }; + // Teclas de función/edición estilo `ESC[N~` (o `ESC[N;{mod}~`). + let tilde = |n: u8| -> Vec { + if modcode > 1 { + format!("\x1b[{n};{modcode}~").into_bytes() + } else { + format!("\x1b[{n}~").into_bytes() + } + }; + // F1–F4 sin modificadores usan la forma SS3 histórica (`ESC O P..S`). + let fkey_ss3 = |fin: char| -> Vec { + if modcode > 1 { + format!("\x1b[1;{modcode}{fin}").into_bytes() + } else { + format!("\x1bO{fin}").into_bytes() + } + }; + match &ev.key { + Key::Named(NamedKey::Enter) => b"\r".to_vec(), + Key::Named(NamedKey::Tab) if m.shift => b"\x1b[Z".to_vec(), + Key::Named(NamedKey::Tab) => b"\t".to_vec(), + // Alt+Backspace = borrar palabra en readline/TUIs (meta). + Key::Named(NamedKey::Backspace) if m.alt => b"\x1b\x7f".to_vec(), + Key::Named(NamedKey::Backspace) => b"\x7f".to_vec(), + Key::Named(NamedKey::Escape) => b"\x1b".to_vec(), + Key::Named(NamedKey::ArrowUp) => csi1('A'), + Key::Named(NamedKey::ArrowDown) => csi1('B'), + Key::Named(NamedKey::ArrowRight) => csi1('C'), + Key::Named(NamedKey::ArrowLeft) => csi1('D'), + Key::Named(NamedKey::Home) => csi1('H'), + Key::Named(NamedKey::End) => csi1('F'), + Key::Named(NamedKey::Insert) => tilde(2), + Key::Named(NamedKey::Delete) => tilde(3), + Key::Named(NamedKey::PageUp) => tilde(5), + Key::Named(NamedKey::PageDown) => tilde(6), + // Ctrl+Space = NUL (set-mark de emacs, leader de varios TUIs). + Key::Named(NamedKey::Space) if m.ctrl => vec![0], + Key::Named(NamedKey::Space) if m.alt => b"\x1b ".to_vec(), + Key::Named(NamedKey::Space) => b" ".to_vec(), + Key::Named(NamedKey::F1) => fkey_ss3('P'), + Key::Named(NamedKey::F2) => fkey_ss3('Q'), + Key::Named(NamedKey::F3) => fkey_ss3('R'), + Key::Named(NamedKey::F4) => fkey_ss3('S'), + Key::Named(NamedKey::F5) => tilde(15), + Key::Named(NamedKey::F6) => tilde(17), + Key::Named(NamedKey::F7) => tilde(18), + Key::Named(NamedKey::F8) => tilde(19), + Key::Named(NamedKey::F9) => tilde(20), + Key::Named(NamedKey::F10) => tilde(21), + Key::Named(NamedKey::F11) => tilde(23), + Key::Named(NamedKey::F12) => tilde(24), + _ => { + // Ctrl-: el byte de control 0x01..0x1a para letras; con Alt + // encima lleva el prefijo ESC (p. ej. Alt+Ctrl+b). + if m.ctrl { + if let Key::Character(c) = &ev.key { + if let Some(ch) = c.chars().next() { + let lo = ch.to_ascii_lowercase(); + if lo.is_ascii_lowercase() { + let ctl = (lo as u8) - b'a' + 1; + return if m.alt { vec![0x1b, ctl] } else { vec![ctl] }; + } + } + } + } + let txt = ev.text.as_deref().unwrap_or("").as_bytes().to_vec(); + // Alt+carácter = meta: prefijo ESC (Alt+b/Alt+f de readline, etc.). + if m.alt && !txt.is_empty() { + let mut out = Vec::with_capacity(txt.len() + 1); + out.push(0x1b); + out.extend_from_slice(&txt); + out + } else { + txt + } + } + } +} + +/// Manda los bytes de la tecla al PTY del run activo. No-op si no hay +/// tui activo. +pub(crate) fn forward_key_to_pty(s: &State, ev: &KeyEvent) { + let Some(arc) = s.running.as_ref() else { + return; + }; + let bytes = key_to_pty_bytes(ev); + if bytes.is_empty() { + return; + } + let guard = match arc.lock() { + Ok(g) => g, + Err(p) => p.into_inner(), + }; + guard.handle.write_input(bytes); +} + +/// Pega el contenido del clipboard en el PTY del run activo. Si el TUI +/// hijo está en bracketed-paste mode (DECSET 2004), envuelve la +/// secuencia en `\x1b[200~...\x1b[201~` para que vim, less y emacs +/// distingan "tipeé esto" de "pegué esto" (auto-indent, paste-mode, +/// etc.). No-op silencioso si no hay TUI o el clipboard está vacío. +pub(crate) fn forward_paste_to_pty(s: &State) { + let Some(text) = read_clipboard() else { + return; + }; + forward_text_to_pty(s, &text); +} + +/// Como [`forward_paste_to_pty`] pero con el texto ya resuelto por el caller +/// (p. ej. el cuasi-clipboard PRIMARY que pega el botón medio). Respeta el +/// bracketed-paste del TUI hijo igual. No-op silencioso si no hay run vivo o +/// el texto está vacío. +pub(crate) fn forward_text_to_pty(s: &State, text: &str) { + let Some(arc) = s.running.as_ref() else { + return; + }; + if text.is_empty() { + return; + } + let guard = match arc.lock() { + Ok(g) => g, + Err(p) => p.into_inner(), + }; + let bracketed = guard + .tui + .as_ref() + .map(|t| t.parser.screen().bracketed_paste()) + .unwrap_or(false); + let payload: Vec = if bracketed { + let mut buf: Vec = b"\x1b[200~".to_vec(); + buf.extend_from_slice(text.as_bytes()); + buf.extend_from_slice(b"\x1b[201~"); + buf + } else { + text.as_bytes().to_vec() + }; + guard.handle.write_input(payload); +} + +/// Marca con la que el asistente ofrece una **respuesta sugerida** en la última +/// línea de su turno: `➜ frase corta`. +/// +/// Claude Code **no tiene** función de respuesta sugerida (verificado contra su +/// doc, 2026-07-21): no pinta ningún fantasma tenue en su caja `❯`, así que no +/// había nada que reconocer ahí. La sugerencia entonces la emite el asistente +/// como texto plano marcado, y shuma la levanta de la pantalla, la borra del +/// panel (para que no se vea dos veces) y la ofrece como ghost en la barra. +/// La convención de emisión vive en `CLAUDE.md`. +pub(crate) const MARCA_SUGERENCIA: char = '➜'; + +/// La convención de emisión, inyectada por shuma al lanzar `claude` con +/// `--append-system-prompt`. Vive **acá** y no sólo en el `CLAUDE.md` del repo +/// para que la función venga con shuma: quien se baje tawasuyu la tiene sin +/// tocar la config de su proyecto. +pub(crate) const PROMPT_SUGERENCIA: &str = "\ +Cuando tu turno termine esperando algo de quien te habla (una confirmación, un \ +dato, un «listo»), cerrá el mensaje con la respuesta que le proponés, en su voz \ +y en primera persona, marcada así: ➜ redesplegá y avisame. Una sola línea, \ +menos de 60 caracteres, la última del mensaje, nada después de ella. Si el turno \ +no espera nada de vuelta, no pongas la marca. El terminal que te hospeda levanta \ +esa línea y la ofrece como texto sugerido en su propia barra de entrada."; + +/// ¿La línea es una invocación pelada de `claude`, sin tuberías ni redirecciones +/// donde meter un flag sería inseguro? +pub(crate) fn es_claude_pelado(linea: &str) -> bool { + let l = linea.trim(); + if l.contains(['|', '>', '<', ';', '&', '`', '\n']) || l.contains("$(") { + return false; + } + matches!(l.split_whitespace().next(), Some("claude")) +} + +/// Texto de la línea marcada con [`MARCA_SUGERENCIA`] más cercana al fondo de +/// la pantalla viva — la última línea del turno recién terminado. +pub(crate) fn detectar_sugerencia_marcada(screen: &vt100::Screen) -> Option { + if screen.alternate_screen() { + return None; + } + let (rows, cols) = screen.size(); + for r in (0..rows).rev() { + let linea: String = (0..cols) + .map(|c| { + screen + .cell(r, c) + .map(|x| x.contents().to_string()) + .filter(|s| !s.is_empty()) + .unwrap_or_else(|| " ".to_string()) + }) + .collect(); + if let Some(resto) = linea.trim().strip_prefix(MARCA_SUGERENCIA) { + let sug = resto.trim(); + return (!sug.is_empty()).then(|| sug.to_string()); + } + } + None +} + +/// Detecta la **respuesta sugerida** de claude: el texto que aparece en su caja +/// de input `❯` sin que lo hayamos puesto nosotros. +/// +/// Antes esto exigía dos cosas que la evidencia tumbó (log de framebuffer, +/// 2026-07-21): (a) un footer conocido («auto mode» / «esc to interrupt» / +/// «for shortcuts») bajo la caja, que no siempre está; y (b) que el texto fuera +/// TENUE — pero en ningún volcado la caja mostró texto atenuado: o está vacía o +/// tiene el texto del usuario en color default. Así que ahora: se busca la fila +/// del `❯` más baja de la zona baja y se devuelve su texto tal cual, sin mirar +/// color. El filtro de «esto lo escribí yo» es del llamador, comparando contra +/// lo último que shuma pegó (ver `run_exec`). Skin claude, modo líneas. +pub(crate) fn detectar_sugerencia_claude(screen: &vt100::Screen) -> Option { + if screen.alternate_screen() { + return None; + } + let (rows, cols) = screen.size(); + let fila_txt = |r: u16| -> String { + (0..cols) + .map(|c| { + screen + .cell(r, c) + .map(|x| x.contents().to_string()) + .filter(|s| !s.is_empty()) + .unwrap_or_else(|| " ".to_string()) + }) + .collect::() + }; + // La caja de input vive en las últimas filas; tomamos el `❯` más bajo. + for r in (rows.saturating_sub(8)..rows).rev() { + let t = fila_txt(r); + if let Some(resto) = t.trim_start().strip_prefix('❯') { + let sug = resto.trim(); + return (!sug.is_empty()).then(|| sug.to_string()); + } + } + None +} + +/// Manda el **TEXTO** de una línea del input de consola al PTY del programa +/// inline (claude) — SIN el CR. El `\r` que la envía va DESPUÉS, en otro tick +/// (ver [`State::cr_pendiente`]): mandar `texto\r` de una se coalesce en UNA +/// sola lectura del PTY y claude/Ink lo trata como un pegado en ráfaga, comiéndose +/// el `\r` (el bug del doble-Enter). Separando el CR en otra iteración del loop, +/// claude ya procesó el texto y lee el `\r` como un Enter REAL. +/// +/// Con bracketed-paste activo (DECSET 2004) el texto va envuelto en +/// `ESC[200~…ESC[201~` para que claude no interprete controles del contenido. +pub(crate) fn send_consola_texto(s: &State, line: &str) { + if line.is_empty() { + return; + } + let Some(arc) = s.running.as_ref() else { + return; + }; + let guard = match arc.lock() { + Ok(g) => g, + Err(p) => p.into_inner(), + }; + let bracketed = guard + .tui + .as_ref() + .map(|t| t.parser.screen().bracketed_paste()) + .unwrap_or(false); + if bracketed { + let mut buf: Vec = b"\x1b[200~".to_vec(); + buf.extend_from_slice(line.as_bytes()); + buf.extend_from_slice(b"\x1b[201~"); + guard.handle.write_input(buf); + } else { + guard.handle.write_input(line.as_bytes().to_vec()); + } +} + +/// Manda un CR (`\r`) pelado al PTY del programa inline — el Enter que envía la +/// línea (tras [`send_consola_texto`], en un tick posterior) o una confirmación +/// de menú (Enter con input vacío). +pub(crate) fn send_consola_cr(s: &State) { + let Some(arc) = s.running.as_ref() else { + return; + }; + let guard = match arc.lock() { + Ok(g) => g, + Err(p) => p.into_inner(), + }; + guard.handle.write_input(b"\r".to_vec()); +} + +/// Convierte un click sobre el panel TUI en bytes xterm-mouse y los manda +/// al PTY del run activo. No-op si el programa no habilitó mouse +/// (`MouseProtocolMode::None`) o no hay TUI. Para modos que reportan +/// release (VT200/ButtonMotion/AnyMotion), encadena Press + Release en una +/// sola escritura — los TUIs (vim/htop/btop) los procesan en ese orden. +pub(crate) fn forward_tui_click_to_pty( + s: &State, + button: u8, + lx: f32, + ly: f32, + rect_w: f32, + rect_h: f32, +) { + let Some(arc) = s.running.as_ref() else { + return; + }; + let guard = match arc.lock() { + Ok(g) => g, + Err(p) => p.into_inner(), + }; + let Some(tui) = guard.tui.as_ref() else { + return; + }; + let screen = tui.parser.screen(); + let mode = screen.mouse_protocol_mode(); + if matches!(mode, vt100::MouseProtocolMode::None) { + return; + } + let encoding = screen.mouse_protocol_encoding(); + let btn = match button { + 0 => XBtn::Left, + 1 => XBtn::Middle, + 2 => XBtn::Right, + _ => return, + }; + let (col, row) = local_to_cell(lx, ly, rect_w, rect_h, tui.cols, tui.rows); + let mut payload: Vec = Vec::new(); + if let Some(b) = encode(mode, encoding, btn, XPhase::Press, col, row) { + payload.extend_from_slice(&b); + } + if let Some(b) = encode(mode, encoding, btn, XPhase::Release, col, row) { + payload.extend_from_slice(&b); + } + if !payload.is_empty() { + guard.handle.write_input(payload); + } +} + +/// Convierte un tick de rueda sobre el panel TUI en eventos xterm-mouse +/// (button 4 = arriba, button 5 = abajo) y los manda al PTY. Emite tantos +/// "press" como ticks lógicos (ceil de `|dy|`) — la rueda no tiene release +/// en xterm. No-op si el programa no habilitó mouse o no hay TUI. +pub(crate) fn forward_tui_wheel_to_pty( + s: &State, + dy: f32, + lx: f32, + ly: f32, + rect_w: f32, + rect_h: f32, +) { + let Some(arc) = s.running.as_ref() else { + return; + }; + let guard = match arc.lock() { + Ok(g) => g, + Err(p) => p.into_inner(), + }; + let Some(tui) = guard.tui.as_ref() else { + return; + }; + let screen = tui.parser.screen(); + let mode = screen.mouse_protocol_mode(); + if matches!(mode, vt100::MouseProtocolMode::None) { + return; + } + let encoding = screen.mouse_protocol_encoding(); + let btn = if dy > 0.0 { XBtn::WheelUp } else { XBtn::WheelDown }; + let ticks = dy.abs().ceil() as u32; + if ticks == 0 { + return; + } + let (col, row) = local_to_cell(lx, ly, rect_w, rect_h, tui.cols, tui.rows); + let mut payload: Vec = Vec::new(); + for _ in 0..ticks.min(8) { + if let Some(b) = encode(mode, encoding, btn, XPhase::Press, col, row) { + payload.extend_from_slice(&b); + } + } + if !payload.is_empty() { + guard.handle.write_input(payload); + } +} diff --git a/02_ruway/shuma/sandbox/shuma-module-shell/src/update/run_exec.rs b/02_ruway/shuma/sandbox/shuma-module-shell/src/update/run_exec.rs new file mode 100644 index 0000000..ae9cc10 --- /dev/null +++ b/02_ruway/shuma/sandbox/shuma-module-shell/src/update/run_exec.rs @@ -0,0 +1,1296 @@ +use super::*; + +pub(crate) fn run_submitted(mut s: State) -> State { + // Override de intención de ESTE submit (Alt+Enter→IA, Ctrl+Enter→shell), + // consumido SIEMPRE —aunque la línea sea builtin/`:`-meta— para no filtrarse + // al próximo submit. + let forced_intent = s.forced_intent.take(); + let line = s.input.text().to_string(); + let trimmed = line.trim().to_string(); + s.input.clear(); + if trimmed.is_empty() { + return s; + } + // Modelo de input paralelo: el Enter se dirige por `input_focus`. + // • Foco en un comando VIVO (foreground o bg) y la línea no fuerza bg + // (`&`) → el Enter es respuesta a SU stdin (apt Y/n, sudo password, + // prompts custom). Escribimos `\n` a ese job. + // • Foco en la LÍNEA (`input_focus == None`) → el Enter arranca un + // comando NUEVO aunque haya otros vivos (no bloquea: para volver a la + // línea basta click en el prompt/cabezal → `FocusInput`). + // Antes esto miraba `s.running.is_some()` e ignoraba `input_focus`: con un + // comando que no termina (una app GUI, `ssh`…) TODO lo tipeado iba a su + // stdin y no había forma de lanzar otro — el "se queda bloqueado". + if let (Some(fb), false, false) = + (s.input_focus, trimmed.ends_with('&'), trimmed.starts_with(':')) + { + // Los meta-comandos `:` (`:jobs`, `:kill`, `:term`…) son control del + // shell, no datos: siempre ejecutan, aunque el foco esté en un job. + if let Some(active_arc) = s.job_by_block(fb).filter(|_| s.block_has_live_job(fb)) { + let bytes = { + let mut v = line.clone().into_bytes(); + v.push(b'\n'); + v + }; + if let Ok(guard) = active_arc.lock() { + guard.handle.write_input(bytes); + } + // Echo discreto de lo enviado para que el usuario vea qué tipeó. + s.push_output(OutputLine::notice(format!("← {line}"))); + return s; + } + } + // E3 — cada submit del usuario re-arma la regla on_exit_nonzero. (El + // comando de la regla la re-desarma después de su run.) + s.exit_rule_fired = false; + // El comando que estaba en foco recede al historial: se pliega para que + // el nuevo nazca expandido y la vista no sea un volcado plano. Sólo los + // que tienen cuerpo (los sin salida no se pliegan; se ven distinto). + let prev = s.current_block; + if prev != 0 && !body_lines_for_block(&s, prev).is_empty() { + s.collapsed.insert(prev); + } + // Corte natural: un comando nuevo recupera el hueco que la marca de agua alta + // hubiera reservado por los efímeros del comando anterior — el HWM arranca de + // cero y vuelve a crecer con el output fresco. + s.reset_content_hwm(); + s.push_output(OutputLine::prompt(format!("$ {trimmed}"))); + + // Append al historial — todo lo que el usuario Enter-eó queda + // registrado, builtins incluidos (para que `cd ../foo` reaparezca + // por Up). `IgnoreConsecutive` evita ráfagas iguales. + { + let now = std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .map(|d| d.as_secs()) + .unwrap_or(0); + let entry = shuma_history::Entry::new(trimmed.clone(), s.cwd.display().to_string(), now); + if let Ok(mut h) = s.history.lock() { + let _ = h.append(entry); + } + } + // Recalcula los patrones emergentes con el historial ya actualizado — + // alimentan la predicción del ghost para el próximo comando. + refresh_patterns(&mut s); + + // Expansión de aliases del `.shumarc`: la primera palabra se reemplaza si + // está declarada. Los meta-comandos del shell (`:save`, `:limit`, …) se + // dejan SIN expandir para que el rc no pueda secuestrarlos. Lo que se + // muestra (`$ trimmed`) y se persiste en el historial es lo que el usuario + // tipeó; lo que se ejecuta es `exec_line` ya resuelto. + let exec_line = if trimmed.starts_with(':') { + trimmed.clone() + } else { + s.config.expand_aliases(&trimmed).into_owned() + }; + + // Builtins primero — no spawnean proceso, corren aunque haya run vivo. + if let Some((cmd, rest)) = split_first_word(&exec_line) { + match cmd { + "cd" => { + return apply_cd(s, rest); + } + "pwd" => { + let cwd_str = s.cwd.display().to_string(); + s.push_output(OutputLine::stdout(cwd_str)); + return s; + } + "clear" => { + s.clear_output(); + return s; + } + "exit" => { + s.push_output(OutputLine::notice( + "exit: el chasis maneja la salida (F12 para cerrar)", + )); + return s; + } + ":jobs" => return apply_jobs_list(s), + ":term" => return apply_jobs_signal(s, rest, JobSignal::Term), + ":kill" => return apply_jobs_signal(s, rest, JobSignal::Kill), + ":stop" => return apply_jobs_signal(s, rest, JobSignal::Stop), + ":cont" => return apply_jobs_signal(s, rest, JobSignal::Cont), + ":env" => return apply_env(s, rest), + "export" => return apply_export(s, rest), + "unset" => return apply_unset(s, rest), + ":persist" => return apply_persist(s, rest), + ":limit" => return apply_capture_limit(s, rest), + ":spill" => return apply_spill(s, rest), + ":scrollback" => return apply_scrollback(s, rest), + ":save" => return save_group(s, rest), + ":write" => return apply_write(s, rest), + ":yank" | ":copy" => return apply_yank(s, rest), + ":diff" => return apply_diff(s, rest), + ":compara" | ":cotejar" | ":vs" => return apply_compare(s, rest), + ":groups" => return apply_groups_list(s), + ":macro" => return apply_macro(s, rest), + ":macros" => return list_macros(s), + ":stats" => return apply_stats(s, rest), + ":?" => return apply_ask(s, rest), + ":haz" | ":hace" | ":control" => return apply_hacer(s, rest), + ":explica" | ":explain" => return apply_explain(s, rest, false), + ":resume" | ":resumen" => return apply_explain(s, rest, true), + ":filtra" | ":filter" | ":fia" => return apply_filter(s, rest), + ":predice" | ":sugiere" | ":next" => return apply_predict(s, rest), + ":buscar" | ":search" => return apply_search(s, rest), + ":buscar-archivos" | ":fbuscar" | ":fsearch" => return apply_search_files(s, rest), + ":ssh" => return apply_ssh(s, rest), + ":spawn" => return apply_spawn_session(s, rest), + ":sessions" => return apply_sessions(s, rest), + ":attach" => return apply_attach_session(s, rest), + ":kill-session" => return apply_kill_session(s, rest), + _ => {} + } + } + + // Línea de **sólo asignaciones** (`PATH=$PATH:/opt/bin`, `FOO=bar BAZ=qux`): + // se aplican a la sesión, no a un bash efímero donde no persistirían. Un + // `FOO=bar cmd` (con comando) NO entra aquí — eso es env de un solo comando + // y va a bash, como corresponde. + if es_asignacion_pura(&exec_line) { + return apply_export(s, &exec_line); + } + + // Sufijo `&` (con espacios opcionales antes) → background. El + // background siempre arranca, sin encolar; no hay límite. + if let Some(stripped) = exec_line.strip_suffix('&') { + let cmd = stripped.trim_end().to_string(); + if cmd.is_empty() { + return s; + } + return start_bg(s, cmd); + } + + // Ruteo inteligente SIN prefijo (clasificador de intención): si la línea NO + // es un comando y suena a lenguaje natural (pregunta o varias palabras cuya + // primera no es ejecutable), va a la IA como si fuera `:?` — en vez de fallar + // como «command not found». Los comandos reales, rutas y sintaxis de shell ya + // se fueron por los caminos de arriba; ante la duda, el clasificador ejecuta. + // #3 — launcher: si la línea (cuya primera palabra NO es un comando) matchea + // una app del registro del host, la lanzamos (el host la spawnea detached). + // Sin prefijo: tecleas el nombre de la app y arranca. Tiene prioridad sobre el + // ruteo a IA; se salta si hubo override manual (Alt/Ctrl+Enter). + let w0 = exec_line.split_whitespace().next().unwrap_or(""); + if forced_intent.is_none() && !s.completion_source.es_comando(w0) { + if let Some(cmd) = crate::intent::buscar_app(&s.apps, &exec_line) { + s.app_launch = Some(cmd.clone()); + s.push_output(OutputLine::notice(format!("↗ app — {cmd}"))); + return s; + } + } + + let intent = forced_intent.unwrap_or_else(|| { + crate::intent::clasificar(&exec_line, |w| s.completion_source.es_comando(w)) + }); + if intent == crate::intent::Intencion::Preguntar { + return apply_ask(s, &exec_line); + } + + // Comando externo foreground. Si ya hay uno corriendo, el nuevo + // arranca en background paralelo (job nuevo) — no encolar. Esto + // evita que un comando colgado (fastfetch, ssh, etc.) bloquee el + // shell. El usuario ve un notice "▶ job N en background" y puede + // seguir tipeando; `:jobs` los lista, `:kill N` los mata, `:fg N` + // los traería al foreground (TODO). + if s.running.is_some() { + let cmd = exec_line.clone(); + s.push_output(OutputLine::notice(format!( + "▶ corre en background (hay otro comando vivo) — {cmd}" + ))); + let mut s = start_bg(s, exec_line); + // Auto-bg (lanzado con otro vivo): se lleva el foco del input, como el + // foreground. El `&` explícito NO foca (fire-and-forget, sigues en la + // línea). Volver a la línea: click en el prompt/cabezal (`FocusInput`). + let blk = s.bg_jobs.last().and_then(|j| j.lock().ok().map(|g| g.block)); + if let Some(b) = blk { + s.input_focus = Some(b); + } + return s; + } + start_run(s, exec_line) +} + +/// Variante de `start_run` que arranca como job background. La salida +/// se mergea al output buffer prefijada por `[N]`. Devuelve `s` con el +/// nuevo job en `bg_jobs`. +pub(crate) fn start_bg(mut s: State, line: String) -> State { + let cwd_str = s.cwd.display().to_string(); + let (mut spec, _tui) = build_spec(&line, &cwd_str); + // Mismo overlay `on_command` que en foreground: el job de fondo también + // recibe el entorno scoped que le toque a su comando. + spec.env = s.config.rules.env_for_command(&line); + // Background no soporta TUI (no le pintamos el grid; el panel + // sería robado al foreground). Si la línea era TUI, la corremos + // sin PTY igual — el binario podrá quejarse, pero al menos no + // tira la UI. + let bg_spec = if matches!(spec.exec, Exec::Pty { .. }) { + let mut s2 = spec.clone(); + s2.exec = Exec::Shell { + line: line.clone(), + program: "bash".into(), + }; + s2 + } else { + spec + }; + let handle = shuma_exec::run(&bg_spec); + let killer = handle.killer(); + let idx = s.bg_jobs.len(); + // Cada job de fondo vive en SU propia card (bloque propio). Sin esto + // su salida se intercalaba en la card del comando de foreground. + let bg_block = s.open_block(); + s.push_in_block(bg_block, OutputLine::prompt(format!("[{idx}] $ {line} &"))); + let active = ActiveRun { + handle: BackendHandle::Local(handle), + killer: Some(killer), + command: line, + tui: None, + block: bg_block, + session: None, + }; + s.bg_jobs.push(Arc::new(Mutex::new(active))); + s +} + +/// E2 — el scrollback como base de datos: resuelve las etapas-referencia +/// `%cN`/`%pN` de una línea materializando el stdout de esos bloques. Devuelve +/// `(línea_ejecutable, stdin_inyectado)`: +/// - `%c12 | grep error | sort` → ejecuta `grep error | sort` con el stdout del +/// bloque 12 como stdin (la ref es la fuente de datos del pipeline). +/// - `%c12` solo → `cat` con ese stdin (re-muestra el bloque, consultable). +/// - sin refs → la línea tal cual, sin inyección. +/// +/// Tanto `%cN` (comando) como `%pN` (buffer) referencian el stdout del bloque +/// `N`; el shell no materializa buffers intermedios aparte. El chip `» stdin` +/// (reprocess) es el caso degenerado de esto. +pub(crate) fn resolve_injects(s: &State, line: &str) -> (String, Option) { + let intention = shuma_intent::Intention::parse(line); + let tiene_ref = intention + .stages + .iter() + .any(|st| matches!(st, shuma_intent::Stage::Inject(_))); + if !tiene_ref { + return (line.to_string(), None); + } + let mut data = String::new(); + let mut exec_stages: Vec = Vec::new(); + for st in &intention.stages { + match st { + shuma_intent::Stage::Inject(r) => { + let block = match r { + shuma_intent::Ref::Command(n) | shuma_intent::Ref::Buffer(n) => *n as u64, + }; + data.push_str(&gather_block_stdout(s, block)); + } + shuma_intent::Stage::Exec(cmd) => exec_stages.push(cmd.clone()), + } + } + // Sólo refs (sin comando) → `cat` re-muestra el contenido inyectado. + let exec_line = if exec_stages.is_empty() { + "cat".to_string() + } else { + exec_stages.join(" | ") + }; + (exec_line, Some(data)) +} + +/// Traduce el rect en px del panel TUI a `(rows, cols)` del PTY. Celda nominal +/// ~7.5×16 px (12 pt monoespacio en Llimphi default). `None` si el rect aún no +/// se pintó (queda el default del spawn). +/// Alto (filas) FIJO del PTY de un skin-claude en vista dividida: claude pinta +/// su historia completa aquí (no en la cola chica) y la empuja al scrollback, +/// que la pre-cosecha captura. La cola sólo MUESTRA el tail. +pub(crate) const ALTO_CONSOLA_FILAS: u16 = 44; + +/// Ancho de celda (px) con el que se traduce el rect del panel a columnas. +/// Tiene que ser el MISMO que usa el render — `EditorMetrics::terminal(12.0)`, +/// o sea `font_size * 0.6` — o el programa escribe más ancho (o más angosto) +/// que la caja donde después se lo pinta. Era 7.5 contra un render de 7.2: una +/// columna de más cada treinta. +/// +/// Cuando entre el zoom de fuente, este número deja de ser constante y tiene +/// que salir del tamaño de fuente vigente; la negociación se rehará sola porque +/// el rect se publica en cada cuadro. +/// Ancho de una celda del terminal en px, **para el zoom vigente**. Es el +/// mismo cálculo que hace la vista al pintar (`font_size * 0.6`, con el +/// `font_size` ya multiplicado por `state.font_zoom`). +/// +/// Antes esto era una constante de 7.2 px que daba por sentado zoom 1. El zoom +/// arranca en 1.15, así que la celda real mide 8.28: por cada columna que le +/// declarábamos al programa, la vista gastaba un 15% más de ancho. El error es +/// **proporcional al largo de la línea**, y por eso se veía como líneas largas +/// cortadas por la derecha mientras las cortas entraban bien. +pub(crate) fn ancho_celda_px(zoom: f32) -> f32 { + 12.0 * 0.6 * zoom.clamp(0.5, 3.0) +} + +/// Alto de una fila en px para el zoom vigente — espeja `ROW_H * zoom` de la +/// vista. Con el 16.0 fijo de antes, el PTY declaraba más filas de las que +/// entraban y el programa dibujaba por debajo del borde. +pub(crate) fn alto_fila_px(zoom: f32) -> f32 { + crate::view::command_card::ROW_H * zoom.clamp(0.5, 3.0) +} + +pub(crate) fn pty_dims(w: f32, h: f32, zoom: f32) -> Option<(u16, u16)> { + if w > 1.0 && h > 1.0 { + let cols = ((w / ancho_celda_px(zoom)).floor() as i32).clamp(20, 400) as u16; + let rows = ((h / alto_fila_px(zoom)).floor() as i32).clamp(5, 200) as u16; + Some((rows, cols)) + } else { + None + } +} + +/// Crea una sesión PTY persistente en el daemon (auto-arrancándolo si hace +/// falta) y se adjunta. `Err` con el motivo si no se pudo — el caller cae al +/// PTY local. +fn spawn_persistente( + spec: &CommandSpec, + label: &str, +) -> Result<(ulid::Ulid, RemoteRunHandle), String> { + let sock = shuma_protocol::default_socket_path(); + shuma_remote_exec::ensure_daemon(&sock).map_err(|e| e.to_string())?; + shuma_remote_exec::spawn_session(spec, &sock, label).map_err(|e| e.to_string()) +} + +pub(crate) fn start_run(mut s: State, line: String) -> State { + let cwd_str = s.cwd.display().to_string(); + // E2 — resolución de `%cN`/`%pN`: la línea ejecutable puede diferir de la + // tipeada (las refs se sacan del pipe y su stdout va al stdin). + let (exec_line, injected_stdin) = resolve_injects(&s, &line); + let (mut spec, mut tui) = build_spec(&exec_line, &cwd_str); + // El PTY nace con el tamaño REAL del último panel TUI pintado, si ya lo + // conocemos. El 24×80 fijo hacía que un TUI exigente arrancara mal + // dimensionado hasta el primer resize del tick (que a su vez depende de + // que el grid llegue a pintarse). + if let Some(t) = tui.as_mut() { + let (w, h) = match s.last_tui_rect.lock() { + Ok(g) => *g, + Err(p) => *p.into_inner(), + }; + if let Some((rows, cols)) = pty_dims(w, h, s.font_zoom) { + t.set_size(rows, cols); + if let Exec::Pty { rows: r, cols: c, .. } = &mut spec.exec { + *r = rows; + *c = cols; + } + } + } + // Overlay de entorno por-comando (`[rules].on_command`): scoped al spawn, + // no toca el entorno del resto ni del sistema. Ej.: `http_proxy` sólo para + // `claude`. Aplica en los tres modos (Shell/Direct/PTY). + spec.env = s.config.rules.env_for_command(&exec_line); + // Skin claude → renderer INLINE forzado: con las queries del terminal + // contestadas (QueryScanner), claude activa su renderer fullscreen por + // alt-screen y la vista consola nunca engancha. El daemon inyecta lo + // mismo en su wrapper para las sesiones persistentes; esto cubre el + // fallback local (sin daemon). + if tui + .as_ref() + .is_some_and(|t| matches!(t.skin, AppSkin::Claude)) + { + spec.env.push(( + "CLAUDE_CODE_DISABLE_ALTERNATE_SCREEN".to_string(), + "1".to_string(), + )); + // La convención de la respuesta sugerida (`➜` al cerrar el turno) viaja + // con shuma, no con el `CLAUDE.md` del proyecto: así la función existe + // para cualquiera que abra claude desde acá, no sólo en el repo que la + // escribió. Va sobre los args ya parseados y no sobre la línea porque + // el PTY parte por espacios y las comillas no sobreviven. + if super::pty::es_claude_pelado(&exec_line) { + if let Exec::Pty { args, .. } = &mut spec.exec { + args.push("--append-system-prompt".to_string()); + args.push(super::pty::PROMPT_SUGERENCIA.to_string()); + } + } + } + // Config de captura y reprocess sólo aplican a runs no-PTY (los TUI + // capturan a vt100, no a buffer, y no consumen stdin reprocesado). + if tui.is_none() { + spec.capture_limit = s.capture_limit_bytes; + spec.spill_path = (s.spill && s.capture_limit_bytes > 0).then(|| { + std::env::temp_dir().join(format!( + "shuma-spill-{}-{}.log", + std::process::id(), + s.current_block + )) + }); + // E2 — stdin inyectado por `%cN`/`%pN` (tiene prioridad sobre el + // reprocess del chip). Si la línea trajo refs, ya desarmamos cualquier + // reprocess pendiente: la fuente explícita manda. + if let Some(data) = injected_stdin { + if !data.is_empty() { + spec.stdin_data = Some(data); + } + s.reprocess_source = None; + } else if let Some(src) = s.reprocess_source.take() { + // Reprocess armado: el stdout del bloque fuente alimenta el stdin. + let data = gather_block_stdout(&s, src); + if !data.is_empty() { + spec.stdin_data = Some(data); + } + } + } else { + // Un run TUI desarma cualquier reprocess pendiente (no aplica). + s.reprocess_source = None; + } + // Registramos la intención antes de hacer spawn — si el spawn + // remoto falla, igual queda el nodo `%cN` con status `Failed` + // marcado más abajo (vía el RunEvent::Failed que retorna el + // backend). El lienzo refleja el intento. + s.current_run_node = Some(s.intent_graph.record(line.clone())); + s.current_run_bytes = 0; + // El prompt de este run ya abrió su bloque (current_block); fijamos + // que TODA su salida —drenada en ticks futuros— vaya a esa card. + let run_block = s.current_block; + let active = match &s.source { + Source::Local => { + // Skin claude → sesión PERSISTENTE en el daemon (tipo tmux): + // sobrevive a cerrar el frontend y a reiniciar el compositor; al + // próximo arranque el módulo se re-adjunta (`auto_reattach`). + // Best-effort: sin daemon posible, cae al PTY local de siempre + // (claude corre igual, sólo que sin persistencia). + let persistente = tui + .as_ref() + .is_some_and(|t| matches!(t.skin, AppSkin::Claude)); + let remoto = persistente.then(|| spawn_persistente(&spec, &line)).and_then( + |r| match r { + Ok(ok) => Some(ok), + Err(e) => { + s.push_in_block( + run_block, + OutputLine::notice(format!( + "(sin daemon — corre local, no persiste: {e})" + )), + ); + None + } + }, + ); + match remoto { + Some((session, h)) => { + s.push_in_block( + run_block, + OutputLine::notice(format!( + "(sesión persistente {session} — sobrevive a reiniciar; :sessions las lista)" + )), + ); + super::builtins::recordar_montada(session); + ActiveRun { + handle: BackendHandle::Remote(h), + killer: None, + command: line, + tui, + block: run_block, + session: Some(session), + } + } + None => { + // Camino histórico — exec directo sobre esta máquina. + let handle = shuma_exec::run(&spec); + let killer = handle.killer(); + ActiveRun { + handle: BackendHandle::Local(handle), + killer: Some(killer), + command: line, + tui, + block: run_block, + session: None, + } + } + } + } + Source::Daemon { socket, .. } => { + let sock = socket + .clone() + .unwrap_or_else(shuma_protocol::default_socket_path); + // PTY remoto full-duplex: conservamos la `TuiSession` para + // pintar el terminal localmente; las teclas/resize viajan al + // daemon por el asa remota. + if tui.is_some() { + match shuma_remote_exec::run_pty(&spec, &sock) { + Ok(h) => ActiveRun { + handle: BackendHandle::Remote(h), + killer: None, + command: line, + tui, + block: run_block, + session: None, + }, + Err(e) => { + s.push_output(OutputLine::notice(format!("✘ daemon pty: {e}"))); + fail_pending_intent(&mut s); + return s; + } + } + } else { + match shuma_remote_exec::run(&spec, &sock) { + Ok(h) => ActiveRun { + handle: BackendHandle::Remote(h), + killer: None, + command: line, + tui: None, + block: run_block, + session: None, + }, + Err(e) => { + s.push_output(OutputLine::notice(format!("✘ daemon: {e}"))); + fail_pending_intent(&mut s); + return s; + } + } + } + } + Source::DaemonTcp { + addr, + server_pub_hex, + .. + } => { + // Identidad y pubkey del server hacen falta en ambos caminos + // (PTY y no-PTY); las resolvemos una vez antes de ramificar. + let kp = match load_or_create_identity() { + Ok(kp) => kp, + Err(e) => { + s.push_output(OutputLine::notice(format!("✘ identity: {e}"))); + fail_pending_intent(&mut s); + return s; + } + }; + let server_pub = match parse_pub_hex(server_pub_hex) { + Ok(p) => p, + Err(e) => { + s.push_output(OutputLine::notice(format!("✘ server_pub_hex: {e}"))); + fail_pending_intent(&mut s); + return s; + } + }; + if tui.is_some() { + match shuma_remote_exec::run_pty_tcp(&spec, addr, kp, server_pub) { + Ok(h) => ActiveRun { + handle: BackendHandle::Remote(h), + killer: None, + command: line, + tui, + block: run_block, + session: None, + }, + Err(e) => { + s.push_output(OutputLine::notice(format!("✘ daemon tcp pty: {e}"))); + fail_pending_intent(&mut s); + return s; + } + } + } else { + match shuma_remote_exec::run_tcp(&spec, addr, kp, server_pub) { + Ok(h) => ActiveRun { + handle: BackendHandle::Remote(h), + killer: None, + command: line, + tui: None, + block: run_block, + session: None, + }, + Err(e) => { + s.push_output(OutputLine::notice(format!("✘ daemon tcp: {e}"))); + fail_pending_intent(&mut s); + return s; + } + } + } + } + Source::Remote { host, user, port, transporte, .. } => { + // Dos transportes, elegidos por host: + // + // - `SshExec` (default histórico): un `ssh exec` por comando, sin + // terminal. Barato y no instala nada, pero deja mudo a todo lo + // de pantalla completa. + // - `SshPty`: cuando el comando ES interactivo (vim/htop/claude), + // abre un canal SSH **con PTY** y conserva la `TuiSession` — el + // terminal remoto se pinta acá igual que uno local. Los comandos + // comunes siguen yendo por `exec` para no perder el modelo de + // bloques (un `ls` remoto da líneas, no una pantalla). + // + // La auth sale de hosts.json en ambos casos. + match resolve_ssh_auth(host, user) { + Ok(auth) => { + let cwd = s.cwd.display().to_string(); + let pty_dims = match (&spec.exec, transporte.soporta_pty()) { + (Exec::Pty { rows, cols, .. }, true) => Some((*rows, *cols)), + _ => None, + }; + match pty_dims { + Some((rows, cols)) => { + let handle = shuma_remote_exec::run_pty_ssh( + Some(line.clone()), + &cwd, + host, + user, + *port, + auth, + rows, + cols, + ); + ActiveRun { + handle: BackendHandle::Remote(handle), + killer: None, + command: line, + tui, + block: run_block, + session: None, + } + } + None => { + let handle = + shuma_remote_exec::run_ssh(&line, &cwd, host, user, *port, auth); + ActiveRun { + handle: BackendHandle::Remote(handle), + killer: None, + command: line, + tui: None, + block: run_block, + session: None, + } + } + } + } + Err(e) => { + s.push_output(OutputLine::notice(format!("✘ SSH: {e}"))); + fail_pending_intent(&mut s); + return s; + } + } + } + Source::Container { engine, name, .. } => { + // Envuelve el spec en ` exec` contra el contenedor. El wrap + // ya leyó `spec.cwd` como cwd INTERIOR (lo inyecta como `cd` adentro). + let mut wrapped = wrap_spec_for_container(spec.clone(), engine, name); + // El cwd del SPAWN en el host debe ser válido y accesible: el cwd + // interior (p.ej. `/root`) no existe/no es accesible en el host y + // haría fallar el spawn. `/` siempre sirve; el chroot/bind manda. + wrapped.cwd = "/".to_string(); + let handle = shuma_exec::run(&wrapped); + let killer = handle.killer(); + ActiveRun { + handle: BackendHandle::Local(handle), + killer: Some(killer), + command: line, + tui, + block: run_block, + session: None, + } + } + Source::RemoteContainer { + host, user, port, engine, name, .. + } => { + // El comando viaja por SSH y allá se envuelve en ` exec` + // (o `chroot` para rootfs). El cwd interior va DENTRO del wrap (un + // `cd` en el shell del contenedor); a `run_ssh` le pasamos "~" para + // que no anteponga un `cd` del lado del HOST remoto. v1: sin PTY. + match resolve_ssh_auth(host, user) { + Ok(auth) => { + let cwd = s.cwd.display().to_string(); + let cmd = remote_container_command(&line, engine, name, &cwd); + let handle = shuma_remote_exec::run_ssh(&cmd, "~", host, user, *port, auth); + ActiveRun { + handle: BackendHandle::Remote(handle), + killer: None, + command: line, + tui: None, + block: run_block, + session: None, + } + } + Err(e) => { + s.push_output(OutputLine::notice(format!("✘ SSH: {e}"))); + fail_pending_intent(&mut s); + return s; + } + } + } + }; + s.tui_skin_vivo = active.tui.as_ref().map(|t| t.skin); + s.tui_altscreen_vivo = false; + s.claude_ocupado = false; + // Espejo sin lock del programa que corre — el título de la pestaña cuando + // el programa no puso uno por OSC (ver `State::titulo_contexto`). + s.comando_vivo = crate::campana::programa_de(&active.command); + s.running = Some(Arc::new(Mutex::new(active))); + // El comando recién arrancado recibe el foco del input: el Enter siguiente + // alimenta SU stdin. Para lanzar otro, el usuario vuelve a la línea + // (click en el prompt/cabezal → `FocusInput`). + s.input_focus = Some(run_block); + s +} + +/// Cierra el nodo `%cN` registrado por `start_run` como fallido cuando +/// el spawn no llega a colocar el `RunHandle` (errores de socket/identity/ +/// pub-hex/tcp). Sin esto el lienzo mostraría el comando como "running" +/// para siempre. Limpia también el contador de bytes. +pub(crate) fn fail_pending_intent(s: &mut State) { + if let Some(id) = s.current_run_node.take() { + s.intent_graph.complete(id, false, 0); + } + s.current_run_bytes = 0; +} + +pub(crate) fn drain_run(mut s: State) -> State { + let Some(active_arc) = s.running.clone() else { + return s; + }; + let mut finished_with: Option = None; + // Bloque de ESTE run — toda su salida va a su card, aunque el usuario + // haya tipeado otros comandos (que movieron `current_block`) mientras + // corría. + let run_block; + { + let mut guard = match active_arc.lock() { + Ok(g) => g, + Err(p) => p.into_inner(), + }; + run_block = guard.block; + // Resize del PTY si el rect del panel cambió desde el último tick + // (mismo mapeo px→celdas de `pty_dims`). Si el panel se redimensiona + // el TUI hace SIGWINCH al child. + let want_resize: Option<(u16, u16)> = if let Some(tui) = guard.tui.as_ref() { + let (w, h) = match s.last_tui_rect.lock() { + Ok(g) => *g, + Err(p) => *p.into_inner(), + }; + pty_dims(w, h, s.font_zoom).map(|(rows, cols)| { + // MODO CONSOLA (skin claude, vista dividida): el panel que + // publica su rect es la COLA (chica ~18 filas). Dimensionar el + // PTY a eso hacía que claude/Ink re-renderizara TODA su UI en + // 17 filas y NUNCA scrolleara → cero historial que cosechar + // ("no cosecha nada"). El PTY debe ser ALTO: claude pinta su + // historia completa, la empuja hacia arriba, y la pre-cosecha + // la captura mientras la cola muestra sólo el tail. + if matches!(tui.skin, AppSkin::Claude) { + (rows.max(ALTO_CONSOLA_FILAS), cols) + } else { + (rows, cols) + } + }) + .filter(|(rows, cols)| { + if *cols != tui.cols { + return true; + } + // MIENTRAS EL INPUT ESTÁ CRECIDO, EL PTY NO SE MUEVE. La barra + // le come al lienzo una fila por cada renglón que gana el + // input, y redimensionar el PTY hace que claude/Ink + // re-renderice su UI ENTERA: eso es el «terremoto» de todo el + // drawer empujando y volviendo, una vez por línea tipeada. + // Escribir una frase no tiene por qué reacomodar la + // conversación. Al enviar, el input vuelve a una fila y ahí sí + // se ajusta, una sola vez. Fuera de consola (vim, htop en + // alt-screen) la medida sigue siendo exacta: ahí una fila de + // menos deja basura en pantalla. + if matches!(tui.skin, AppSkin::Claude) + && crate::view::input_filas_visuales(&s) > 1 + { + false + } else { + *rows != tui.rows + } + }) + } else { + None + }; + if let Some((rows, cols)) = want_resize { + guard.handle.resize(rows, cols); + if let Some(tui) = guard.tui.as_mut() { + tui.set_size(rows, cols); + } + } + // Espejos SIN LOCK para vista/host — refrescados aquí porque el drain + // YA tiene el lock (la vista jamás debe depender de un try_lock + // contendible para su estructura): alt-screen del PTY (gate del + // teclado + rama fullscreen) y spinner de claude (PS1/placeholder). + s.tui_altscreen_vivo = guard + .tui + .as_ref() + .map(|t| t.parser.screen().alternate_screen()) + .unwrap_or(false); + s.claude_ocupado = matches!(guard.tui.as_ref().map(|t| t.skin), Some(AppSkin::Claude)) + && guard.tui.as_ref().is_some_and(|t| t.spinner_vivo()); + // Avisos del protocolo del terminal (ver `campana`): campanadas (BEL) y + // título OSC. Mismo criterio de espejo — la pestaña los lee por frame y + // no puede depender de dos locks encadenados. + if let Some(tui) = guard.tui.as_ref() { + // El contador del TUI nace en cero con cada run; el del shell es + // monótono. Un valor MENOR que el espejo = arrancó otro run. + let c = tui.campanadas(); + let delta = if c >= s.campanadas_run { c - s.campanadas_run } else { c }; + s.campanadas = s.campanadas.saturating_add(delta); + s.campanadas_run = c; + s.titulo_osc = tui.titulo_osc(); + } + // Notificaciones de escritorio (OSC 9/777/99): se cosechan acá y se + // acumulan en el state para que el chasis las levante y las lleve al + // centro willay. Cosecharlas también cuenta como campanada — que la + // pestaña avise aunque nadie esté mirando el buzón. + let nuevas = guard + .tui + .as_mut() + .map(|t| t.tomar_notificaciones()) + .unwrap_or_default(); + if !nuevas.is_empty() { + // Una notificación es una campanada con texto: que la pestaña avise + // aunque todavía nadie haya cableado el buzón al centro willay. + s.campanadas = s.campanadas.saturating_add(nuevas.len() as u64); + s.notificaciones.extend(nuevas); + const TOPE: usize = 32; + if s.notificaciones.len() > TOPE { + let sobran = s.notificaciones.len() - TOPE; + s.notificaciones.drain(..sobran); + } + } + // Respuesta SUGERIDA por claude (texto en su caja de input que NO + // pusimos nosotros): se detecta acá (con el lock) y se ofrece en la + // barra como ghost → `→` la acepta. Sólo skin claude. + // + // El descarte de «esto lo escribí yo» es por PROCEDENCIA, no por color: + // lo que shuma pega viaja por la caja un tick antes del CR, y es + // exactamente lo último del historial de consola. Comparar contra eso + // es exacto; adivinar por luminancia no lo era (y de hecho la caja + // nunca mostró texto tenue). + // + // Dos fuentes, en orden: la línea marcada con `➜` que el asistente pone + // al cerrar su turno (la vía real — ver `pty::MARCA_SUGERENCIA`), y como + // respaldo el texto varado en la caja `❯`. + let sug = guard + .tui + .as_ref() + .filter(|t| matches!(t.skin, AppSkin::Claude)) + .and_then(|t| { + let pantalla = t.parser.screen(); + super::pty::detectar_sugerencia_marcada(pantalla) + .or_else(|| super::pty::detectar_sugerencia_claude(pantalla)) + }); + s.claude_sugerencia = sug.filter(|texto| { + let igual = |x: &String| x.trim() == texto.trim(); + // Ya la respondí (o la descarté escribiendo otra cosa): la marca + // sigue en pantalla hasta que scrollee, pero no se re-ofrece. + !s.sugerencia_consumida.as_ref().is_some_and(igual) + && !s.consola_historial.last().is_some_and(igual) + && s.input.text().trim() != texto.trim() + }); + // Pre-cosecha del contenido ASENTADO — en CADA tick, lleguen bytes o + // no: el asentamiento ocurre justamente cuando el programa SE CALLA + // (dentro del brazo de Bytes nunca se re-evaluaba y una respuesta + // corta quedaba rehén del umbral para siempre). + if let Some(tui) = guard.tui.as_mut() { + for (linea, runs) in tui.pre_cosechar_asentado() { + s.current_run_bytes = + s.current_run_bytes.saturating_add(linea.len() as u64 + 1); + s.pulso.sumar(linea.len() as u64 + 1); + s.push_in_block(run_block, OutputLine::stdout_con_runs(linea, runs)); + } + } + // Limitamos los eventos por tick. Un `ls -alR /` puede escupir miles + // de líneas en un solo flush; procesar todo dentro del lock de + // `active_arc` pasma la pantalla porque el render llama `try_lock` + // en cada frame. Con un tope, el lock se libera entre Ticks y la UI + // se actualiza sin esperar al final del comando. Los restantes + // QUEDAN EN LA COLA del backend para el próximo Tick (no se pierden). + const DRAIN_BUDGET: usize = 512; + let events = guard.handle.try_events_limit(DRAIN_BUDGET); + for ev in events.into_iter() { + match ev { + RunEvent::Stdout(line) => { + // +1 por el `\n` implícito de cada línea drenada. + s.current_run_bytes = s.current_run_bytes.saturating_add(line.len() as u64 + 1); + s.pulso.sumar(line.len() as u64 + 1); + // Campana del camino SIN PTY: acá no hay vt100 que la + // levante, así que se cuenta el `^G` crudo antes de que + // `strip_ansi` lo tire (`echo -e '\a'` en un run común). + s.campanadas = s + .campanadas + .saturating_add(line.matches('\u{7}').count() as u64); + // Strip ANSI escapes: comandos como `fastfetch`, `ls + // --color`, `git --color=always` emiten SGR + `\r` para + // sobrescribir. `strip_ansi` colapsa `\r` y descarta + // códigos sin perder el contenido visible. El coloreo + // real (style runs) queda para una iteración futura. + let clean = shuma_line::ansi::strip_ansi(&line); + s.push_in_block(run_block, OutputLine::stdout(clean)); + } + RunEvent::StageStdout { stage, line } => { + // Salida de una etapa intermedia (tee). NO suma a + // `current_run_bytes` (el grafo cuenta la salida final); + // queda guardada para el desplegable de su etapa. + let clean = shuma_line::ansi::strip_ansi(&line); + s.push_in_block(run_block, OutputLine::stage_stdout(stage, clean)); + } + RunEvent::Stderr(line) => { + s.current_run_bytes = s.current_run_bytes.saturating_add(line.len() as u64 + 1); + s.pulso.sumar(line.len() as u64 + 1); + let clean = shuma_line::ansi::strip_ansi(&line); + s.push_in_block(run_block, OutputLine::stderr(clean)); + } + RunEvent::Truncated => s.push_in_block( + run_block, + OutputLine::notice("… (salida truncada por límite de captura)"), + ), + RunEvent::Spilled(path) => s.push_in_block( + run_block, + OutputLine::notice(format!("… (resto volcado a {path})")), + ), + RunEvent::Bytes(bytes) => { + s.current_run_bytes = s.current_run_bytes.saturating_add(bytes.len() as u64); + s.pulso.sumar(bytes.len() as u64); + // Separa las secuencias gráficas (kitty/sixel) del texto y + // alimenta el resto al vt100. Las respuestas de query se + // escriben de vuelta por stdin para anunciar soporte (el + // borrow de `tui` se cierra antes de tocar `handle`). + let responses = if let Some(tui) = guard.tui.as_mut() { + tui.process_bytes(&bytes) + } else { + Vec::new() + }; + for resp in responses { + guard.handle.write_input(resp); + } + // COSECHA DEL LOG: lo que scrolleó fuera de la pantalla + // viva es contenido consolidado (el "log" de la corrida) — + // los redibujos in-place (spinner/UI) nunca scrollean. Va + // al block como líneas normales; al pintarse, el detector + // de secciones del comando (p. ej. `claude`) las parte en + // prosa visible + desplegables plegados por herramienta. + if let Some(tui) = guard.tui.as_mut() { + for (linea, runs) in tui.cosechar() { + s.current_run_bytes = + s.current_run_bytes.saturating_add(linea.len() as u64 + 1); + s.push_in_block(run_block, OutputLine::stdout_con_runs(linea, runs)); + } + } + } + ev @ (RunEvent::Exited(_) | RunEvent::Failed(_)) => { + finished_with = Some(ev); + } + } + } + } + if let Some(ev) = finished_with { + // Horneá las imágenes (kitty/sixel) que el PTY dejó en la sesión al + // scrollback del bloque, para que sobrevivan al cierre del comando + // (los visores one-shot salen en un frame y el panel TUI desaparece). + let mut sesion_terminada: Option = None; + let mut era_claude = false; + { + let mut guard = match active_arc.lock() { + Ok(g) => g, + Err(p) => p.into_inner(), + }; + sesion_terminada = guard.session.take(); + era_claude = guard + .tui + .as_ref() + .map(|t| matches!(t.skin, AppSkin::Claude)) + .unwrap_or(false); + if let Some(tui) = guard.tui.as_mut() { + // Volcado final: lo que quedó en pantalla sin scrollear (un + // output corto que nació y murió a la vista) también es log — + // al cerrar el run pasa entero al block/desplegables. + for (linea, runs) in tui.volcar_final() { + s.push_in_block(run_block, OutputLine::stdout_con_runs(linea, runs)); + } + if !tui.images.is_empty() { + let imgs = std::mem::take(&mut tui.images); + s.block_images.entry(run_block).or_default().extend(imgs); + } + } + } + let ok = matches!(ev, RunEvent::Exited(0)); + let notice = match &ev { + RunEvent::Exited(0) => "✔ exit 0".to_string(), + RunEvent::Exited(code) => format!("✘ exit {code}"), + RunEvent::Failed(e) => format!("✘ no se pudo spawnear: {e}"), + _ => unreachable!(), + }; + // Diag del bug "abrir pestaña mata la de al lado": registra CADA muerte + // de run con su código/señal, si era claude, y su sesión persistente. + // Correlacionable con el `TabNew` que loguea el chasis. `exit 137` = SIGKILL, + // `143` = SIGTERM, `129..` = 128+señal — dice quién lo mató. + crate::diag_tab(&format!( + "run-end block={run_block} ev={ev:?} claude={era_claude} sesion={sesion_terminada:?}" + )); + s.push_in_block(run_block, OutputLine::notice(notice)); + // Sella el cierre para el titular semáforo del header colapsado + // (duración = ended − started). + let ended = now_unix_secs(); + s.block_ended.insert(run_block, ended); + // A6 — comando largo terminado. + register_long_command(&mut s, run_block, ended); + // A4 — si falló por `command not found`, ofrece la corrección. + if !ok { + detect_did_you_mean(&mut s, run_block); + } + // El comando terminado queda EXPANDIDO; sólo recede (se pliega) al + // perderse en el historial cuando arranca uno nuevo (ver + // `recede_previous_blocks` en `run_submitted`). + // Cierra el nodo del grafo de intenciones — el lienzo lo refleja + // como verde/rojo en el próximo render. + if let Some(id) = s.current_run_node.take() { + s.intent_graph.complete(id, ok, s.current_run_bytes); + } + s.current_run_bytes = 0; + s.running = None; + s.tui_skin_vivo = None; + s.tui_altscreen_vivo = false; + s.claude_ocupado = false; + // El próximo run monta otro `TuiSession` con su contador en cero; el + // título OSC muere con el programa que lo puso (si no, una pestaña + // quedaría rotulada «nvim src/lib.rs» para siempre). + s.campanadas_run = 0; + s.titulo_osc = None; + s.comando_vivo = None; + // Sesión persistente terminada: su proceso MURIÓ (esto no es un + // detach) — olvidala para el auto-reattach y cosecha el cadáver del + // registro del daemon (que no se acumule en `:sessions`). + if let Some(id) = sesion_terminada { + super::builtins::olvidar_montada(id); + let _ = shuma_remote_exec::kill_session( + &shuma_protocol::default_socket_path(), + id, + ); + } + // Si quedó algo en cola, arrancarlo ya — sin esperar otro Tick. + if let Some(next) = s.queue.pop_front() { + s = start_run(s, next); + } + // E3 — [rules].on_exit_nonzero: si el comando falló y la regla está + // armada, corre el comando declarado (típicamente un builtin como + // `:jobs`). La guarda evita que el propio comando de la regla la + // re-dispare. Sólo si no quedó otro corriendo de la cola. + if !ok && !s.exit_rule_fired && s.running.is_none() { + if let Some(cmd) = s.config.rules.on_exit_nonzero.clone() { + let cmd = cmd.trim().to_string(); + if !cmd.is_empty() { + s.input.set_text(&cmd); + s = run_submitted(s); + s.exit_rule_fired = true; + } + } + } + } + // Drenado de jobs background — cada uno aporta sus líneas + // prefijadas por `[N]`. Los terminados se eliminan del Vec. + s = drain_bg_jobs(s); + s +} + +/// Drena los `bg_jobs` y los limpia. Las líneas se prefijan `[N]` +/// para distinguir su origen. +pub(crate) fn drain_bg_jobs(mut s: State) -> State { + let mut next_jobs: Vec>> = Vec::with_capacity(s.bg_jobs.len()); + // Snapshot de los Arc: `push_output` toma `&mut s`, incompatible con + // retener el borrow de `s.bg_jobs` durante el loop. + let jobs = s.bg_jobs.clone(); + for arc in jobs.iter() { + let mut keep = true; + let mut finished: Option = None; + // Bloque propio del job — su salida vive en SU card, nunca en la + // del foreground (era el bug del "output mezclado"). + let job_block; + { + let mut guard = match arc.lock() { + Ok(g) => g, + Err(p) => p.into_inner(), + }; + job_block = guard.block; + // Mismo límite que `drain_run`: ráfagas grandes de un job background + // no pasman la pantalla; los eventos restantes se procesan en el + // próximo Tick. + for ev in guard.handle.try_events_limit(512) { + match ev { + RunEvent::Stdout(line) => s.push_in_block( + job_block, + OutputLine::stdout(shuma_line::ansi::strip_ansi(&line)), + ), + RunEvent::StageStdout { stage, line } => s.push_in_block( + job_block, + OutputLine::stage_stdout(stage, shuma_line::ansi::strip_ansi(&line)), + ), + RunEvent::Stderr(line) => s.push_in_block( + job_block, + OutputLine::stderr(shuma_line::ansi::strip_ansi(&line)), + ), + RunEvent::Truncated => { + s.push_in_block(job_block, OutputLine::notice("… (truncada)")) + } + RunEvent::Spilled(path) => s.push_in_block( + job_block, + OutputLine::notice(format!("… (volcado a {path})")), + ), + RunEvent::Bytes(_) => { + // Background sin PTY — no debería emitir Bytes. + } + ev @ (RunEvent::Exited(_) | RunEvent::Failed(_)) => { + finished = Some(ev); + } + } + } + } + if let Some(ev) = finished { + let notice = match ev { + RunEvent::Exited(0) => "✔ exit 0".to_string(), + RunEvent::Exited(code) => format!("✘ exit {code}"), + RunEvent::Failed(e) => format!("✘ failed: {e}"), + _ => unreachable!(), + }; + s.push_in_block(job_block, OutputLine::notice(notice)); + keep = false; + } + if keep { + next_jobs.push(arc.clone()); + } + } + s.bg_jobs = next_jobs; + s +} + +pub(crate) fn cancel_running(mut s: State) -> State { + let mut run_block = s.current_block; + if let Some(arc) = s.running.as_ref() { + let guard = match arc.lock() { + Ok(g) => g, + Err(p) => p.into_inner(), + }; + run_block = guard.block; + // Local: SIGKILL al grupo entero — Ctrl-C debe doler en una UI. + // Remoto: cerrar el stream — el daemon detecta EOF y mata al + // hijo. La forma del notice no cambia. + if let Some(killer) = guard.killer.as_ref() { + killer.kill(); + } else { + guard.handle.kill(); + } + // El próximo Tick observará `RunEvent::Exited` y limpiará el handle. + } + s.push_in_block(run_block, OutputLine::notice("⏹ cancel (SIGKILL enviado)")); + s +} + +/// A6 — registra un comando largo terminado: si duró ≥ +/// `[rules].on_long_command_secs` (`0` = apagado), suma una alerta para la badge +/// del diente (que el chasis pinta cuando la sesión no está activa) y deja un +/// rastro `⏲` en el bloque. Sin notificaciones del sistema: el chasis es la +/// superficie. Puro sobre el `State` — sin la maquinaria de spawn — para poder +/// testearlo directo. `ended` es el cierre en segundos unix. +pub(crate) fn register_long_command(s: &mut State, block: u64, ended: u64) { + let umbral = s.config.rules.on_long_command_secs; + if umbral == 0 { + return; + } + let Some(&started) = s.block_started.get(&block) else { + return; + }; + let dur = ended.saturating_sub(started); + if dur < umbral { + return; + } + s.long_alerts += 1; + s.push_in_block( + block, + OutputLine::notice(format!("⏲ comando largo — terminó tras {dur}s")), + ); +} + +#[cfg(test)] +mod a6_long_command_tests { + use super::*; + + /// State con `on_long_command_secs = umbral` y un bloque que arrancó hace + /// `dur` segundos (ended − started = dur). + fn state_con_bloque_durado(umbral: u64, dur: u64) -> (State, u64, u64) { + let mut s = State::new(shuma_module::Source::Local); + s.config.rules.on_long_command_secs = umbral; + let block = 7; + let started = 1_000_000; + s.block_started.insert(block, started); + (s, block, started + dur) + } + + #[test] + fn comando_largo_suma_alerta_y_rastro() { + let (mut s, block, ended) = state_con_bloque_durado(30, 45); + register_long_command(&mut s, block, ended); + assert_eq!(s.long_alerts(), 1); + // Dejó el rastro ⏲ en el bloque. + assert!(s + .output + .iter() + .any(|l| l.block == block && l.text.contains("⏲") && l.text.contains("45s"))); + } + + #[test] + fn comando_corto_no_alerta() { + let (mut s, block, ended) = state_con_bloque_durado(30, 5); + register_long_command(&mut s, block, ended); + assert_eq!(s.long_alerts(), 0); + } + + #[test] + fn umbral_cero_apaga_la_funcion() { + let (mut s, block, ended) = state_con_bloque_durado(0, 9999); + register_long_command(&mut s, block, ended); + assert_eq!(s.long_alerts(), 0); + } + + #[test] + fn ack_limpia_la_badge() { + let (mut s, block, ended) = state_con_bloque_durado(30, 60); + register_long_command(&mut s, block, ended); + assert_eq!(s.long_alerts(), 1); + s.ack_long_alerts(); + assert_eq!(s.long_alerts(), 0); + } +} + +#[cfg(test)] +mod e2_inject_tests { + use super::*; + + fn state_con_bloque(block: u64, lineas: &[&str]) -> State { + let mut s = State::new(shuma_module::Source::Local); + for t in lineas { + let mut l = OutputLine::stdout(*t); + l.block = block; + s.output.push(l); + } + s + } + + #[test] + fn ref_como_fuente_alimenta_el_pipe() { + let s = state_con_bloque(5, &["foo", "error bar", "baz"]); + let (exec, stdin) = resolve_injects(&s, "%c5 | grep error"); + assert_eq!(exec, "grep error"); + assert_eq!(stdin.as_deref(), Some("foo\nerror bar\nbaz\n")); + } + + #[test] + fn ref_sola_se_remuestra_con_cat() { + let s = state_con_bloque(12, &["línea uno", "línea dos"]); + let (exec, stdin) = resolve_injects(&s, "%c12"); + assert_eq!(exec, "cat"); + assert_eq!(stdin.as_deref(), Some("línea uno\nlínea dos\n")); + } + + #[test] + fn pn_aliasa_al_stdout_del_bloque() { + let s = state_con_bloque(3, &["a", "b"]); + let (exec, stdin) = resolve_injects(&s, "%p3 | sort"); + assert_eq!(exec, "sort"); + assert_eq!(stdin.as_deref(), Some("a\nb\n")); + } + + #[test] + fn linea_sin_ref_pasa_intacta() { + let s = State::new(shuma_module::Source::Local); + let (exec, stdin) = resolve_injects(&s, "ls -la | grep foo"); + assert_eq!(exec, "ls -la | grep foo"); + assert!(stdin.is_none()); + } +} diff --git a/02_ruway/shuma/sandbox/shuma-module-shell/src/update/scroll.rs b/02_ruway/shuma/sandbox/shuma-module-shell/src/update/scroll.rs new file mode 100644 index 0000000..47f565e --- /dev/null +++ b/02_ruway/shuma/sandbox/shuma-module-shell/src/update/scroll.rs @@ -0,0 +1,94 @@ +use super::*; + +/// Aplica un delta de scroll a la superficie, manteniendo el invariante de +/// anclaje (Fase 5.0). Devuelve `s` con `scroll_px` / `surf_scroll_anchor` +/// actualizados. NO toca `surf_scroll_velocity` — eso lo hacen los callers +/// (`Msg::Scroll` la captura, `step_scroll_inertia` la decae). +pub(crate) fn apply_scroll_delta(mut s: State, delta: f32) -> State { + let overflow = s.out_overflow.lock().map(|g| *g).unwrap_or(0.0); + // Re-baseline a la `scroll_y` intencionada del usuario contra el + // `overflow` actual (Fase 5: anclaje estable bajo append). + let prev_anchor = if s.surf_scroll_anchor > 0.5 { + s.surf_scroll_anchor + } else { + overflow + }; + let curr_scroll_y = (prev_anchor - s.scroll_px).clamp(0.0, overflow); + // `delta > 0` = rueda arriba = ver historial (scroll_y baja). + let new_scroll_y = (curr_scroll_y - delta).clamp(0.0, overflow); + // Si el usuario alcanzó el fondo, re-pin al bottom (scroll_px=0). + // Threshold de 0.5 absorbe ruido sub-pixel. + if new_scroll_y >= overflow - 0.5 { + s.scroll_px = 0.0; + s.surf_scroll_anchor = 0.0; + // Re-pinned al fondo: la ventana del archive vuelve a "cola" liviana + // (las últimas N), así no carga de más cuando no se la mira. + if let Ok(mut c) = s.surf_spilled_visible.lock() { + c.window_start = None; + } + } else { + s.scroll_px = overflow - new_scroll_y; + s.surf_scroll_anchor = overflow; + s = maybe_page_spill_back(s, new_scroll_y, overflow); + } + s +} + +/// Altura de una línea de output (espeja `view::command_card::ROW_H`, privado +/// a ese módulo). Usada para la matemática de anclaje del paginado del archive. +const SPILL_ROW_H: f32 = 16.0; + +/// Fase 5.12 — al rozar el borde superior del contenido, pagina el archive +/// spilled hacia atrás cargando una página más vieja. Prepender K líneas no +/// cambia la distancia al fondo (`scroll_px` queda igual): sólo subimos el +/// ancla por `K·row_h` para que la línea que el usuario mira no salte cuando el +/// próximo render agregue esas líneas arriba. +fn maybe_page_spill_back(mut s: State, new_scroll_y: f32, overflow: f32) -> State { + let row_h = SPILL_ROW_H * s.font_zoom.clamp(0.5, 3.0); + let spilled_count = s + .surf_history + .lock() + .map(|h| h.spilled_count()) + .unwrap_or(0); + let window_start = s.surf_spilled_visible.lock().ok().and_then(|c| c.window_start); + let Some(new_start) = + crate::spill_page_back(window_start, spilled_count, new_scroll_y, row_h) + else { + return s; + }; + let effective = crate::spill_effective_start(window_start, spilled_count); + let k = effective.saturating_sub(new_start); + if let Ok(mut c) = s.surf_spilled_visible.lock() { + c.window_start = Some(new_start); + } + // El render sumará K líneas arriba → overflow crece K·row_h. Subimos el + // ancla igual para preservar la posición visual (scroll_px ya quedó fijo). + s.surf_scroll_anchor = overflow + k as f32 * row_h; + s +} + +/// Aplica un paso de scroll inercial: si la velocidad supera el umbral, +/// scrollea por ella y decae por fricción. Si tocó el fondo (re-pin), la +/// inercia se detiene (evita el "fantasma" de seguir scrolleando contra +/// el límite). Lo llama el handler de `Msg::Tick` por frame. +pub(crate) fn step_scroll_inertia(mut s: State) -> State { + /// Magnitud bajo la cual consideramos que el scroll está quieto, en px. + const EPSILON: f32 = 0.5; + /// Factor de fricción aplicado por tick (~100 ms). 0.82 → la inercia + /// decae a ~10% en ~12 ticks (~1.2 s). Tuneable. + const FRICTION: f32 = 0.82; + if s.surf_scroll_velocity.abs() <= EPSILON { + s.surf_scroll_velocity = 0.0; + return s; + } + let v = s.surf_scroll_velocity; + s = apply_scroll_delta(s, v); + // Si el delta nos dejó pinned al fondo, parar la inercia para no + // simular un "rebote" contra el borde. + if s.scroll_px <= f32::EPSILON { + s.surf_scroll_velocity = 0.0; + } else { + s.surf_scroll_velocity *= FRICTION; + } + s +} diff --git a/02_ruway/shuma/sandbox/shuma-module-shell/src/update/spec_builder.rs b/02_ruway/shuma/sandbox/shuma-module-shell/src/update/spec_builder.rs new file mode 100644 index 0000000..5fba0a7 --- /dev/null +++ b/02_ruway/shuma/sandbox/shuma-module-shell/src/update/spec_builder.rs @@ -0,0 +1,117 @@ +use super::*; + +/// Si `line` es un pipe «simple» de ≥2 etapas —sólo `Command`/`Argument`/ +/// `Flag`/`Pipe`/espacio, sin comillas, variables, redirecciones, +/// operadores, globs (`* ? [ ] { }`) ni `~`— devuelve sus etapas como +/// [`StageSpec`] para correrlo por `Exec::Direct`. Si no, `None` (cae a +/// `sh -c`, que sí absorbe esa sintaxis). Un único comando también cae a +/// `sh -c`: el modo directo sólo aporta cuando hay tubería que interceptar. +/// +/// Conservador a propósito: `shuma_line::Stage` no recoge los `StringLit` +/// en `args`, así que un pipe con comillas debe ir al shell o perdería el +/// argumento citado. +pub(crate) fn simple_pipe_stages(line: &str) -> Option> { + use shuma_line::TokenKind::*; + let tokens = shuma_line::tokenize(line, shuma_line::Dialect::Bash); + let simple = !tokens.is_empty() + && tokens.iter().all(|t| { + matches!(t.kind, Command | Argument | Flag | Pipe | Whitespace) + && !t.text.contains(['*', '?', '[', ']', '{', '}']) + && !t.text.starts_with('~') + }); + if !simple { + return None; + } + let pipeline = shuma_line::split_pipeline(&tokens); + if pipeline.stages.len() < 2 { + return None; + } + let mut stages = Vec::with_capacity(pipeline.stages.len()); + for st in &pipeline.stages { + // Una etapa sin comando (línea incompleta, p. ej. termina en `|`) + // → al shell, que reporta el error de sintaxis como toca. + let program = st.command.clone()?; + stages.push(StageSpec { + program, + args: st.args.clone(), + }); + } + Some(stages) +} + +/// Decide cómo lanzar `line`: si el primer token está en la allowlist +/// TUI (o el usuario lo prefijó con `:tui`), abre un PTY; si es un pipe +/// simple, lo corre directo con captura por etapa; si no, va por el shell +/// normal (streaming Stdout/Stderr). +/// Inserta `-A` después de `sudo` cuando el usuario no lo puso, para que +/// sudo dispare `SUDO_ASKPASS` (popup) en vez de quedar colgado leyendo +/// stdin del PTY. Respeta `-A`, `-S`, `--askpass`, `--stdin` ya presentes. +/// Sólo toca la primera ocurrencia al principio del line — pipes / `&&` / +/// `;` van por su cuenta (el shell del PTY los maneja). +pub(crate) fn build_spec(line: &str, cwd: &str) -> (CommandSpec, Option) { + // sudo sin `-A`/`-S` quedaría colgado pidiendo pass en stdin del PTY — + // inyectamos `-A` para que use `SUDO_ASKPASS` (popup Llimphi). + let line_owned = inject_askpass(line); + let line = line_owned.as_str(); + // Prefijo explícito `:tui `. + let (cmd_line, force_tui) = match line.strip_prefix(":tui ") { + Some(rest) => (rest.trim(), true), + None => (line, false), + }; + let first_word = cmd_line.split_whitespace().next().unwrap_or(""); + let is_tui = force_tui || TUI_ALLOWLIST.contains(&first_word); + if !is_tui { + // Pipe «simple» (sólo comandos/args/flags y `|`, sin comillas, + // variables, redirecciones, globs ni `~`): lo corremos directo + // —conectando los procesos nosotros— y activamos la captura por + // etapa (tee) para inspeccionar los intermedios en vivo. Cualquier + // sintaxis que el modo directo no absorbe cae a `sh -c`. + if let Some(stages) = simple_pipe_stages(line) { + return ( + CommandSpec { + exec: Exec::Direct { stages }, + cwd: cwd.to_string(), + capture_limit: 0, + spill_path: None, + stdin_data: None, + env: Vec::new(), + capture_stages: true, + }, + None, + ); + } + return (CommandSpec::shell(line, cwd), None); + } + // Bajo PTY: parseamos en stages básicos por whitespace. No soporta + // pipes ni redirecciones — un TUI fullscreen no los usa. + let parts: Vec = cmd_line.split_whitespace().map(String::from).collect(); + if parts.is_empty() { + return (CommandSpec::shell(line, cwd), None); + } + let program = parts[0].clone(); + let args = parts[1..].to_vec(); + let spec = CommandSpec { + exec: Exec::Pty { + program, + args, + cols: PTY_COLS, + rows: PTY_ROWS, + }, + cwd: cwd.to_string(), + capture_limit: 0, + spill_path: None, + stdin_data: None, + env: Vec::new(), + capture_stages: false, + }; + // Stage marker — usamos `parts` para sintaxis, no para ejecutar; el + // Exec::Pty arma el spawn directo. La conversión a `StageSpec` + // queda como guía visual del tooltip si después la queremos + // exponer (hoy `Exec::Pty` no usa stages). + let _ = StageSpec { + program: parts[0].clone(), + args: parts[1..].to_vec(), + }; + // `program` ya se movió al `Exec::Pty`; usamos `parts[0]` (sigue vivo). + (spec, Some(TuiSession::new(&parts[0], PTY_ROWS, PTY_COLS))) +} diff --git a/02_ruway/shuma/sandbox/shuma-module-shell/src/update/ssh_auth.rs b/02_ruway/shuma/sandbox/shuma-module-shell/src/update/ssh_auth.rs new file mode 100644 index 0000000..65ba2a0 --- /dev/null +++ b/02_ruway/shuma/sandbox/shuma-module-shell/src/update/ssh_auth.rs @@ -0,0 +1,152 @@ +use super::*; + +// --- Auth SSH para `Source::Remote` ----------------------------------------- +// Espejo MÍNIMO del schema de `~/.config/shuma/hosts.json` (lo escribe el +// chasis vía `hosts.rs`): sólo los campos que necesita el transporte SSH. + +#[derive(serde::Deserialize)] +pub(crate) struct HostEntry { + /// Nombre amigable con el que el usuario lo llama (`:ssh casa`). + #[serde(default)] + pub(crate) name: String, + pub(crate) host: String, + #[serde(default)] + pub(crate) user: String, + #[serde(default = "host_default_port")] + pub(crate) port: u16, + #[serde(default)] + pub(crate) auth: HostAuthJson, + /// Transporte guardado: `true` = canal SSH con PTY. + #[serde(default)] + pub(crate) pty: bool, +} + +/// Lee la lista de hosts guardados. Vacía si no hay archivo o no parsea — +/// nunca es un error fatal para el shell. +pub(crate) fn load_hosts() -> Vec { + hosts_json_path() + .and_then(|p| std::fs::read_to_string(p).ok()) + .and_then(|t| serde_json::from_str::>(&t).ok()) + .unwrap_or_default() +} + +/// Busca un host por **nombre amigable** primero y por hostname después — +/// el orden con el que un humano lo escribe (`:ssh casa` antes que +/// `:ssh 192.168.1.10`). +pub(crate) fn lookup_host(clave: &str) -> Option { + let hosts = load_hosts(); + let clave_l = clave.to_lowercase(); + hosts + .into_iter() + .find(|h| h.name.to_lowercase() == clave_l || h.host.to_lowercase() == clave_l) +} + +pub(crate) fn host_default_port() -> u16 { + 22 +} + +#[derive(serde::Deserialize, Default)] +#[serde(tag = "kind", rename_all = "snake_case")] +pub(crate) enum HostAuthJson { + #[default] + Password, + Key { + path: String, + }, +} + +pub(crate) fn hosts_json_path() -> Option { + let base = std::env::var_os("XDG_CONFIG_HOME") + .map(PathBuf::from) + .or_else(|| std::env::var_os("HOME").map(|h| PathBuf::from(h).join(".config")))?; + Some(base.join("shuma").join("hosts.json")) +} + +/// Ubica el binario askpass. Prioriza la env (`SHUMA_ASKPASS`/`SSH_ASKPASS`), +/// pero cae a paths canónicos cuando no está: pata **respawnea sin heredar +/// env** (la respawnea mirada desde `/usr/local/bin`), así que atarse sólo a +/// la env dejaba la auth por contraseña muerta en el drawer. Buscamos, en +/// orden: env → `shuma-askpass` junto al ejecutable actual → el deploy en +/// `/usr/local/bin` → el `PATH`. +fn find_askpass() -> Option { + if let Some(bin) = std::env::var_os("SHUMA_ASKPASS").or_else(|| std::env::var_os("SSH_ASKPASS")) + { + return Some(bin); + } + if let Ok(exe) = std::env::current_exe() { + if let Some(dir) = exe.parent() { + let cand = dir.join("shuma-askpass"); + if cand.is_file() { + return Some(cand.into_os_string()); + } + } + } + let deploy = PathBuf::from("/usr/local/bin/shuma-askpass"); + if deploy.is_file() { + return Some(deploy.into_os_string()); + } + // Último recurso: dejar que el PATH lo resuelva (funciona si hay env de PATH). + Some(std::ffi::OsString::from("shuma-askpass")) +} + +/// Corre el binario askpass con `prompt` y devuelve lo que imprime en stdout +/// (la contraseña/passphrase). `None` si no hay askpass o el usuario canceló. +pub(crate) fn run_askpass(prompt: &str) -> Option { + let bin = find_askpass()?; + let out = std::process::Command::new(bin).arg(prompt).output().ok()?; + if !out.status.success() { + return None; + } + let s = String::from_utf8_lossy(&out.stdout) + .trim_end_matches(['\n', '\r']) + .to_string(); + if s.is_empty() { + None + } else { + Some(s) + } +} + +/// Resuelve el método de auth para `host`/`user` leyendo `hosts.json`. Clave +/// (PEM) → `SshAuth::Key`; contraseña → askpass al conectar. +pub(crate) fn resolve_ssh_auth( + host: &str, + user: &str, +) -> Result { + let path = hosts_json_path().ok_or("no se pudo ubicar hosts.json")?; + let txt = std::fs::read_to_string(&path) + .map_err(|e| format!("no pude leer {}: {e}", path.display()))?; + let entries: Vec = + serde_json::from_str(&txt).map_err(|e| format!("hosts.json inválido: {e}"))?; + let entry = entries + .iter() + .find(|h| h.host == host && (h.user == user || h.user.is_empty())) + .or_else(|| entries.iter().find(|h| h.host == host)) + .ok_or_else(|| format!("no hay host guardado para {host} — gestiona hosts"))?; + let _ = entry.port; + match &entry.auth { + HostAuthJson::Key { path } => Ok(shuma_remote_exec::SshAuth::Key { + path: PathBuf::from(path), + passphrase: None, + }), + HostAuthJson::Password => { + let pw = run_askpass(&format!("Contraseña SSH para {user}@{host}:")).ok_or( + "auth por contraseña: configurá SHUMA_ASKPASS/SSH_ASKPASS o usa una clave (PEM)", + )?; + Ok(shuma_remote_exec::SshAuth::Password(pw)) + } + } +} + +/// Carga el `Keypair` del shell desde el archivo de identidad, +/// creando uno nuevo si no existe. Usa el path por defecto de +/// `shuma-link::Keypair::default_path()` (`~/.config/shuma/keys/identity`). +pub(crate) fn load_or_create_identity() -> Result { + let path = shuma_link::Keypair::default_path() + .ok_or_else(|| "no se pudo derivar el path de identidad".to_string())?; + shuma_link::Keypair::load_or_generate(&path).map_err(|e| e.to_string()) +} + +pub(crate) fn parse_pub_hex(hex_str: &str) -> Result { + shuma_link::PublicKey::from_hex(hex_str).map_err(|e| e.to_string()) +} diff --git a/02_ruway/shuma/sandbox/shuma-module-shell/src/update/ssh_tab.rs b/02_ruway/shuma/sandbox/shuma-module-shell/src/update/ssh_tab.rs new file mode 100644 index 0000000..2aa89f8 --- /dev/null +++ b/02_ruway/shuma/sandbox/shuma-module-shell/src/update/ssh_tab.rs @@ -0,0 +1,212 @@ +//! `:ssh` — abrir una **tab contra un host remoto** desde el teclado. +//! +//! El módulo es una sola sesión y no puede crear tabs: lo que hace acá es +//! resolver el host (nombre amigable de `hosts.json`, `user@host:port`, o el +//! hostname pelado) a un [`Source::Remote`] y dejarlo como **intención** en +//! `new_tab_source`. El chasis la drena y abre la tab. Mismo mecanismo que +//! «Ejecutar en nuevo tab» del menú contextual. + +use super::*; +use shuma_module::{RemoteTransport, Source}; + +/// Coordenadas de un destino SSH ya resuelto. +pub(crate) struct DestinoSsh { + pub host: String, + pub user: String, + pub port: u16, + pub transporte: RemoteTransport, + /// Etiqueta con la que mostrar la tab (el nombre amigable si lo hay). + pub etiqueta: String, +} + +/// Parte `user@host:port` en sus piezas. Todo salvo `host` es opcional. +/// No valida: lo que no parsea cae a los defaults (`$USER`, 22). +pub(crate) fn parse_destino(arg: &str) -> Option<(Option, String, Option)> { + let arg = arg.trim(); + if arg.is_empty() { + return None; + } + let (user, resto) = match arg.split_once('@') { + Some((u, r)) if !u.is_empty() && !r.is_empty() => (Some(u.to_string()), r), + _ => (None, arg), + }; + // Un `:` sólo separa puerto si lo que sigue son dígitos — así un IPv6 + // pelado no se parte por la mitad. + let (host, port) = match resto.rsplit_once(':') { + Some((h, p)) if !h.is_empty() && p.chars().all(|c| c.is_ascii_digit()) && !p.is_empty() => { + (h.to_string(), p.parse::().ok()) + } + _ => (resto.to_string(), None), + }; + if host.is_empty() { + return None; + } + Some((user, host, port)) +} + +/// Resuelve `arg` a un destino: primero busca en `hosts.json` (por nombre +/// amigable y por hostname), y si no está arma uno con lo que el usuario +/// escribió. Lo guardado gana sobre los defaults, pero lo que el usuario +/// escribe explícito (`otro@host`, `:2222`) gana sobre lo guardado. +pub(crate) fn resolver_destino(arg: &str) -> Option { + let (user_arg, host_arg, port_arg) = parse_destino(arg)?; + let guardado = lookup_host(&host_arg); + match guardado { + Some(h) => { + let etiqueta = if h.name.is_empty() { h.host.clone() } else { h.name.clone() }; + Some(DestinoSsh { + user: user_arg.unwrap_or_else(|| { + if h.user.is_empty() { + usuario_local() + } else { + h.user.clone() + } + }), + port: port_arg.unwrap_or(h.port), + transporte: if h.pty { + RemoteTransport::SshPty + } else { + RemoteTransport::SshExec + }, + host: h.host, + etiqueta, + }) + } + None => { + let user = user_arg.unwrap_or_else(usuario_local); + Some(DestinoSsh { + etiqueta: format!("{user}@{host_arg}"), + user, + port: port_arg.unwrap_or(22), + // Host no guardado: PTY por default — es lo que uno espera de + // un `ssh host` tipeado a mano (que `vim` allá funcione). + transporte: RemoteTransport::SshPty, + host: host_arg, + }) + } + } +} + +fn usuario_local() -> String { + std::env::var("USER").unwrap_or_else(|_| "root".to_string()) +} + +impl DestinoSsh { + pub(crate) fn source(&self) -> Source { + Source::Remote { + host: self.host.clone(), + user: self.user.clone(), + port: self.port, + label: Some(self.etiqueta.clone()), + transporte: self.transporte, + } + } +} + +/// `:ssh` — sin argumento lista los hosts guardados; con argumento deja la +/// intención de abrir una tab contra ese host. +pub(crate) fn apply_ssh(mut s: State, rest: &str) -> State { + let arg = rest.trim(); + if arg.is_empty() { + let hosts = load_hosts(); + if hosts.is_empty() { + s.push_output(OutputLine::notice( + "uso: :ssh · acepta un nombre guardado, `user@host` o `host:puerto`", + )); + s.push_output(OutputLine::notice( + "(no hay hosts guardados todavía — el gestor de hosts los agrega)", + )); + return s; + } + s.push_output(OutputLine::notice("hosts guardados:")); + for h in hosts { + let modo = if h.pty { "pty" } else { "exec" }; + let nombre = if h.name.is_empty() { h.host.clone() } else { h.name.clone() }; + s.push_output(OutputLine::stdout(format!( + " {nombre} — {}@{}:{} · {modo}", + h.user, h.host, h.port + ))); + } + return s; + } + match resolver_destino(arg) { + Some(d) => { + let modo = if d.transporte.soporta_pty() { "pty" } else { "exec" }; + s.push_output(OutputLine::notice(format!( + "↗ tab remota — {}@{}:{} · {modo}", + d.user, d.host, d.port + ))); + s.new_tab_source = Some((d.source(), d.etiqueta.clone())); + } + None => { + s.push_output(OutputLine::notice(format!( + "✘ :ssh — no entiendo el destino «{arg}» (esperaba host, user@host o host:puerto)" + ))); + } + } + s +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn parse_destino_pelado_y_con_partes() { + assert_eq!(parse_destino("casa"), Some((None, "casa".into(), None))); + assert_eq!( + parse_destino("ops@edge-1"), + Some((Some("ops".into()), "edge-1".into(), None)) + ); + assert_eq!( + parse_destino("ops@edge-1:2222"), + Some((Some("ops".into()), "edge-1".into(), Some(2222))) + ); + assert_eq!( + parse_destino("edge-1:2222"), + Some((None, "edge-1".into(), Some(2222))) + ); + assert_eq!(parse_destino(" "), None); + } + + #[test] + fn parse_destino_no_parte_un_sufijo_no_numerico() { + // `host:algo` no es un puerto — el host se queda entero. + assert_eq!( + parse_destino("edge-1:web"), + Some((None, "edge-1:web".into(), None)) + ); + } + + #[test] + fn destino_no_guardado_default_pty_y_puerto_22() { + // Un host que no está en hosts.json: PTY por default (es lo que uno + // espera de un `ssh host` a mano) y puerto estándar. + let d = resolver_destino("maquina-que-no-existe-en-hosts-json.invalid").unwrap(); + assert_eq!(d.port, 22); + assert!(d.transporte.soporta_pty()); + assert!(d.etiqueta.contains('@')); + } + + #[test] + fn ssh_sin_argumento_no_deja_intencion() { + let s = apply_ssh(State::new(Source::Local), ""); + assert!(s.new_tab_source.is_none()); + } + + #[test] + fn ssh_con_destino_deja_la_intencion_para_el_host() { + let s = apply_ssh(State::new(Source::Local), "ops@edge-1:2222"); + let (src, etiqueta) = s.new_tab_source.expect("debe dejar la intención"); + assert_eq!(etiqueta, "ops@edge-1"); + match src { + Source::Remote { host, user, port, transporte, .. } => { + assert_eq!(host, "edge-1"); + assert_eq!(user, "ops"); + assert_eq!(port, 2222); + assert!(transporte.soporta_pty()); + } + other => panic!("esperaba Source::Remote, vino {other:?}"), + } + } +} diff --git a/02_ruway/shuma/sandbox/shuma-module-shell/src/update/surface.rs b/02_ruway/shuma/sandbox/shuma-module-shell/src/update/surface.rs new file mode 100644 index 0000000..6f1bb7a --- /dev/null +++ b/02_ruway/shuma/sandbox/shuma-module-shell/src/update/surface.rs @@ -0,0 +1,377 @@ +use super::*; + +/// Actualiza la selección viva del cuerpo de output en modo superficie. El +/// primer Move arranca (`anchor = head = point_at(ax, ay)`); los siguientes +/// extienden (`head = point_at(acc)`); End deja la selección fijada pero +/// `surf_selecting = false` para que un próximo Move arranque limpio. +pub(crate) fn apply_surf_select_drag( + mut s: State, + phase: llimphi_ui::DragPhase, + dx: f32, + dy: f32, + ax: f32, + ay: f32, +) -> State { + use llimphi_ui::DragPhase; + use llimphi_widget_terminal::{point_at_geo, SelectionRange}; + // Snapshot del layout publicado por la `view` el frame previo. Sin él + // no podemos resolver `(lx, ly)` a `Point` — es no-op silencioso. + let snap = match s.surf_layout.lock() { + Ok(g) => g.clone(), + Err(p) => p.into_inner().clone(), + }; + let Some(snap) = snap else { + return s; + }; + match phase { + DragPhase::Move => { + if !s.surf_selecting { + // Primer evento del drag: ancla en (ax, ay). + s.surf_selecting = true; + s.surf_drag_acc = (ax, ay); + let p = point_at_geo( + &snap.items_geo, + snap.scroll_y, + snap.viewport_h, + snap.metrics, + snap.gutter_w, + &snap.store, + ax, + ay, + ); + s.surf_selection = p.map(SelectionRange::collapsed); + } else { + // Extender: acumulamos delta sobre la posición previa. + s.surf_drag_acc.0 += dx; + s.surf_drag_acc.1 += dy; + let p = point_at_geo( + &snap.items_geo, + snap.scroll_y, + snap.viewport_h, + snap.metrics, + snap.gutter_w, + &snap.store, + s.surf_drag_acc.0, + s.surf_drag_acc.1, + ); + if let (Some(sel), Some(p)) = (s.surf_selection.as_mut(), p) { + sel.head = p; + } + } + } + DragPhase::End => { + s.surf_selecting = false; + // Si el drag fue tan corto que la selección quedó colapsada, + // limpiamos — un click sin arrastre no debería dejar una + // selección vacía visible (es la misma UX que xterm/gnome-term). + if let Some(sel) = s.surf_selection { + if sel.is_empty() && !s.surf_copy_mode { + // Un click sin arrastre no deja selección vacía visible — SALVO + // en copy-mode, donde ese click reposiciona el caret (la + // selección colapsada ES el caret; borrarla lo perdía). + s.surf_selection = None; + } else if !sel.is_empty() { + // Copy-on-select estilo kitty: al soltar, la selección se + // copia sola (texto crudo, sin prependir el comando). + copy_surf_selection_raw(&s); + } + } + } + } + s +} + +/// Comando a prependir al copiar una selección que arranca en `start_line`: el +/// del bloque cuyo rango de store `[a, b)` la contiene. `None` si la línea no +/// cae en ningún bloque con comando conocido. **Puro** — base de «copiar +/// también el comando si se le selecciona». +pub(crate) fn command_for_selection_start( + block_ranges: &[(usize, usize, u64)], + block_command: &std::collections::HashMap, + start_line: usize, +) -> Option { + block_ranges + .iter() + .find(|(a, b, _)| start_line >= *a && start_line < *b) + .and_then(|(_, _, id)| block_command.get(id)) + .cloned() +} + +/// Copia al **portapapeles principal** la selección viva del output, **con el +/// comando** prependido si la selección arranca dentro de un bloque (paridad +/// con el `:copy` del modo card y con el Ctrl+C de xterm). La vía "copiar +/// comando + salida" del menú contextual / Ctrl+Shift+C. No-op si no hay +/// selección. +pub(crate) fn copy_surf_selection(s: &State) { + copy_surf_selection_impl(s, true, false) +} + +/// Como [`copy_surf_selection`] pero **crudo** y al **cuasi-clipboard PRIMARY**: +/// copia exactamente lo seleccionado, sin prependir el comando. Es el +/// copy-on-select estilo X11 (al soltar/extender la selección se copia sola al +/// PRIMARY, que pega el botón medio) — separado del portapapeles principal. +pub(crate) fn copy_surf_selection_raw(s: &State) { + copy_surf_selection_impl(s, false, true) +} + +/// `include_command`: prepende el comando del bloque donde arranca la selección. +/// `to_primary`: destino = cuasi-clipboard PRIMARY (botón medio) en vez del +/// portapapeles principal (Ctrl+V). +fn copy_surf_selection_impl(s: &State, include_command: bool, to_primary: bool) { + let Some(sel) = s.surf_selection.as_ref() else { + return; + }; + let snap = match s.surf_layout.lock() { + Ok(g) => g.clone(), + Err(p) => p.into_inner().clone(), + }; + let Some(snap) = snap else { + return; + }; + let text = sel.slice_text(&snap.store); + if text.is_empty() { + return; + } + // «Copiar también el comando si se le selecciona»: el comando es chrome (no + // una línea del store), así que no entra en `slice_text`. En la vía + // explícita, si la selección arranca dentro del cuerpo de un bloque + // prependemos su comando — copiar la salida de `ls` da `$ ls\n`. + let out = if include_command { + let (start, _end) = sel.normalized(); + match command_for_selection_start(&snap.block_ranges, &s.block_command, start.line) { + Some(c) => format!("{c}\n{text}"), + None => text, + } + } else { + text + }; + if to_primary { + crate::update::clipboard::set_primary(&out); + } else { + crate::update::clipboard::set_clipboard(&out); + } +} + +/// Doble-click sobre el cuerpo de output: selecciona la palabra bajo el +/// punto (paridad con xterm/gnome-terminal). Resuelve `(lx, ly)` a `Point` +/// con `point_at_geo`, computa los boundaries de palabra en char-indices y +/// los convierte a offsets de byte UTF-8 para armar el `SelectionRange`. +pub(crate) fn apply_surf_double_click( + mut s: State, + lx: f32, + ly: f32, + _rect_w: f32, + _rect_h: f32, +) -> State { + use llimphi_widget_terminal::{point_at_geo, Point, SelectionRange}; + let snap = match s.surf_layout.lock() { + Ok(g) => g.clone(), + Err(p) => p.into_inner().clone(), + }; + let Some(snap) = snap else { + return s; + }; + let Some(hit) = point_at_geo( + &snap.items_geo, + snap.scroll_y, + snap.viewport_h, + snap.metrics, + snap.gutter_w, + &snap.store, + lx, + ly, + ) else { + return s; + }; + let Some(text) = snap.store.line(hit.line) else { + return s; + }; + // El click se entrega en byte_col; `word_range_at` opera en char-indices. + // Convertir byte → char. + let char_col = text[..hit.col.min(text.len())].chars().count(); + let (start_char, end_char) = word_range_at(text, char_col); + if end_char <= start_char { + return s; + } + // Char-indices → byte offsets. + let mut chars_seen = 0usize; + let mut start_byte = text.len(); + let mut end_byte = text.len(); + for (b, _) in text.char_indices() { + if chars_seen == start_char { + start_byte = b; + } + if chars_seen == end_char { + end_byte = b; + break; + } + chars_seen += 1; + } + s.surf_selection = Some(SelectionRange { + anchor: Point::new(hit.line, start_byte), + head: Point::new(hit.line, end_byte), + }); + s +} + +/// Triple-click sobre el cuerpo de output: selecciona la línea entera bajo +/// el punto (paridad con xterm/gnome-terminal). Reusa `point_at_geo` para +/// localizar la línea y arma `SelectionRange` de (line, 0) a (line, +/// text.len()). No-op silencioso si el click cae en chrome o fuera del +/// store. +pub(crate) fn apply_surf_triple_click(mut s: State, lx: f32, ly: f32) -> State { + use llimphi_widget_terminal::{point_at_geo, Point, SelectionRange}; + let snap = match s.surf_layout.lock() { + Ok(g) => g.clone(), + Err(p) => p.into_inner().clone(), + }; + let Some(snap) = snap else { + return s; + }; + let Some(hit) = point_at_geo( + &snap.items_geo, + snap.scroll_y, + snap.viewport_h, + snap.metrics, + snap.gutter_w, + &snap.store, + lx, + ly, + ) else { + return s; + }; + let Some(text) = snap.store.line(hit.line) else { + return s; + }; + s.surf_selection = Some(SelectionRange { + anchor: Point::new(hit.line, 0), + head: Point::new(hit.line, text.len()), + }); + s +} + +/// Las acciones del menú contextual del output, en el orden en que se pintan. +/// **Fuente única**: la [`surf_context_menu`](crate::view) construye sus items a +/// partir de esta lista y [`apply_surf_menu_pick`] la indexa igual — así el +/// índice del pick nunca se desincroniza de lo que ve el usuario, aunque los +/// items sean condicionales (Ejecutar sólo en modo shell, etc.). +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub(crate) enum SurfMenuAction { + /// Copiar la selección al portapapeles principal (con comando si arranca en un bloque). + Copiar, + /// Ejecutar la selección como comando en **esta** sesión (sólo modo shell). + Ejecutar, + /// Ejecutar la selección como comando en un **tab nuevo** (siempre que haya selección). + EjecutarNuevoTab, + /// Pegar el cuasi-clipboard PRIMARY (lo mismo que el botón medio). + Pegar, + /// Copiar todo el scrollback en memoria. + CopiarTodo, + /// Seleccionar todo el scrollback. + SeleccionarTodo, +} + +/// Construye la lista de acciones del menú contextual según el estado: «Ejecutar» +/// aparece con selección **y** en modo shell (no en una consola/PTY viva, donde +/// tipear va al programa); «Ejecutar en nuevo tab» aparece con cualquier +/// selección (útil también en la consola de claude: correr en fresco lo que +/// sugirió). Copiar/Pegar/Copiar todo/Seleccionar todo van siempre. +pub(crate) fn surf_menu_actions(s: &State) -> Vec { + use SurfMenuAction::*; + let hay_sel = s.surf_selection.as_ref().is_some_and(|x| !x.is_empty()); + let modo_shell = s.tui_skin_vivo.is_none(); + let mut v = vec![Copiar]; + if hay_sel { + if modo_shell { + v.push(Ejecutar); + } + v.push(EjecutarNuevoTab); + } + v.push(Pegar); + v.push(CopiarTodo); + v.push(SeleccionarTodo); + v +} + +/// Texto de la selección viva del output (crudo, sin prependir comando), o `None` +/// si no hay selección resoluble. Base de «Ejecutar»/«Ejecutar en nuevo tab». +fn surf_selection_text(s: &State, snap: &crate::SurfLayout) -> Option { + let sel = s.surf_selection.as_ref()?; + let t = sel.slice_text(&snap.store); + let t = t.trim(); + (!t.is_empty()).then(|| t.to_string()) +} + +/// Aplica el item elegido del menú contextual del surface y lo cierra. El `idx` +/// indexa la lista de [`surf_menu_actions`] (misma que pinta la vista). +pub(crate) fn apply_surf_menu_pick(mut s: State, idx: usize) -> State { + use llimphi_widget_terminal::{Point, SelectionRange}; + let snap = match s.surf_layout.lock() { + Ok(g) => g.clone(), + Err(p) => p.into_inner().clone(), + }; + let Some(snap) = snap else { + s.surf_menu = None; + return s; + }; + let Some(action) = surf_menu_actions(&s).get(idx).copied() else { + s.surf_menu = None; + return s; + }; + match action { + SurfMenuAction::Copiar => copy_surf_selection(&s), + SurfMenuAction::Ejecutar => { + // Correr la selección como comando en ESTA sesión: igual que + // `Msg::RunLine` (setea el input y submitea). El menú cierra abajo. + if let Some(cmd) = surf_selection_text(&s, &snap) { + s.input.set_text(cmd); + s = crate::update::run_exec::run_submitted(s); + } + } + SurfMenuAction::EjecutarNuevoTab => { + // El módulo no tiene tabs: dejamos la intención para que el host abra + // una tab fresca y le mande `RunLine` (drenada con `take_new_tab_cmd`). + if let Some(cmd) = surf_selection_text(&s, &snap) { + s.new_tab_cmd = Some(cmd); + } + } + SurfMenuAction::Pegar => { + // Pegar: el cuasi-clipboard PRIMARY (lo último seleccionado). Al PTY + // si hay consola viva, al input si no — igual que el botón medio. + let text = crate::update::clipboard::get_primary(); + if !text.is_empty() { + if s.tui_skin_vivo.is_some() && s.canvas_visible { + crate::update::forward_text_to_pty(&s, &text); + } else { + s.input.insert(&crate::update::clipboard::sanitize_paste(&text)); + s.focused = true; + } + } + } + SurfMenuAction::CopiarTodo => { + // Copia todo el scrollback vigente (líneas spilled NO incluidas — + // serían lookups async; el menú "todo" copia lo en memoria). + let n = snap.store.len(); + if n > 0 { + let text = snap.store.slice_text(0, n); + if let Ok(mut cb) = arboard::Clipboard::new() { + let _ = cb.set_text(text); + } + } + } + SurfMenuAction::SeleccionarTodo => { + // Selección desde (0,0) hasta el final de la última línea. + let n = snap.store.len(); + if n > 0 { + let last = n - 1; + let last_len = snap.store.line(last).map(|t| t.len()).unwrap_or(0); + s.surf_selection = Some(SelectionRange { + anchor: Point::new(0, 0), + head: Point::new(last, last_len), + }); + } + } + } + s.surf_menu = None; + s +} diff --git a/02_ruway/shuma/sandbox/shuma-module-shell/src/update/utils.rs b/02_ruway/shuma/sandbox/shuma-module-shell/src/update/utils.rs new file mode 100644 index 0000000..c7620f5 --- /dev/null +++ b/02_ruway/shuma/sandbox/shuma-module-shell/src/update/utils.rs @@ -0,0 +1,211 @@ +use super::*; + +pub(crate) fn apply_cd(mut s: State, rest: &str) -> State { + // En un contenedor el `cd` es contra el FS de ADENTRO, no el del host: + // resolvemos el path de forma léxica (sin `canonicalize`, que miraría el + // host) y actualizamos el cwd interior. Sin verificación de existencia — + // el siguiente comando (que corre con `cd ` adentro) reporta el error + // si el dir no existe. + if matches!(s.source, Source::Container { .. } | Source::RemoteContainer { .. }) { + let trimmed = rest.trim(); + let base = if trimmed.is_empty() { + PathBuf::from("/root") // HOME del root dentro del contenedor + } else if trimmed.starts_with('/') { + PathBuf::from(trimmed) + } else { + s.cwd.join(trimmed) + }; + s.cwd = normalize_lexical(&base); + s.completion_source = crate::completion_source_for(&s.source, &s.cwd); + return s; + } + // Remoto (SSH): cada comando es un `ssh exec` (shell nuevo en $HOME). v1: + // sólo persistimos `cd` a rutas ABSOLUTAS (un `cd` relativo no tiene contra + // qué resolver sin un round-trip). El cwd se antepone como `cd` en run_ssh. + if matches!(s.source, Source::Remote { .. }) { + let trimmed = rest.trim(); + if trimmed.is_empty() { + s.cwd = PathBuf::from("~"); + } else if trimmed.starts_with('/') { + s.cwd = normalize_lexical(&PathBuf::from(trimmed)); + } else { + s.push_output(OutputLine::notice( + "cd remoto (v1): usa una ruta absoluta (p. ej. cd /var/log)", + )); + } + return s; + } + let target = if rest.trim().is_empty() { + // `cd` sin args → HOME (convención bash/zsh). + match std::env::var("HOME") { + Ok(h) => PathBuf::from(h), + Err(_) => { + s.push_output(OutputLine::notice("cd: HOME no está definido")); + return s; + } + } + } else { + let trimmed = rest.trim(); + let p = PathBuf::from(trimmed); + if p.is_absolute() { + p + } else { + s.cwd.join(p) + } + }; + match std::fs::canonicalize(&target) { + Ok(canonical) => { + if canonical.is_dir() { + s.cwd = canonical; + s = maybe_fire_cwd_rule(s); + } else { + s.push_output(OutputLine::notice(format!( + "cd: no es un directorio: {}", + target.display() + ))); + } + } + Err(e) => { + s.push_output(OutputLine::notice(format!("cd: {}: {e}", target.display()))); + } + } + s +} + +/// E3 — `[rules].on_enter_cwd`: tras un `cd` local exitoso, si el nuevo cwd +/// matchea un prefijo declarado, corre el comando asociado (típicamente +/// `:env …`). La guarda `in_cwd_rule` evita recursión si la regla hace `cd`. +fn maybe_fire_cwd_rule(mut s: State) -> State { + if s.in_cwd_rule || s.config.rules.on_enter_cwd.is_empty() { + return s; + } + let home = std::env::var("HOME").unwrap_or_default(); + let cwd = s.cwd.display().to_string(); + let cmd = s + .config + .rules + .command_for_cwd(&cwd, &home) + .map(|c| c.trim().to_string()); + if let Some(cmd) = cmd { + if !cmd.is_empty() { + s.in_cwd_rule = true; + s.input.set_text(&cmd); + s = run_submitted(s); + s.in_cwd_rule = false; + } + } + s +} + +/// Resuelve `.`/`..` de forma puramente léxica (sin tocar el FS ni seguir +/// symlinks). Para el `cd` dentro de un contenedor, donde el path es del FS +/// de adentro y `canonicalize` (host) no aplica. +fn normalize_lexical(p: &std::path::Path) -> PathBuf { + use std::path::Component; + let mut out: Vec = Vec::new(); + for comp in p.components() { + match comp { + Component::RootDir | Component::Prefix(_) => {} + Component::CurDir => {} + Component::ParentDir => { + out.pop(); + } + Component::Normal(c) => out.push(c.to_os_string()), + } + } + let mut res = PathBuf::from("/"); + for c in out { + res.push(c); + } + res +} + +pub(crate) fn split_first_word(line: &str) -> Option<(&str, &str)> { + let line = line.trim_start(); + if line.is_empty() { + return None; + } + match line.find(char::is_whitespace) { + Some(i) => Some((&line[..i], &line[i + 1..])), + None => Some((line, "")), + } +} + +/// Acciona el click sobre una decoración del output. Ninguna acción +/// bloquea la UI: `xdg-open` se forkea detached, y los cambios al +/// state (cwd, input) son in-memory. +pub(crate) fn open_decoration(mut s: State, kind: shuma_line::DecorationKind) -> State { + use shuma_line::DecorationKind as Dk; + match kind { + Dk::Path { + abs, + is_dir, + is_executable, + .. + } => { + if is_dir { + // Directorios → cd. Cambia el cwd y lo refleja en el + // header sin "ejecutar" un comando. + if abs.is_dir() { + s.cwd = abs; + s.completion_source = crate::completion_source_for(&s.source, &s.cwd); + } + } else if is_executable { + // Binarios → pre-llenar el input con el path; el + // usuario decide los args y Enter. + s.input.set_text(abs.display().to_string()); + } else { + // Archivos regulares → xdg-open detached. + spawn_detached("xdg-open", &[abs.display().to_string().as_str()]); + } + } + Dk::Url(url) => { + spawn_detached("xdg-open", &[&url]); + } + Dk::GrepRef { abs, line_no, col } => { + // `$EDITOR +line file` para vim/neovim/helix; si no hay + // EDITOR, xdg-open al archivo y listo. + if let Ok(editor) = std::env::var("EDITOR") { + let line_flag = format!("+{line_no}"); + let path = abs.display().to_string(); + let args: Vec<&str> = match col { + Some(_) => vec![&line_flag, &path], + None => vec![&line_flag, &path], + }; + spawn_detached(&editor, &args); + } else { + spawn_detached("xdg-open", &[abs.display().to_string().as_str()]); + } + } + Dk::GitSha(sha) => { + // Pre-llenar `git show ` — la acción más útil 99% del tiempo. + s.input.set_text(format!("git show {sha}")); + } + Dk::IssueRef(_) + | Dk::BoxDraw + | Dk::Number + | Dk::DateTime + | Dk::Severity(_) + | Dk::Version + | Dk::Percent + | Dk::PermMask => { + // Sin acción asociada — coloreo puro. + } + } + s +} + +/// Lanza un proceso "detached" — no esperamos, no leemos su output, +/// y el padre puede morir sin matarlo (`process_group(0)` para +/// despegarlo de la sesión de shuma). Usado para `xdg-open` y `$EDITOR` +/// disparados desde clicks. +pub(crate) fn spawn_detached(program: &str, args: &[&str]) { + use std::os::unix::process::CommandExt; + let _ = std::process::Command::new(program) + .args(args) + .stdin(std::process::Stdio::null()) + .stdout(std::process::Stdio::null()) + .stderr(std::process::Stdio::null()) + .process_group(0) + .spawn(); +} diff --git a/02_ruway/shuma/sandbox/shuma-module-shell/src/view.rs b/02_ruway/shuma/sandbox/shuma-module-shell/src/view.rs deleted file mode 100644 index 637e029..0000000 --- a/02_ruway/shuma/sandbox/shuma-module-shell/src/view.rs +++ /dev/null @@ -1,2052 +0,0 @@ -use super::*; - -pub fn view( - state: &State, - theme: &Theme, - lift: impl Fn(Msg) -> HostMsg + Clone + Send + Sync + 'static, -) -> View { - let header = shell_header(state, theme); - let main_panel: View = if is_tui_active(state) { - tui_panel::(state, theme, lift.clone()) - } else { - output_pane::(state, theme, &lift) - }; - // Panel de grupos [RUN] a la izquierda (rescate del shell GPUI): cada - // grupo guardado (`:save`) es una card clickable que lo ejecuta, con su - // tecla F. Sólo aparece si hay grupos y no estamos en un TUI fullscreen. - let body: View = if !state.groups.is_empty() && !is_tui_active(state) { - View::new(Style { - flex_direction: FlexDirection::Row, - size: Size { - width: percent(1.0_f32), - height: Dimension::auto(), - }, - flex_basis: length(0.0_f32), - flex_grow: 1.0, - min_size: Size { - width: Dimension::auto(), - height: length(0.0_f32), - }, - gap: Size { - width: length(8.0_f32), - height: length(0.0_f32), - }, - align_items: Some(AlignItems::Stretch), - ..Default::default() - }) - .children(vec![groups_panel::(state, theme, &lift), main_panel]) - } else { - main_panel - }; - let input = shell_input_view(state, theme, lift.clone()); - - let mut children = vec![header, body]; - // Banner de reprocess: el próximo comando recibe por stdin el stdout - // del bloque armado. Click → cancela (toggle). - if let Some(src) = state.reprocess_source { - children.push( - View::new(Style { - size: Size { - width: percent(1.0_f32), - height: length(18.0_f32), - }, - padding: Rect { - left: length(8.0_f32), - right: length(8.0_f32), - top: length(0.0_f32), - bottom: length(0.0_f32), - }, - ..Default::default() - }) - .fill(theme.bg_input) - .radius(3.0) - .hover_fill(theme.bg_row_hover) - .on_click(lift(Msg::SetReprocess(src))) - .text_aligned( - format!("» reprocesando la salida del bloque #{src} — Enter ejecuta · click cancela"), - 10.0, - theme.accent, - Alignment::Start, - ), - ); - } - // Popup de completado: justo encima del input, candidatos con el - // resaltado actual. Tab/flechas navegan, Enter acepta, Esc cierra. - if let Some(popup) = completion_popup::(state, theme) { - children.push(popup); - } - children.push(input); - if state.history_search.is_some() { - children.push(history_search_panel::(state, theme)); - } - - View::new(Style { - flex_direction: FlexDirection::Column, - size: Size { - width: percent(1.0_f32), - height: percent(1.0_f32), - }, - padding: Rect { - left: length(12.0_f32), - right: length(12.0_f32), - top: length(10.0_f32), - bottom: length(10.0_f32), - }, - gap: Size { - width: length(0.0_f32), - height: length(8.0_f32), - }, - ..Default::default() - }) - .fill(theme.bg_app) - .children(children) -} - -/// Color por `TokenKind` — paleta diseñada para que el comando salte y -/// los flags/strings tengan su propio tono. -pub(crate) fn token_color( - kind: TokenKind, - theme: &Theme, -) -> llimphi_ui::llimphi_raster::peniko::Color { - use llimphi_ui::llimphi_raster::peniko::Color; - match kind { - TokenKind::Command => theme.accent, - TokenKind::Argument => theme.fg_text, - TokenKind::Flag => Color::from_rgba8(220, 200, 120, 255), // amarillo - TokenKind::StringLit => Color::from_rgba8(160, 210, 140, 255), // verde - TokenKind::Variable => Color::from_rgba8(200, 160, 220, 255), // violeta - TokenKind::Pipe | TokenKind::Redirect | TokenKind::Operator => theme.accent, - TokenKind::Comment | TokenKind::Whitespace => theme.fg_muted, - TokenKind::Unknown => theme.fg_destructive, - } -} - -/// Renderiza la línea de entrada con tokens coloreados, cursor visible -/// y ghost suggestion. El layout es un nodo único con `paint_with` — -/// medimos cada token con el typesetter en el closure para alinear el -/// cursor al carácter exacto. -pub(crate) fn shell_input_view( - state: &State, - theme: &Theme, - lift: impl Fn(Msg) -> HostMsg + Clone + Send + Sync + 'static, -) -> View { - use llimphi_ui::llimphi_raster::peniko::Color; - let bg = if state.focused { - theme.bg_input_focus - } else { - theme.bg_input - }; - let border = if state.focused { - theme.border_focus - } else { - theme.border - }; - - let text = state.input.text().to_string(); - let cursor = state.input.cursor(); - let ghost = current_ghost(state); - let placeholder = if text.is_empty() && ghost.is_none() { - Some("tipeá un comando…".to_string()) - } else { - None - }; - // Multi-línea: cada `\n` agrega una línea visible y crece el alto - // del input. El cursor cae en (línea, columna) calculadas desde el - // byte offset del cursor. - let line_count = text.matches('\n').count() + 1; - const LINE_H: f64 = 18.0; - const BORDER_INNER_H: f64 = 16.0; // padding visual sumado al alto - let container_h = BORDER_INNER_H + LINE_H * line_count as f64; - let theme_clone = *theme; - let focused = state.focused; - - let painter = move |scene: &mut vello::Scene, - ts: &mut llimphi_ui::llimphi_text::Typesetter, - rect: llimphi_ui::PaintRect| { - use llimphi_ui::llimphi_text::{ - draw_layout, layout_block, measurement, Alignment as TAlign, TextBlock, - }; - let pad_x = 10.0; - let baseline_y = rect.y as f64 + 8.0; - let line_x_start = rect.x as f64 + pad_x; - - if let Some(ph) = &placeholder { - let block = TextBlock { - text: ph, - size_px: 13.0, - color: theme_clone.fg_placeholder, - origin: (line_x_start, baseline_y), - max_width: None, - alignment: TAlign::Start, - line_height: 1.2, - italic: false, - font_family: None, - }; - let layout = layout_block(ts, &block); - draw_layout( - scene, - &layout, - theme_clone.fg_placeholder, - (line_x_start, baseline_y), - ); - } - - // Calcular qué línea/columna ocupa el cursor. - let (cursor_line_idx, cursor_byte_in_line) = { - let pre = &text[..cursor]; - let line_idx = pre.matches('\n').count(); - let line_start = pre.rfind('\n').map(|i| i + 1).unwrap_or(0); - (line_idx, cursor - line_start) - }; - - let mut cursor_x: f64 = line_x_start; - let mut cursor_y: f64 = baseline_y; - let mut last_line_end_x: f64 = line_x_start; - let mut last_line_y: f64 = baseline_y; - let mut line_byte_start = 0usize; - for (line_idx, line_str) in text.split('\n').enumerate() { - let line_y = baseline_y + line_idx as f64 * LINE_H; - let mut x = line_x_start; - // Pintar tokens sobre el slice de la línea, usando el - // tokenizer estándar (dialect por defecto = bash). - let tokens = shuma_line::tokenize(line_str, state_dialect_default()); - for tok in &tokens { - let color = token_color(tok.kind, &theme_clone); - let segment = &line_str[tok.start..tok.end]; - let block = TextBlock { - text: segment, - size_px: 13.0, - color, - origin: (x, line_y), - max_width: None, - alignment: TAlign::Start, - line_height: 1.2, - italic: false, - font_family: None, - }; - let layout = layout_block(ts, &block); - let m = measurement(&layout); - draw_layout(scene, &layout, color, (x, line_y)); - if line_idx == cursor_line_idx - && tok.start < cursor_byte_in_line - && cursor_byte_in_line <= tok.end - { - let prefix = &line_str[tok.start..cursor_byte_in_line]; - if prefix.is_empty() { - cursor_x = x; - } else { - let pblock = TextBlock { - text: prefix, - size_px: 13.0, - color, - origin: (x, line_y), - max_width: None, - alignment: TAlign::Start, - line_height: 1.2, - italic: false, - font_family: None, - }; - let plat = layout_block(ts, &pblock); - cursor_x = x + measurement(&plat).width as f64; - } - cursor_y = line_y; - } - x += m.width as f64; - } - // Cursor al final de una línea vacía / sin tokens hasta el cursor. - if line_idx == cursor_line_idx - && (cursor_byte_in_line == line_str.len() || tokens.is_empty()) - { - cursor_x = x; - cursor_y = line_y; - } - last_line_end_x = x; - last_line_y = line_y; - line_byte_start += line_str.len() + 1; // +1 por el '\n' - } - let _ = line_byte_start; // sólo informativo - - // Ghost suggestion: sólo aplica si el cursor está al final del - // texto (última línea, columna final). Lo pinta detrás del cursor. - if let Some(suffix) = &ghost { - if !suffix.is_empty() && cursor == text.len() { - let block = TextBlock { - text: suffix, - size_px: 13.0, - color: theme_clone.fg_placeholder, - origin: (last_line_end_x, last_line_y), - max_width: None, - alignment: TAlign::Start, - line_height: 1.2, - italic: false, - font_family: None, - }; - let layout = layout_block(ts, &block); - draw_layout( - scene, - &layout, - theme_clone.fg_placeholder, - (last_line_end_x, last_line_y), - ); - } - } - - // Cursor — barra vertical de 2 px en la línea calculada. - if focused { - use llimphi_ui::llimphi_raster::kurbo::Rect as KurboRect; - use llimphi_ui::llimphi_raster::peniko::Fill; - let cursor_rect = - KurboRect::new(cursor_x, cursor_y + 2.0, cursor_x + 2.0, cursor_y + LINE_H); - scene.fill( - Fill::NonZero, - vello::kurbo::Affine::IDENTITY, - Color::from_rgba8(214, 222, 232, 220), - None, - &cursor_rect, - ); - } - }; - - let inner = View::new(Style { - size: Size { - width: percent(1.0_f32), - height: percent(1.0_f32), - }, - ..Default::default() - }) - .fill(bg) - .radius(3.0) - .paint_with(painter); - - View::new(Style { - size: Size { - width: percent(1.0_f32), - height: length(container_h as f32), - }, - padding: Rect { - left: length(1.0_f32), - right: length(1.0_f32), - top: length(1.0_f32), - bottom: length(1.0_f32), - }, - ..Default::default() - }) - .fill(border) - .radius(4.0) - .on_click(lift(Msg::FocusInput)) - .children(vec![inner]) -} - -/// Dialect por defecto para el painter — el `LineState` lo guarda -/// internamente pero no lo expone; mientras todos los usos sean bash -/// alcanza con este getter. -pub(crate) fn state_dialect_default() -> shuma_line::Dialect { - shuma_line::Dialect::default() -} - -/// Panel de TUI app-aware: según el programa bajo el PTY elige un skin. -/// `is_tui_active(state)` ya garantiza que hay un run con PTY. vim se -/// pinta como un card themeable; el resto cae al grid vt100 crudo. -pub(crate) fn tui_panel( - state: &State, - theme: &Theme, - lift: impl Fn(Msg) -> HostMsg + Clone + Send + Sync + 'static, -) -> View { - // Snapshot + skin en un solo lock; la closure de paint debe ser - // `Send + Sync`, así que no captura el Mutex. - let (snapshot, skin) = match state.running.as_ref().and_then(|arc| arc.lock().ok()) { - Some(g) => { - let skin = g.tui.as_ref().map(|t| t.skin).unwrap_or(AppSkin::Generic); - (capture_tui(&g), skin) - } - None => (None, AppSkin::Generic), - }; - let rect_slot = Arc::clone(&state.last_tui_rect); - if let AppSkin::Vim = skin { - let metrics_slot = Arc::clone(&state.vim_metrics); - return vim_panel::( - snapshot, - theme, - rect_slot, - metrics_slot, - state.vim_sel, - lift, - ); - } - generic_grid_panel::(snapshot, theme, rect_slot) -} - -/// Render de grilla vt100 cruda — el camino histórico para htop/less/man. -pub(crate) fn generic_grid_panel( - snapshot: Option, - theme: &Theme, - rect_slot: Arc>, -) -> View { - let theme_clone = *theme; - - let painter = move |scene: &mut vello::Scene, - ts: &mut llimphi_ui::llimphi_text::Typesetter, - rect: llimphi_ui::PaintRect| { - use llimphi_ui::llimphi_raster::kurbo::Rect as KurboRect; - use llimphi_ui::llimphi_raster::peniko::{Color, Fill}; - use llimphi_ui::llimphi_text::{draw_layout, layout_block, Alignment as TAlign, TextBlock}; - // Publica el rect al state — el próximo Tick disparará resize - // si las dims cambiaron. - if let Ok(mut g) = rect_slot.lock() { - *g = (rect.w, rect.h); - } - let Some(snap) = &snapshot else { return }; - // Tamaño de la celda derivado del rect disponible. Monoespacio, - // ancho/alto fijos por celda. Si el panel es chico el grid - // se recorta abajo/derecha (no scrolleamos por ahora). - let pad = 6.0_f64; - let avail_w = (rect.w as f64 - pad * 2.0).max(0.0); - let avail_h = (rect.h as f64 - pad * 2.0).max(0.0); - let cell_w = (avail_w / snap.cols as f64).max(1.0); - let cell_h = (avail_h / snap.rows as f64).max(1.0); - let font_size = (cell_h * 0.75).clamp(8.0, 18.0) as f32; - let origin_x = rect.x as f64 + pad; - let origin_y = rect.y as f64 + pad; - - // Backgrounds primero (en bloques rect), texto encima. - for (r, row) in snap.cells.iter().enumerate() { - for (c, cell) in row.iter().enumerate() { - let bg = vt_color(cell.bg, theme_clone, true); - if bg.components[3] > 0.0 { - let x0 = origin_x + c as f64 * cell_w; - let y0 = origin_y + r as f64 * cell_h; - let rect = KurboRect::new(x0, y0, x0 + cell_w, y0 + cell_h); - scene.fill( - Fill::NonZero, - vello::kurbo::Affine::IDENTITY, - bg, - None, - &rect, - ); - } - } - } - // Texto por celda. Para reducir shaping, agrupamos runs con - // mismo color contiguo en la misma fila. - for (r, row) in snap.cells.iter().enumerate() { - let mut c = 0usize; - while c < row.len() { - let fg = vt_color(row[c].fg, theme_clone, false); - let mut end = c + 1; - let mut buf = String::new(); - buf.push_str(&row[c].ch); - while end < row.len() && row[end].fg == row[c].fg { - buf.push_str(&row[end].ch); - end += 1; - } - if !buf.trim().is_empty() { - let x0 = origin_x + c as f64 * cell_w; - let y0 = origin_y + r as f64 * cell_h; - let block = TextBlock { - text: &buf, - size_px: font_size, - color: fg, - origin: (x0, y0), - max_width: None, - alignment: TAlign::Start, - line_height: 1.0, - italic: false, - font_family: None, - }; - let layout = layout_block(ts, &block); - draw_layout(scene, &layout, fg, (x0, y0)); - } - c = end; - } - } - // Cursor: barra vertical en (cursor_r, cursor_c). - if !snap.hide_cursor { - let x0 = origin_x + snap.cursor_c as f64 * cell_w; - let y0 = origin_y + snap.cursor_r as f64 * cell_h; - let rect = KurboRect::new(x0, y0 + 2.0, x0 + 2.0, y0 + cell_h); - scene.fill( - Fill::NonZero, - vello::kurbo::Affine::IDENTITY, - Color::from_rgba8(214, 222, 232, 220), - None, - &rect, - ); - } - }; - - View::new(Style { - size: Size { - width: percent(1.0_f32), - height: Dimension::auto(), - }, - flex_grow: 1.0, - ..Default::default() - }) - .fill(theme.bg_panel) - .radius(3.0) - .paint_with(painter) -} - -/// Skin de vim: reconstruye cada fila del `Screen` como una línea de -/// texto en la paleta del tema — sin la grilla de celdas ni los `~` de -/// relleno —, con la última fila como barra de estado. El contenido se -/// lee como un output normal, dentro del card del panel; las teclas -/// siguen yendo al PTY (vim sigue siendo interactivo). -/// -/// MVP: read-only (la selección/click-derecho-pegar nativos vienen -/// después, sobre el widget de texto). El objetivo de este paso es que -/// vim deje de verse "como por un vidrio". -/// Geometría del card de vim — compartida entre el painter (resaltado) -/// y `copy_vim_selection` (px → celda) para que las celdas coincidan. -/// `VIM_PAD` es fijo (margen del panel); el avance horizontal y el alto -/// de línea son *fallbacks* — los reales los mide el painter sobre el -/// layout de parley y los publica en `State::vim_metrics`. -pub(crate) const VIM_PAD: f64 = 10.0; -pub(crate) const VIM_LINE_H: f64 = 16.0; -pub(crate) const VIM_CHAR_W: f64 = 7.8; -pub(crate) const VIM_FONT_PX: f32 = 13.0; - -/// Coordenadas locales (px, relativas al rect del panel) → celda (fila, -/// col), con las métricas reales del monospace (`char_w`, `line_h`). -pub(crate) fn vim_px_to_cell(x: f64, y: f64, char_w: f64, line_h: f64) -> (usize, usize) { - let col = (((x - VIM_PAD) / char_w).floor()).max(0.0) as usize; - let row = (((y - VIM_PAD) / line_h).floor()).max(0.0) as usize; - (row, col) -} - -pub(crate) fn vim_panel( - snapshot: Option, - theme: &Theme, - rect_slot: Arc>, - metrics_slot: Arc>, - sel: Option, - lift: L, -) -> View -where - HostMsg: Clone + 'static, - L: Fn(Msg) -> HostMsg + Clone + Send + Sync + 'static, -{ - let theme_clone = *theme; - let lift_drag = lift.clone(); - let painter = move |scene: &mut vello::Scene, - ts: &mut llimphi_ui::llimphi_text::Typesetter, - rect: llimphi_ui::PaintRect| { - use llimphi_ui::llimphi_raster::kurbo::Rect as KurboRect; - use llimphi_ui::llimphi_raster::peniko::{Color, Fill}; - use llimphi_ui::llimphi_text::{draw_layout, layout_block, Alignment as TAlign, TextBlock}; - // Publica el rect para que el próximo Tick dispare resize si cambió. - if let Ok(mut g) = rect_slot.lock() { - *g = (rect.w, rect.h); - } - let Some(snap) = &snapshot else { return }; - let pad = VIM_PAD; - let font = VIM_FONT_PX; - // Métricas reales del monospace: medimos un bloque-sonda de 40 - // glifos idénticos y dividimos para el avance horizontal; el alto - // del layout (line_height 1.0) da el alto de línea. Adivinar las - // constantes desfasa el resaltado al acumularse por columna. - const PROBE: &str = "0000000000000000000000000000000000000000"; // 40 - let probe = TextBlock { - text: PROBE, - size_px: font, - color: theme_clone.fg_text, - origin: (0.0, 0.0), - max_width: None, - alignment: TAlign::Start, - line_height: 1.0, - italic: false, - font_family: None, - }; - let m = llimphi_ui::llimphi_text::measure(ts, &probe); - let char_w = if m.width > 1.0 { - (m.width as f64) / PROBE.len() as f64 - } else { - VIM_CHAR_W - }; - let line_h = if m.height > 1.0 { - m.height as f64 - } else { - VIM_LINE_H - }; - // Publica las métricas para que `copy_vim_selection` use las mismas. - if let Ok(mut g) = metrics_slot.lock() { - *g = (char_w as f32, line_h as f32); - } - let origin_x = rect.x as f64 + pad; - let origin_y = rect.y as f64 + pad; - let n = snap.cells.len(); - // Resaltado de la selección (drag): un rect translúcido por fila. - if let Some(vs) = sel { - let (r0, c0) = vim_px_to_cell(vs.ax as f64, vs.ay as f64, char_w, line_h); - let (r1, c1) = vim_px_to_cell(vs.hx as f64, vs.hy as f64, char_w, line_h); - let (sr, sc, er, ec) = if (r0, c0) <= (r1, c1) { - (r0, c0, r1, c1) - } else { - (r1, c1, r0, c0) - }; - let ncols = snap.cells.first().map(|row| row.len()).unwrap_or(0); - let er = er.min(n.saturating_sub(1)); - let bg = theme_clone.bg_selected; - let sel_color = Color::from_rgba8( - (bg.components[0] * 255.0) as u8, - (bg.components[1] * 255.0) as u8, - (bg.components[2] * 255.0) as u8, - 120, - ); - for r in sr..=er { - let lo = if r == sr { sc } else { 0 }; - let hi = if r == er { (ec + 1).min(ncols) } else { ncols }; - if hi <= lo { - continue; - } - let x0 = origin_x + lo as f64 * char_w; - let x1 = origin_x + hi as f64 * char_w; - let y0 = origin_y + r as f64 * line_h; - let hrect = KurboRect::new(x0, y0, x1, y0 + line_h); - scene.fill( - Fill::NonZero, - vello::kurbo::Affine::IDENTITY, - sel_color, - None, - &hrect, - ); - } - } - for (r, row) in snap.cells.iter().enumerate() { - let raw: String = row.iter().map(|c| c.ch.as_str()).collect(); - let line_str = raw.trim_end(); - // La última fila es la barra de estado / línea de comando de vim. - let is_status = n > 1 && r + 1 == n; - // Relleno de vim: una fila cuyo único contenido es `~`. - if !is_status && line_str.trim_start() == "~" { - continue; - } - let y = origin_y + r as f64 * line_h; - let color = if is_status { - theme_clone.accent - } else { - theme_clone.fg_text - }; - if is_status { - // Fondo sutil para distinguir la barra de estado del buffer. - let bar = - KurboRect::new(rect.x as f64, y - 2.0, (rect.x + rect.w) as f64, y + line_h); - scene.fill( - Fill::NonZero, - vello::kurbo::Affine::IDENTITY, - theme_clone.bg_input, - None, - &bar, - ); - } - if !line_str.is_empty() { - let block = TextBlock { - text: line_str, - size_px: font, - color, - origin: (origin_x, y), - max_width: None, - alignment: TAlign::Start, - line_height: 1.0, - italic: false, - font_family: None, - }; - let layout = layout_block(ts, &block); - draw_layout(scene, &layout, color, (origin_x, y)); - } - } - // Cursor: barra vertical en la posición del cursor de vim. - if !snap.hide_cursor { - let x0 = origin_x + snap.cursor_c as f64 * char_w; - let y0 = origin_y + snap.cursor_r as f64 * line_h; - let cur = KurboRect::new(x0, y0 + 2.0, x0 + 2.0, y0 + line_h); - scene.fill( - Fill::NonZero, - vello::kurbo::Affine::IDENTITY, - Color::from_rgba8(214, 222, 232, 220), - None, - &cur, - ); - } - }; - - View::new(Style { - size: Size { - width: percent(1.0_f32), - height: Dimension::auto(), - }, - flex_grow: 1.0, - ..Default::default() - }) - .fill(theme.bg_panel) - .radius(3.0) - .paint_with(painter) - // Selección estilo terminal: arrastrar con el botón izquierdo - // selecciona celdas; al soltar se copia al clipboard. - .draggable_at(move |phase, dx, dy, lx0, ly0| { - Some(lift_drag(Msg::VimDrag { - end: matches!(phase, llimphi_ui::DragPhase::End), - dx, - dy, - ax: lx0, - ay: ly0, - })) - }) - // Paste estilo terminal: click derecho y botón del medio pegan el - // clipboard al PTY (vim sigue recibiendo las teclas aparte). - .on_right_click(lift(Msg::VimPaste)) - .on_middle_click(lift(Msg::VimPaste)) -} - -/// Snapshot copiable del Screen para enviar a una closure `paint_with`. -pub(crate) struct TuiSnapshot { - cells: Vec>, - rows: u16, - cols: u16, - cursor_r: u16, - cursor_c: u16, - hide_cursor: bool, -} - -#[derive(Clone)] -pub(crate) struct TuiCell { - ch: String, - fg: vt100::Color, - bg: vt100::Color, -} - -/// Copia el screen actual de un `ActiveRun` PTY a un snapshot -/// `Send`-able. Devuelve `None` si el run no es TUI. -pub(crate) fn capture_tui(active: &std::sync::MutexGuard<'_, ActiveRun>) -> Option { - let tui = active.tui.as_ref()?; - let screen = tui.parser.screen(); - let (rows, cols) = screen.size(); - let mut cells: Vec> = Vec::with_capacity(rows as usize); - for r in 0..rows { - let mut row: Vec = Vec::with_capacity(cols as usize); - for c in 0..cols { - let (ch, fg, bg) = match screen.cell(r, c) { - Some(cell) => ( - if cell.has_contents() { - cell.contents().to_string() - } else { - " ".to_string() - }, - cell.fgcolor(), - cell.bgcolor(), - ), - None => (" ".into(), vt100::Color::Default, vt100::Color::Default), - }; - row.push(TuiCell { ch, fg, bg }); - } - cells.push(row); - } - let (cursor_r, cursor_c) = screen.cursor_position(); - Some(TuiSnapshot { - cells, - rows, - cols, - cursor_r, - cursor_c, - hide_cursor: screen.hide_cursor(), - }) -} - -/// Convierte un `vt100::Color` a un `peniko::Color`, respetando el tema -/// del shell (los 16 índices ANSI se mapean a una paleta consistente). -pub(crate) fn vt_color( - c: vt100::Color, - theme: Theme, - is_bg: bool, -) -> llimphi_ui::llimphi_raster::peniko::Color { - use llimphi_ui::llimphi_raster::peniko::Color; - match c { - vt100::Color::Default => { - if is_bg { - // Transparent — el panel ya tiene su propio fill. - Color::from_rgba8(0, 0, 0, 0) - } else { - theme.fg_text - } - } - vt100::Color::Rgb(r, g, b) => Color::from_rgba8(r, g, b, 255), - vt100::Color::Idx(i) => ansi_idx_to_color(i), - } -} - -/// Mapeo 256 → RGB usando la paleta xterm estándar. Cubre los 16 -/// básicos, el cubo 6×6×6 y la rampa de grises. -pub(crate) fn ansi_idx_to_color(i: u8) -> llimphi_ui::llimphi_raster::peniko::Color { - use llimphi_ui::llimphi_raster::peniko::Color; - const BASIC: [[u8; 3]; 16] = [ - [0, 0, 0], - [205, 49, 49], - [13, 188, 121], - [229, 229, 16], - [36, 114, 200], - [188, 63, 188], - [17, 168, 205], - [229, 229, 229], - [102, 102, 102], - [241, 76, 76], - [35, 209, 139], - [245, 245, 67], - [59, 142, 234], - [214, 112, 214], - [41, 184, 219], - [255, 255, 255], - ]; - if i < 16 { - let [r, g, b] = BASIC[i as usize]; - return Color::from_rgba8(r, g, b, 255); - } - if i >= 232 { - let v = 8 + (i - 232) * 10; - return Color::from_rgba8(v, v, v, 255); - } - let i = i - 16; - let r = i / 36; - let g = (i / 6) % 6; - let b = i % 6; - let to_byte = |x: u8| if x == 0 { 0 } else { 55 + x * 40 }; - Color::from_rgba8(to_byte(r), to_byte(g), to_byte(b), 255) -} - -/// Overlay de búsqueda Ctrl-R. Vive como hijo extra del root cuando -/// `state.history_search` está activo; un input + lista de matches. -pub(crate) fn history_search_panel( - state: &State, - theme: &Theme, -) -> View { - let search = state - .history_search - .as_ref() - .expect("panel sólo se construye con search activo"); - let matches: Vec = { - let history = state.history.lock().unwrap(); - history - .fuzzy_search(&search.query, 50) - .into_iter() - .map(|e| e.line.clone()) - .collect() - }; - let label = format!("Ctrl-R › {}", search.query); - let mut children: Vec> = vec![View::new(Style { - size: Size { - width: percent(1.0_f32), - height: length(20.0_f32), - }, - ..Default::default() - }) - .text_aligned(label, 12.0, theme.accent, Alignment::Start)]; - - for (i, m) in matches.iter().enumerate().take(8) { - let color = if i == search.selected { - theme.accent - } else { - theme.fg_text - }; - let bg = if i == search.selected { - theme.bg_selected - } else { - theme.bg_panel - }; - children.push( - View::new(Style { - size: Size { - width: percent(1.0_f32), - height: length(18.0_f32), - }, - ..Default::default() - }) - .fill(bg) - .text_aligned(m.clone(), 12.0, color, Alignment::Start), - ); - } - - View::new(Style { - flex_direction: FlexDirection::Column, - size: Size { - width: percent(1.0_f32), - height: Dimension::auto(), - }, - padding: Rect { - left: length(10.0_f32), - right: length(10.0_f32), - top: length(8.0_f32), - bottom: length(8.0_f32), - }, - gap: Size { - width: length(0.0_f32), - height: length(2.0_f32), - }, - ..Default::default() - }) - .fill(theme.bg_panel) - .radius(3.0) - .children(children) -} - -pub(crate) fn shell_header( - state: &State, - theme: &Theme, -) -> View { - let status = if let Some(arc) = state.running.as_ref() { - let cmd = match arc.lock() { - Ok(g) => g.command.clone(), - Err(p) => p.into_inner().command.clone(), - }; - let queued = state.queue.len(); - if queued > 0 { - format!(" · ⟳ {cmd} (+{queued} en cola)") - } else { - format!(" · ⟳ {cmd}") - } - } else { - String::new() - }; - // Rama git del cwd, si estamos en un repo (`· (main)`). La fuente del - // shell no trae el glifo ⎇, así que usamos la convención de paréntesis. - let branch = match git_branch(&state.cwd) { - Some(b) => format!(" · ({b})"), - None => String::new(), - }; - let label = format!( - "Shell · {} · cwd: {}{}{}", - state.source.label(), - pretty_path(&state.cwd), - branch, - status, - ); - let color = if state.is_running() { - theme.accent - } else { - theme.fg_text - }; - View::new(Style { - size: Size { - width: percent(1.0_f32), - height: length(24.0_f32), - }, - ..Default::default() - }) - .text_aligned(label, 12.0, color, Alignment::Start) -} - -/// Panel de grupos `[RUN]` a la izquierda: una card por grupo guardado -/// (`:save`), clickable para ejecutarlo, con su tecla F. Ancho fijo. El -/// caller ya garantizó que hay ≥1 grupo. -pub(crate) fn groups_panel( - state: &State, - theme: &Theme, - lift: &(impl Fn(Msg) -> HostMsg + Clone + Send + Sync + 'static), -) -> View { - const PANEL_W: f32 = 176.0; - let mut children: Vec> = vec![View::new(Style { - size: Size { - width: percent(1.0_f32), - height: length(18.0_f32), - }, - ..Default::default() - }) - .text_aligned("GRUPOS".to_string(), 10.0, theme.fg_muted, Alignment::Start)]; - - for (i, g) in state.groups.iter().enumerate() { - let title = format!("F{} {}", i + 1, g.name); - let sub = format!("{} cmds", g.lines.len()); - let card = View::new(Style { - flex_direction: FlexDirection::Column, - size: Size { - width: percent(1.0_f32), - height: length(38.0_f32), - }, - padding: Rect { - left: length(6.0_f32), - right: length(6.0_f32), - top: length(3.0_f32), - bottom: length(3.0_f32), - }, - gap: Size { - width: length(0.0_f32), - height: length(1.0_f32), - }, - ..Default::default() - }) - .fill(theme.bg_input) - .radius(4.0) - .hover_fill(theme.bg_row_hover) - .on_click(lift(Msg::RunGroup(i))) - .children(vec![ - View::new(Style { - size: Size { - width: percent(1.0_f32), - height: length(16.0_f32), - }, - ..Default::default() - }) - .text_aligned(title, 12.0, theme.accent, Alignment::Start), - View::new(Style { - size: Size { - width: percent(1.0_f32), - height: length(14.0_f32), - }, - ..Default::default() - }) - .text_aligned(sub, 10.0, theme.fg_muted, Alignment::Start), - ]); - children.push(card); - } - - View::new(Style { - flex_direction: FlexDirection::Column, - size: Size { - width: length(PANEL_W), - height: percent(1.0_f32), - }, - flex_shrink: 0.0, - padding: Rect { - left: length(6.0_f32), - right: length(6.0_f32), - top: length(6.0_f32), - bottom: length(6.0_f32), - }, - gap: Size { - width: length(0.0_f32), - height: length(4.0_f32), - }, - ..Default::default() - }) - .fill(theme.bg_panel) - .radius(3.0) - .children(children) -} - -/// Popup de completado: lista de candidatos con el actual resaltado. Se -/// pinta sobre el input (en la columna, justo antes). Acota a `MAX_ROWS` -/// filas visibles centradas en el índice. `None` si no hay popup abierto. -pub(crate) fn completion_popup( - state: &State, - theme: &Theme, -) -> Option> { - let comp = state.completion.as_ref()?; - if comp.candidates.is_empty() { - return None; - } - const MAX_ROWS: usize = 8; - const ROW: f32 = 18.0; - let n = comp.candidates.len(); - let sel = state.completion_index.min(n - 1); - // Ventana deslizante centrada en la selección. - let start = sel.saturating_sub(MAX_ROWS / 2).min(n.saturating_sub(MAX_ROWS)); - let end = (start + MAX_ROWS).min(n); - - let mut rows: Vec> = Vec::new(); - for (i, cand) in comp.candidates[start..end].iter().enumerate() { - let idx = start + i; - let selected = idx == sel; - let (fill, fg) = if selected { - (theme.accent, theme.bg_panel) - } else { - (theme.bg_input, theme.fg_text) - }; - rows.push( - View::new(Style { - size: Size { - width: percent(1.0_f32), - height: length(ROW), - }, - padding: Rect { - left: length(8.0_f32), - right: length(8.0_f32), - top: length(0.0_f32), - bottom: length(0.0_f32), - }, - ..Default::default() - }) - .fill(fill) - .text_aligned(cand.clone(), 12.0, fg, Alignment::Start), - ); - } - // Pie con el conteo cuando hay más de lo que entra. - let mut total_rows = rows.len(); - if n > MAX_ROWS { - rows.push( - View::new(Style { - size: Size { - width: percent(1.0_f32), - height: length(ROW), - }, - padding: Rect { - left: length(8.0_f32), - right: length(8.0_f32), - top: length(0.0_f32), - bottom: length(0.0_f32), - }, - ..Default::default() - }) - .text_aligned( - format!("{}/{} · Tab/↑↓ navega · Enter acepta · Esc cierra", sel + 1, n), - 10.0, - theme.fg_muted, - Alignment::Start, - ), - ); - total_rows += 1; - } - - Some( - View::new(Style { - flex_direction: FlexDirection::Column, - size: Size { - width: percent(1.0_f32), - height: length(total_rows as f32 * ROW + 4.0), - }, - padding: Rect { - left: length(2.0_f32), - right: length(2.0_f32), - top: length(2.0_f32), - bottom: length(2.0_f32), - }, - ..Default::default() - }) - .fill(theme.bg_panel) - .radius(4.0) - .children(rows), - ) -} - -// Geometría fija del panel de output. Debe coincidir EXACTAMENTE con los -// `Style` de `output_pane`/`command_card`: el scroll calcula `content_h` -// con estas constantes (no medimos el árbol; con alturas fijas alcanza). -pub(crate) const PANE_PAD_V: f32 = 12.0; // padding top 6 + bottom 6 del column interno -pub(crate) const PANE_GAP: f32 = 6.0; // gap entre cards / líneas sueltas -pub(crate) const CARD_PAD_V: f32 = 9.0; // card padding top 4 + bottom 5 -pub(crate) const CARD_GAP: f32 = 2.0; // gap entre hijos de la card -pub(crate) const HEADER_H: f32 = 20.0; // header de la card -pub(crate) const STAGES_H: f32 = 20.0; // fila de etapas de pipe -pub(crate) const ROW_H: f32 = 16.0; // una línea de output - -pub(crate) fn output_pane( - state: &State, - theme: &Theme, - lift: &(impl Fn(Msg) -> HostMsg + Clone + Send + Sync + 'static), -) -> View { - const MAX_VISIBLE: usize = 400; - let start = state.output.len().saturating_sub(MAX_VISIBLE); - let visible = &state.output[start..]; - - // Agrupamos por `block` COLECTANDO todas las líneas del bloque aunque - // se intercalen en el buffer (un job de fondo que escupe entre líneas - // del foreground ya no fragmenta ni contamina ninguna card). El orden - // de las cards es el de primera aparición del bloque. - let mut order: Vec = Vec::new(); - let mut groups: std::collections::HashMap> = - std::collections::HashMap::new(); - for line in visible { - if !groups.contains_key(&line.block) { - order.push(line.block); - } - groups.entry(line.block).or_default().push(line); - } - - // Cada item lleva su alto exacto → `content_h` para el scroll. - let mut items: Vec<(View, f32)> = Vec::new(); - for id in &order { - let g = &groups[id]; - if g.first() - .map(|l| l.kind == OutputKind::Prompt) - .unwrap_or(false) - { - items.push(command_card::( - g.as_slice(), - *id, - state, - theme, - lift, - )); - } else { - // Líneas sueltas (tope parcial tras capar, notices iniciales). - for &line in g.iter() { - items.push(( - render_output_line::(line, &state.cwd, theme, lift), - ROW_H, - )); - } - } - } - - let content_h = if items.is_empty() { - PANE_PAD_V - } else { - PANE_PAD_V - + items.iter().map(|(_, h)| *h).sum::() - + PANE_GAP * (items.len() as f32 - 1.0) - }; - let children: Vec> = items.into_iter().map(|(v, _)| v).collect(); - - // Scroll: el viewport lo midió el painter el frame anterior. Por - // defecto pegado al fondo (lo último visible, como una terminal); - // `scroll_px` (rueda) desplaza hacia el historial. Publicamos el - // overflow para que `Msg::Scroll` clampe sin recomputar geometría. - let viewport_h = state.out_viewport_h.lock().map(|g| *g).unwrap_or(0.0); - let overflow = (content_h - viewport_h).max(0.0); - if let Ok(mut g) = state.out_overflow.lock() { - *g = overflow; - } - let ty: f64 = if viewport_h < 1.0 { - 0.0 // primer frame, todavía sin medir → tope - } else { - (state.scroll_px.clamp(0.0, overflow) - overflow) as f64 - }; - - let inner = View::new(Style { - flex_direction: FlexDirection::Column, - size: Size { - width: percent(1.0_f32), - height: Dimension::auto(), - }, - padding: Rect { - left: length(8.0_f32), - right: length(8.0_f32), - top: length(6.0_f32), - bottom: length(6.0_f32), - }, - gap: Size { - width: length(0.0_f32), - height: length(PANE_GAP), - }, - align_items: Some(AlignItems::Stretch), - ..Default::default() - }) - .transform(vello::kurbo::Affine::translate((0.0, ty))) - .children(children); - - // El painter publica el alto del viewport; coexiste con los hijos - // (el compositor pinta painter y luego children). - let slot = Arc::clone(&state.out_viewport_h); - let painter = move |_scene: &mut vello::Scene, - _ts: &mut llimphi_ui::llimphi_text::Typesetter, - rect: llimphi_ui::PaintRect| { - if let Ok(mut g) = slot.lock() { - *g = rect.h; - } - }; - - View::new(Style { - flex_direction: FlexDirection::Column, - size: Size { - width: percent(1.0_f32), - height: Dimension::auto(), - }, - // Región scrolleable en una flex column: `flex_basis: 0` + - // `min_height: 0` para que tome SÓLO el espacio sobrante (tras el - // header y el input) y NO el tamaño de su contenido. Sin esto el - // alto del contenido (un `ls` largo) se filtra al flex-basis y el - // panel aplasta/expulsa el input. El contenido se clipa adentro. - flex_basis: length(0.0_f32), - flex_grow: 1.0, - min_size: Size { - width: Dimension::auto(), - height: length(0.0_f32), - }, - ..Default::default() - }) - .fill(theme.bg_panel) - .radius(3.0) - .clip(true) - .paint_with(painter) - .children(vec![inner]) -} - -/// Color del badge de estado a partir del texto de la notice de cierre -/// (`✔ exit 0`, `✘ exit N`, `⏹ cancel …`). `None` si la línea no es un -/// estado de cierre — se queda en el cuerpo de la card. -pub(crate) fn status_color( - text: &str, - theme: &Theme, -) -> Option { - use llimphi_ui::llimphi_raster::peniko::Color; - let t = text.trim_start(); - if t.starts_with('✔') { - Some(Color::from_rgba8(120, 200, 140, 255)) // verde "ok" - } else if t.starts_with('✘') || t.starts_with('⏹') { - Some(theme.fg_destructive) - } else { - None - } -} - -/// Extrae el comando crudo del texto del header (`$ ls | wc`, o el de un -/// job de fondo `[0] $ sleep 5 &`) — para parsear las etapas del pipe. -pub(crate) fn extract_command(header: &str) -> String { - let after = header.splitn(2, "$ ").nth(1).unwrap_or(header); - after.trim().trim_end_matches('&').trim_end().to_string() -} - -/// Fila de etapas de un pipe: `⇢ a | b | c`, cada etapa clickable para -/// re-ejecutar la línea truncada hasta ahí (inspeccionar intermedios). -/// `None` si la línea no es un pipe de ≥2 etapas. Recuperada del shuma -/// GPUI viejo (commit 3751aadb), ahora sobre Llimphi. -pub(crate) fn pipe_stages_row( - header_text: &str, - theme: &Theme, - lift: &(impl Fn(Msg) -> HostMsg + Clone + Send + Sync + 'static), -) -> Option> { - let cmd = extract_command(header_text); - let toks = shuma_line::tokenize(&cmd, state_dialect_default()); - let pipe = shuma_line::split_pipeline(&toks); - if pipe.stages.len() < 2 { - return None; - } - let raw_parts: Vec<&str> = cmd.split('|').collect(); - let mut row_children: Vec> = vec![View::new(Style { - size: Size { - width: length(16.0_f32), - height: length(16.0_f32), - }, - ..Default::default() - }) - .text_aligned("⇢".to_string(), 11.0, theme.fg_muted, Alignment::Start)]; - - for (i, st) in pipe.stages.iter().enumerate() { - let label = st - .command - .clone() - .unwrap_or_else(|| format!("etapa {}", i + 1)); - // Prefijo a re-ejecutar: la línea hasta esta etapa, inclusive. - let prefix = raw_parts - .get(..=i) - .map(|p| p.join("|").trim().to_string()) - .unwrap_or_else(|| cmd.clone()); - let l = lift.clone(); - row_children.push( - View::new(Style { - size: Size { - width: Dimension::auto(), - height: length(16.0_f32), - }, - padding: Rect { - left: length(5.0_f32), - right: length(5.0_f32), - top: length(0.0_f32), - bottom: length(0.0_f32), - }, - ..Default::default() - }) - .fill(theme.bg_input) - .radius(3.0) - .hover_fill(theme.bg_row_hover) - .on_click(l(Msg::RunLine(prefix))) - .text_aligned(label, 11.0, theme.fg_text, Alignment::Start), - ); - } - - Some( - View::new(Style { - flex_direction: FlexDirection::Row, - size: Size { - width: percent(1.0_f32), - height: length(STAGES_H), - }, - align_items: Some(AlignItems::Center), - gap: Size { - width: length(5.0_f32), - height: length(0.0_f32), - }, - ..Default::default() - }) - .children(row_children), - ) -} - -/// Paleta de etapa — hues desaturados, en la misma familia que la de -/// tokens. Cicla a las 6; un pipe con más etapas reusa colores, sigue -/// siendo legible. -const STAGE_PALETTE: [(u8, u8, u8); 6] = [ - (130, 195, 205), // teal - (220, 190, 120), // ámbar - (160, 205, 150), // verde - (195, 160, 215), // violeta - (220, 160, 150), // coral - (150, 180, 225), // azul -]; - -/// Color estable por índice de etapa — para que cada etapa del pipe lea -/// distinto de un vistazo (chip + sus líneas + su barra-guía). -pub(crate) fn stage_color(i: usize) -> llimphi_ui::llimphi_raster::peniko::Color { - use llimphi_ui::llimphi_raster::peniko::Color; - let (r, g, b) = STAGE_PALETTE[i % STAGE_PALETTE.len()]; - Color::from_rgba8(r, g, b, 255) -} - -/// Misma tinta, atenuada (alfa 80%) — para el texto de las líneas -/// capturadas: menos peso visual que el chip que las titula. -fn stage_color_dim(i: usize) -> llimphi_ui::llimphi_raster::peniko::Color { - use llimphi_ui::llimphi_raster::peniko::Color; - let (r, g, b) = STAGE_PALETTE[i % STAGE_PALETTE.len()]; - Color::from_rgba8(r, g, b, 204) -} - -/// Bytes a etiqueta compacta: `840`, `1.2K`, `3.4M`. Sin espacio para que -/// quepa en el chip. -fn humanize_bytes(n: usize) -> String { - if n < 1024 { - format!("{n}B") - } else if n < 1024 * 1024 { - format!("{:.1}K", n as f32 / 1024.0) - } else { - format!("{:.1}M", n as f32 / (1024.0 * 1024.0)) - } -} - -/// Fila de etapas con **captura en vivo** (tee): cada chip despliega las -/// líneas intermedias ya capturadas de su etapa, sin re-ejecutar. Devuelve -/// `(views, alto)` — la fila de chips más, por cada etapa desplegada, sus -/// líneas. `stage_lines` son las `OutputLine` con `stage = Some(_)` del -/// bloque. La última etapa no se captura (su salida es el cuerpo). -pub(crate) fn stage_capture_rows( - header_text: &str, - stage_lines: &[&OutputLine], - block: u64, - state: &State, - theme: &Theme, - lift: &(impl Fn(Msg) -> HostMsg + Clone + Send + Sync + 'static), -) -> (Vec>, f32) { - let cmd = extract_command(header_text); - let toks = shuma_line::tokenize(&cmd, state_dialect_default()); - let pipe = shuma_line::split_pipeline(&toks); - if pipe.stages.len() < 2 { - return (Vec::new(), 0.0); - } - - // Chips de etapa. - let mut row_children: Vec> = vec![View::new(Style { - size: Size { - width: length(16.0_f32), - height: length(16.0_f32), - }, - ..Default::default() - }) - .text_aligned("⇢".to_string(), 11.0, theme.fg_muted, Alignment::Start)]; - - for (i, st) in pipe.stages.iter().enumerate() { - let captured = stage_lines.iter().filter(|l| l.stage == Some(i)).count(); - let bytes: usize = stage_lines - .iter() - .filter(|l| l.stage == Some(i)) - .map(|l| l.text.len()) - .sum(); - let expanded = state.expanded_stages.contains(&(block, i)); - let base = st - .command - .clone() - .unwrap_or_else(|| format!("etapa {}", i + 1)); - // Conteo doble (líneas + bytes) sólo cuando hay captura. - let label = if captured > 0 { - format!("{base} {captured}L {}", humanize_bytes(bytes)) - } else { - base - }; - // La última etapa no tiene captura (su salida es el cuerpo): chip - // inerte, en color tenue, para que se vea la estructura del pipe. - let is_last = i + 1 == pipe.stages.len(); - let fill = if expanded { - theme.bg_row_hover - } else { - theme.bg_input - }; - // Color estable por etapa para las que capturan; la última, tenue. - let txt_color = if is_last { - theme.fg_muted - } else { - stage_color(i) - }; - let mut chip = View::new(Style { - size: Size { - width: Dimension::auto(), - height: length(16.0_f32), - }, - padding: Rect { - left: length(5.0_f32), - right: length(5.0_f32), - top: length(0.0_f32), - bottom: length(0.0_f32), - }, - ..Default::default() - }) - .fill(fill) - .radius(3.0) - .text_aligned(label, 11.0, txt_color, Alignment::Start); - if !is_last { - chip = chip - .hover_fill(theme.bg_row_hover) - .on_click(lift(Msg::ToggleStage { block, stage: i })); - } - row_children.push(chip); - } - - let chips_row = View::new(Style { - flex_direction: FlexDirection::Row, - size: Size { - width: percent(1.0_f32), - height: length(STAGES_H), - }, - align_items: Some(AlignItems::Center), - gap: Size { - width: length(5.0_f32), - height: length(0.0_f32), - }, - ..Default::default() - }) - .children(row_children); - - let mut out: Vec> = vec![chips_row]; - let mut height = STAGES_H; - - // Líneas capturadas de cada etapa desplegada, en orden de etapa. Cada - // etapa va como un bloque `Row[barra-guía coloreada | columna de - // líneas]`: la barra ata visualmente las líneas a su chip por color. - for (i, _st) in pipe.stages.iter().enumerate() { - if !state.expanded_stages.contains(&(block, i)) { - continue; - } - let lines: Vec<&&OutputLine> = - stage_lines.iter().filter(|l| l.stage == Some(i)).collect(); - let color = stage_color(i); - let dim = stage_color_dim(i); - - // Columna de líneas (o el placeholder si la etapa aún no emitió). - let mut col_children: Vec> = Vec::new(); - let block_h = if lines.is_empty() { - col_children.push( - row_text(ROW_H) - .text_aligned( - "(sin líneas capturadas)".to_string(), - 11.0, - theme.fg_muted, - Alignment::Start, - ), - ); - ROW_H - } else { - for l in &lines { - col_children.push( - row_text(ROW_H) - .text_aligned(l.text.clone(), 12.0, dim, Alignment::Start), - ); - } - lines.len() as f32 * ROW_H - }; - - let col = View::new(Style { - flex_direction: FlexDirection::Column, - flex_grow: 1.0, - size: Size { - width: Dimension::auto(), - height: length(block_h), - }, - ..Default::default() - }) - .children(col_children); - - // Barra-guía: 2px de ancho, estira al alto del bloque (align-items - // stretch por defecto en el Row), con sangría a izquierda. - let bar = View::new(Style { - size: Size { - width: length(2.0_f32), - height: percent(1.0_f32), - }, - margin: Rect { - left: length(8.0_f32), - right: length(6.0_f32), - top: length(0.0_f32), - bottom: length(0.0_f32), - }, - ..Default::default() - }) - .fill(color) - .radius(1.0); - - out.push( - View::new(Style { - flex_direction: FlexDirection::Row, - size: Size { - width: percent(1.0_f32), - height: length(block_h), - }, - ..Default::default() - }) - .children(vec![bar, col]), - ); - height += block_h; - } - - (out, height) -} - -/// Una fila de texto de alto `h`, ancho completo, sin padding lateral — -/// la sangría la da la barra-guía del bloque de etapa. -fn row_text(h: f32) -> View { - View::new(Style { - size: Size { - width: percent(1.0_f32), - height: length(h), - }, - ..Default::default() - }) -} - -/// Renderiza un bloque-comando como card desplegable: header (chevron + -/// comando + badge de estado, clickable para plegar), opcional fila de -/// etapas de pipe, y cuerpo (la salida, oculta si está colapsado). -/// `group[0]` es el `Prompt`. Devuelve `(view, alto_exacto)` — el alto -/// alimenta el cálculo de scroll de `output_pane`. -pub(crate) fn command_card( - group: &[&OutputLine], - block: u64, - state: &State, - theme: &Theme, - lift: &(impl Fn(Msg) -> HostMsg + Clone + Send + Sync + 'static), -) -> (View, f32) { - let collapsed = state.collapsed.contains(&block); - let header_text = group[0].text.clone(); - - // Separamos la notice de cierre (se promueve a badge), las líneas de - // etapas intermedias (tee — van a su desplegable) y el resto (cuerpo). - // Si hay varias notices de cierre, gana la última. - let mut body: Vec<&OutputLine> = Vec::new(); - let mut stage_lines: Vec<&OutputLine> = Vec::new(); - let mut badge: Option<(String, llimphi_ui::llimphi_raster::peniko::Color)> = None; - for &l in &group[1..] { - if l.stage.is_some() { - stage_lines.push(l); - } else if let Some(color) = status_color(&l.text, theme) { - badge = Some((l.text.clone(), color)); - } else { - body.push(l); - } - } - // Comando aún vivo (sin notice de cierre todavía): spinner en accent. - // (Foreground o job de fondo: ambos siguen "vivos" hasta su exit.) - let still_running = badge.is_none() - && ((state.current_block == block && state.is_running()) - || state.bg_jobs.iter().any(|j| { - j.lock() - .map(|g| g.block == block && !g.handle.is_finished()) - .unwrap_or(false) - })); - if still_running { - badge = Some(("⟳".to_string(), theme.accent)); - } - - let chevron = if collapsed { "▸" } else { "▾" }; - let mut header_children: Vec> = vec![ - View::new(Style { - size: Size { - width: length(14.0_f32), - height: length(16.0_f32), - }, - ..Default::default() - }) - .text_aligned(chevron.to_string(), 11.0, theme.fg_muted, Alignment::Start), - View::new(Style { - size: Size { - width: Dimension::auto(), - height: length(16.0_f32), - }, - flex_grow: 1.0, - ..Default::default() - }) - .text_aligned(header_text.clone(), 12.0, theme.accent, Alignment::Start), - ]; - // Chip de reprocess: alimenta el stdout de esta card como stdin del - // próximo comando. Sólo en cards con stdout. Hit-test innermost-wins: - // el chip gana el click sobre el header (que pliega el bloque). - let has_stdout = group - .iter() - .any(|l| l.kind == OutputKind::Stdout && l.stage.is_none()); - if has_stdout { - let armed = state.reprocess_source == Some(block); - let (fill, fg) = if armed { - (theme.accent, theme.bg_panel) - } else { - (theme.bg_input, theme.fg_muted) - }; - header_children.push( - View::new(Style { - size: Size { - width: Dimension::auto(), - height: length(16.0_f32), - }, - padding: Rect { - left: length(5.0_f32), - right: length(5.0_f32), - top: length(0.0_f32), - bottom: length(0.0_f32), - }, - ..Default::default() - }) - .fill(fill) - .radius(3.0) - .hover_fill(theme.bg_row_hover) - .on_click(lift(Msg::SetReprocess(block))) - .text_aligned("» stdin".to_string(), 10.0, fg, Alignment::Start), - ); - } - if let Some((btxt, bcolor)) = badge { - header_children.push( - View::new(Style { - size: Size { - width: Dimension::auto(), - height: length(16.0_f32), - }, - ..Default::default() - }) - .text_aligned(btxt, 11.0, bcolor, Alignment::End), - ); - } - - let header = View::new(Style { - flex_direction: FlexDirection::Row, - size: Size { - width: percent(1.0_f32), - height: length(HEADER_H), - }, - align_items: Some(AlignItems::Center), - padding: Rect { - left: length(6.0_f32), - right: length(8.0_f32), - top: length(2.0_f32), - bottom: length(2.0_f32), - }, - gap: Size { - width: length(6.0_f32), - height: length(0.0_f32), - }, - ..Default::default() - }) - .fill(theme.bg_input) - .radius(4.0) - .hover_fill(theme.bg_row_hover) - .on_click(lift(Msg::ToggleBlock(block))) - .children(header_children); - - let mut card_children: Vec> = vec![header]; - let mut child_h_sum = HEADER_H; - - // Fila de etapas de pipe (sólo si NO está colapsado y es un pipe). - if !collapsed { - if stage_lines.is_empty() { - // Sin captura en vivo (pipe vía `sh -c` o comando suelto): los - // chips re-ejecutan la línea hasta esa etapa. - if let Some(row) = pipe_stages_row::(&header_text, theme, lift) { - card_children.push(row); - child_h_sum += STAGES_H; - } - } else { - // Con captura (pipe directo + tee): los chips despliegan las - // líneas intermedias ya capturadas, sin re-ejecutar. - let (rows, h) = stage_capture_rows::( - &header_text, - &stage_lines, - block, - state, - theme, - lift, - ); - for r in rows { - card_children.push(r); - } - child_h_sum += h; - } - } - - if collapsed { - if !body.is_empty() { - card_children.push( - View::new(Style { - size: Size { - width: percent(1.0_f32), - height: length(ROW_H), - }, - ..Default::default() - }) - .text_aligned( - format!("⋯ {} líneas", body.len()), - 11.0, - theme.fg_muted, - Alignment::Start, - ), - ); - child_h_sum += ROW_H; - } - } else { - for &line in &body { - card_children.push(render_output_line::(line, &state.cwd, theme, lift)); - child_h_sum += ROW_H; - } - } - - let n_children = card_children.len() as f32; - let card_h = CARD_PAD_V + child_h_sum + CARD_GAP * (n_children - 1.0); - - let view = View::new(Style { - flex_direction: FlexDirection::Column, - size: Size { - width: percent(1.0_f32), - height: Dimension::auto(), - }, - padding: Rect { - left: length(6.0_f32), - right: length(6.0_f32), - top: length(4.0_f32), - bottom: length(5.0_f32), - }, - gap: Size { - width: length(0.0_f32), - height: length(CARD_GAP), - }, - ..Default::default() - }) - .fill(theme.bg_panel_alt) - .radius(5.0) - .children(card_children); - - (view, card_h) -} - -/// Una "pieza" del partición de una línea: el texto, su color y el -/// kind de decoración (`None` = texto base, no clickable). El render -/// la convierte en `View`s; los tests verifican la partición sin -/// pintar. -#[derive(Debug, Clone)] -pub(crate) struct LinePiece { - pub(crate) text: String, - pub(crate) color: llimphi_ui::llimphi_raster::peniko::Color, - pub(crate) deco: Option, -} - -/// Divide `text` en piezas según `decorations`. Las piezas no decoradas -/// llevan `color = base` y `deco = None`. Las decoradas llevan el -/// color según el kind y `deco = Some(kind.clone())`. -pub(crate) fn partition_line( - text: &str, - decorations: &[shuma_line::Decoration], - base: llimphi_ui::llimphi_raster::peniko::Color, - theme: &Theme, -) -> Vec { - use shuma_line::DecorationKind as Dk; - let mut out: Vec = Vec::new(); - let mut cursor = 0usize; - for d in decorations { - if d.start < cursor || d.end > text.len() || d.start >= d.end { - continue; - } - if d.start > cursor { - out.push(LinePiece { - text: text[cursor..d.start].to_string(), - color: base, - deco: None, - }); - } - let color = match &d.kind { - Dk::GitSha(_) => theme.fg_muted, - // El resto va al accent — paths, urls, grep refs, issue refs, - // box-drawing. Sin underline (Llimphi aún no lo soporta). - _ => theme.accent, - }; - out.push(LinePiece { - text: text[d.start..d.end].to_string(), - color, - deco: Some(d.kind.clone()), - }); - cursor = d.end; - } - if cursor < text.len() { - out.push(LinePiece { - text: text[cursor..].to_string(), - color: base, - deco: None, - }); - } - out -} - -/// Pinta una línea del output. Para Stdout/Stderr aplica -/// `shuma_line::decorate_line`: pinta cada span con su color y, si la -/// decoración es accionable (`Path`/`Url`/`GrepRef`/`GitSha`), agrega -/// un `on_click` que dispara `Msg::OpenDecoration`. Para Prompt/Notice -/// usa el atajo `text_aligned` plano. -pub(crate) fn render_output_line( - line: &OutputLine, - cwd: &std::path::Path, - theme: &Theme, - lift: &(impl Fn(Msg) -> HostMsg + Clone + Send + Sync + 'static), -) -> View { - let line_style = Style { - size: Size { - width: percent(1.0_f32), - height: length(16.0_f32), - }, - ..Default::default() - }; - - match line.kind { - OutputKind::Prompt => View::new(line_style).text_aligned( - line.text.clone(), - 12.0, - theme.accent, - Alignment::Start, - ), - OutputKind::Notice => View::new(line_style).text_aligned( - line.text.clone(), - 12.0, - theme.fg_muted, - Alignment::Start, - ), - OutputKind::Stdout | OutputKind::Stderr => { - let base = if matches!(line.kind, OutputKind::Stderr) { - theme.fg_destructive - } else { - theme.fg_text - }; - let decorations = shuma_line::decorate_line(&line.text, cwd); - // Atajo: si no hubo decoraciones, una sola text_aligned alcanza. - if decorations.is_empty() { - return View::new(line_style).text_aligned( - line.text.clone(), - 12.0, - base, - Alignment::Start, - ); - } - let children = - build_span_children::(&line.text, &decorations, base, theme, lift); - View::new(Style { - flex_direction: FlexDirection::Row, - size: Size { - width: percent(1.0_f32), - height: length(16.0_f32), - }, - align_items: Some(AlignItems::Center), - ..Default::default() - }) - .children(children) - } - } -} - -/// Convierte las piezas en una lista de `View`s. Las accionables -/// (Path/Url/GrepRef/GitSha) llevan `on_click`. -/// Mapea la categoría semántica de `shuma-line` al icono vectorial del -/// set canónico `llimphi-icons`. Los iconos monocromos son más gruesos -/// que los emoji (un solo `code` para todos los lenguajes, un `file_text` -/// para todos los documentos) — la pérdida de granularidad es el precio -/// de no depender de fuentes de emoji del sistema. -fn kind_icon(kind: shuma_line::FileKind) -> llimphi_icons::Icon { - use llimphi_icons::Icon; - use shuma_line::FileKind as K; - match kind { - K::Folder => Icon::Folder, - K::Symlink => Icon::Link, - K::Image => Icon::Image, - K::Audio => Icon::Music, - K::Video => Icon::Film, - K::Archive => Icon::Archive, - K::Document => Icon::FileText, - K::Code => Icon::Code, - K::Data => Icon::Code, - K::Font => Icon::Font, - K::Executable => Icon::Settings, - K::Generic => Icon::File, - } -} - -pub(crate) fn build_span_children( - text: &str, - decorations: &[shuma_line::Decoration], - base: llimphi_ui::llimphi_raster::peniko::Color, - theme: &Theme, - lift: &(impl Fn(Msg) -> HostMsg + Clone + Send + Sync + 'static), -) -> Vec> { - use shuma_line::DecorationKind as Dk; - let pieces = partition_line(text, decorations, base, theme); - let mut out: Vec> = Vec::with_capacity(pieces.len()); - for p in pieces { - if p.text.is_empty() { - continue; - } - let actionable = matches!( - p.deco, - Some(Dk::Path { .. } | Dk::Url(_) | Dk::GrepRef { .. } | Dk::GitSha(_)) - ); - // Texto del span. Para paths le anteponemos un icono vectorial por - // tipo (no emoji): así un `ls` se lee como un explorador de - // archivos (carpeta/imagen/código/…) sin depender de fuentes de - // emoji del sistema. - let text_view: View = View::new(Style { - ..Default::default() - }) - .text_aligned(p.text.clone(), 12.0, p.color, Alignment::Start); - let mut span_view: View = match &p.deco { - Some(Dk::Path { - abs, - is_dir, - is_executable, - is_symlink, - }) => { - let kind = shuma_line::file_kind(abs, *is_dir, *is_executable, *is_symlink); - let icon_box: View = View::new(Style { - size: Size { - width: length(13.0_f32), - height: length(13.0_f32), - }, - flex_shrink: 0.0, - ..Default::default() - }) - .children(vec![llimphi_icons::icon_view( - kind_icon(kind), - p.color, - 1.6, - )]); - View::new(Style { - flex_direction: FlexDirection::Row, - align_items: Some(AlignItems::Center), - gap: Size { - width: length(5.0_f32), - height: length(0.0_f32), - }, - ..Default::default() - }) - .children(vec![icon_box, text_view]) - } - _ => text_view, - }; - if let (true, Some(kind)) = (actionable, p.deco) { - let l = lift.clone(); - // Feedback de hover: el span se resalta al pasar el cursor — - // un `ls` se siente como un explorador donde cada archivo - // "responde". (Llimphi no expone cursor-icon del SO; el - // realce es el afford idiomático, igual que en tree/button.) - span_view = span_view - .radius(3.0) - .hover_fill(theme.bg_row_hover) - .on_click(l(Msg::OpenDecoration(kind))); - } - out.push(span_view); - } - out -} - -pub(crate) fn pretty_path(p: &std::path::Path) -> String { - let full = p.display().to_string(); - if let Ok(home) = std::env::var("HOME") { - if full == home { - return "~".into(); - } - if let Some(rest) = full.strip_prefix(&format!("{home}/")) { - return format!("~/{rest}"); - } - } - full -} diff --git a/02_ruway/shuma/sandbox/shuma-module-shell/src/view/ansi.rs b/02_ruway/shuma/sandbox/shuma-module-shell/src/view/ansi.rs new file mode 100644 index 0000000..92836f7 --- /dev/null +++ b/02_ruway/shuma/sandbox/shuma-module-shell/src/view/ansi.rs @@ -0,0 +1,141 @@ +use super::*; + +/// Convierte un `vt100::Color` a un `peniko::Color`, respetando el tema +/// del shell (los 16 índices ANSI se mapean a una paleta consistente). +pub(crate) fn vt_color( + c: vt100::Color, + theme: Theme, + is_bg: bool, +) -> llimphi_ui::llimphi_raster::peniko::Color { + use llimphi_ui::llimphi_raster::peniko::Color; + match c { + vt100::Color::Default => { + if is_bg { + // Transparent — el panel ya tiene su propio fill. + Color::from_rgba8(0, 0, 0, 0) + } else { + theme.fg_text + } + } + vt100::Color::Rgb(r, g, b) => Color::from_rgba8(r, g, b, 255), + vt100::Color::Idx(i) => ansi_idx_to_color(i), + } +} + +/// Empaca un `peniko::Color` a un u32 RGBA8 little-endian listo para el +/// `CellInstance` del pipeline GPU (Fase 4 del SDD-TERMINAL). Espeja +/// `llimphi_widget_terminal::pack_rgba` pero parte del color del runtime +/// (componentes f32 0..1). +pub(crate) fn pack_peniko(c: llimphi_ui::llimphi_raster::peniko::Color) -> u32 { + let r = (c.components[0].clamp(0.0, 1.0) * 255.0) as u8; + let g = (c.components[1].clamp(0.0, 1.0) * 255.0) as u8; + let b = (c.components[2].clamp(0.0, 1.0) * 255.0) as u8; + let a = (c.components[3].clamp(0.0, 1.0) * 255.0) as u8; + llimphi_widget_terminal::pack_rgba(r, g, b, a) +} + +/// Construye las `CellInstance`s a dibujar para un snapshot vt100 sobre el +/// rect del panel del TUI (Fase 4 del SDD-TERMINAL). Itera fila×col, mira +/// el char + colores fg/bg, rasteriza el glifo si todavía no está en el +/// atlas y arma un instance por celda. Las celdas con char vacío o sólo +/// espacio Y bg default se saltan (el fondo del panel cubre). +/// +/// `render_cell_w`/`render_cell_h` son el tamaño de celda en el viewport +/// (deriva del rect / cols×rows); pueden diferir del cell size natural del +/// atlas — la diferencia se absorbe en el shader (el sampler lineal estira +/// el glifo al cell de salida). +pub(crate) fn build_cell_instances( + snap: &TuiSnapshot, + atlas: &mut llimphi_widget_terminal::GlyphAtlas, + theme: Theme, + rect: llimphi_ui::PaintRect, +) -> Vec { + use llimphi_widget_terminal::CellInstance; + if snap.rows == 0 || snap.cols == 0 { + return Vec::new(); + } + let pad = 6.0_f32; + let avail_w = (rect.w - pad * 2.0).max(0.0); + let avail_h = (rect.h - pad * 2.0).max(0.0); + let render_cell_w = (avail_w / snap.cols as f32).max(1.0); + let render_cell_h = (avail_h / snap.rows as f32).max(1.0); + let origin_x = rect.x + pad; + let origin_y = rect.y + pad; + let (atlas_cell_w, atlas_cell_h) = atlas.cell_size(); + + let mut out: Vec = Vec::with_capacity((snap.rows * snap.cols) as usize); + for (r, row) in snap.cells.iter().enumerate() { + for (c, cell) in row.iter().enumerate() { + let bg = vt_color(cell.bg, theme, true); + let fg = vt_color(cell.fg, theme, false); + let ch = cell.ch.chars().next().unwrap_or(' '); + let is_blank = ch == ' ' || ch == '\0'; + // Salta celdas vacías con fondo default — el panel ya pinta su + // bg, no hay nada que cubrir ni que pintar. + if is_blank && bg.components[3] <= 0.001 { + continue; + } + // Pide el slot del glifo. Si el atlas está lleno, intenta + // crecer una vez; si tampoco entra (raro), salta el char. + let slot = match atlas.glyph_for(ch) { + Some(s) => s, + None => { + atlas.grow(); + match atlas.glyph_for(ch) { + Some(s) => s, + None => continue, + } + } + }; + out.push(CellInstance { + cell_x: origin_x + c as f32 * render_cell_w, + cell_y: origin_y + r as f32 * render_cell_h, + uv_x: slot.px as f32, + uv_y: slot.py as f32, + uv_w: atlas_cell_w as f32, + uv_h: atlas_cell_h as f32, + fg_rgba: pack_peniko(fg), + bg_rgba: pack_peniko(bg), + }); + } + } + out +} + +/// Mapeo 256 → RGB usando la paleta xterm estándar. Cubre los 16 +/// básicos, el cubo 6×6×6 y la rampa de grises. +pub(crate) fn ansi_idx_to_color(i: u8) -> llimphi_ui::llimphi_raster::peniko::Color { + use llimphi_ui::llimphi_raster::peniko::Color; + const BASIC: [[u8; 3]; 16] = [ + [0, 0, 0], + [205, 49, 49], + [13, 188, 121], + [229, 229, 16], + [36, 114, 200], + [188, 63, 188], + [17, 168, 205], + [229, 229, 229], + [102, 102, 102], + [241, 76, 76], + [35, 209, 139], + [245, 245, 67], + [59, 142, 234], + [214, 112, 214], + [41, 184, 219], + [255, 255, 255], + ]; + if i < 16 { + let [r, g, b] = BASIC[i as usize]; + return Color::from_rgba8(r, g, b, 255); + } + if i >= 232 { + let v = 8 + (i - 232) * 10; + return Color::from_rgba8(v, v, v, 255); + } + let i = i - 16; + let r = i / 36; + let g = (i / 6) % 6; + let b = i % 6; + let to_byte = |x: u8| if x == 0 { 0 } else { 55 + x * 40 }; + Color::from_rgba8(to_byte(r), to_byte(g), to_byte(b), 255) +} diff --git a/02_ruway/shuma/sandbox/shuma-module-shell/src/view/command_card.rs b/02_ruway/shuma/sandbox/shuma-module-shell/src/view/command_card.rs new file mode 100644 index 0000000..4124685 --- /dev/null +++ b/02_ruway/shuma/sandbox/shuma-module-shell/src/view/command_card.rs @@ -0,0 +1,337 @@ +use super::*; + +/// Alto fijo de una línea de output. Lo comparten la fila de etapas +/// (`stage_capture_rows`) y la superficie de terminal (`surface_view`). Vivía +/// en el `output_pane` viejo (borrado en la Fase 5 del SDD-TERMINAL); ahora +/// reside aquí, el módulo que aún lo necesita. +pub(crate) const ROW_H: f32 = 16.0; // una línea de output +/// Alto de la fila de chips de etapas de un pipe. +pub(crate) const STAGES_H: f32 = 20.0; +/// Duración del fade de colapso/despliegue de los bloques del output. +pub(crate) const COLLAPSE_ANIM: std::time::Duration = std::time::Duration::from_millis(160); + +/// Paleta de etapa — hues desaturados, en la misma familia que la de +/// tokens. Cicla a las 6; un pipe con más etapas reusa colores, sigue +/// siendo legible. +const STAGE_PALETTE: [(u8, u8, u8); 6] = [ + (130, 195, 205), // teal + (220, 190, 120), // ámbar + (160, 205, 150), // verde + (195, 160, 215), // violeta + (220, 160, 150), // coral + (150, 180, 225), // azul +]; + +/// Extrae el comando crudo del texto del header (`$ ls | wc`, o el de un +/// job de fondo `[0] $ sleep 5 &`) — para parsear las etapas del pipe. +pub(crate) fn extract_command(header: &str) -> String { + let after = header.splitn(2, "$ ").nth(1).unwrap_or(header); + after.trim().trim_end_matches('&').trim_end().to_string() +} + +/// Fila de etapas con **captura en vivo** (tee): cada chip despliega las +/// líneas intermedias ya capturadas de su etapa, sin re-ejecutar. Devuelve +/// `(views, alto)` — la fila de chips más, por cada etapa desplegada, sus +/// líneas. `stage_lines` son las `OutputLine` con `stage = Some(_)` del +/// bloque. La última etapa no se captura (su salida es el cuerpo). +pub(crate) fn stage_capture_rows( + header_text: &str, + stage_lines: &[&OutputLine], + block: u64, + state: &State, + theme: &Theme, + lift: &(impl Fn(Msg) -> HostMsg + Clone + Send + Sync + 'static), +) -> (Vec>, f32) { + let cmd = extract_command(header_text); + let toks = shuma_line::tokenize(&cmd, state_dialect_default()); + let pipe = shuma_line::split_pipeline(&toks); + if pipe.stages.len() < 2 { + return (Vec::new(), 0.0); + } + + // Chips de etapa. + let mut row_children: Vec> = vec![View::new(Style { + size: Size { + width: length(16.0_f32), + height: length(16.0_f32), + }, + ..Default::default() + }) + .children(vec![llimphi_icons::icon_view( + llimphi_icons::Icon::ChevronRight, + theme.fg_muted, + 1.6, + )])]; + + for (i, st) in pipe.stages.iter().enumerate() { + let captured = stage_lines.iter().filter(|l| l.stage == Some(i)).count(); + let bytes: usize = stage_lines + .iter() + .filter(|l| l.stage == Some(i)) + .map(|l| l.text.len()) + .sum(); + let expanded = state.expanded_stages.contains(&(block, i)); + let base = st + .command + .clone() + .unwrap_or_else(|| format!("etapa {}", i + 1)); + // El índice `K` al frente hace obvia la ref `%cN.K` (direccionar la + // etapa con :filtra/:write/:yank/:explica). Conteo doble (líneas + + // bytes) sólo cuando hay captura. + let label = if captured > 0 { + format!("{i}· {base} {captured}L {}", humanize_bytes(bytes)) + } else { + format!("{i}· {base}") + }; + // La última etapa no tiene captura (su salida es el cuerpo): chip + // inerte, en color tenue, para que se vea la estructura del pipe. + let is_last = i + 1 == pipe.stages.len(); + let fill = if expanded { + theme.bg_row_hover + } else { + theme.bg_input + }; + // Color estable por etapa para las que capturan; la última, tenue. + let txt_color = if is_last { + theme.fg_muted + } else { + stage_color(i) + }; + let mut chip = View::new(Style { + size: Size { + width: Dimension::auto(), + height: length(16.0_f32), + }, + padding: Rect { + left: length(5.0_f32), + right: length(5.0_f32), + top: length(0.0_f32), + bottom: length(0.0_f32), + }, + ..Default::default() + }) + .fill(fill) + .radius(3.0) + .text_aligned(label, 11.0, txt_color, Alignment::Start); + if !is_last { + chip = chip + .hover_fill(theme.bg_row_hover) + .on_click(lift(Msg::ToggleStage { block, stage: i })); + } + row_children.push(chip); + } + + let chips_row = View::new(Style { + flex_direction: FlexDirection::Row, + size: Size { + width: percent(1.0_f32), + height: length(STAGES_H), + }, + align_items: Some(AlignItems::Center), + gap: Size { + width: length(5.0_f32), + height: length(0.0_f32), + }, + ..Default::default() + }) + .children(row_children); + + let mut out: Vec> = vec![chips_row]; + let mut height = STAGES_H; + + // Líneas capturadas de cada etapa desplegada, en orden de etapa. Cada + // etapa va como un bloque `Row[barra-guía coloreada | columna de + // líneas]`: la barra ata visualmente las líneas a su chip por color. + for (i, _st) in pipe.stages.iter().enumerate() { + if !state.expanded_stages.contains(&(block, i)) { + continue; + } + let lines: Vec<&&OutputLine> = + stage_lines.iter().filter(|l| l.stage == Some(i)).collect(); + let color = stage_color(i); + let dim = stage_color_dim(i); + + // Columna de líneas (o el placeholder si la etapa aún no emitió). + let mut col_children: Vec> = Vec::new(); + let block_h = if lines.is_empty() { + col_children.push( + row_text(ROW_H) + .text_aligned( + "(sin líneas capturadas)".to_string(), + 11.0, + theme.fg_muted, + Alignment::Start, + ), + ); + ROW_H + } else { + for l in &lines { + col_children.push( + row_text(ROW_H) + .text_aligned(l.text.clone(), 12.0, dim, Alignment::Start) + .mono() + // 1 fila: sin esto una línea de etapa larga wrappea y + // pisa la de abajo (la fila es de altura fija ROW_H). + .max_lines(1), + ); + } + lines.len() as f32 * ROW_H + }; + + let col = View::new(Style { + flex_direction: FlexDirection::Column, + flex_grow: 1.0, + size: Size { + width: Dimension::auto(), + height: length(block_h), + }, + ..Default::default() + }) + .children(col_children); + + // Barra-guía: 2px de ancho, estira al alto del bloque (align-items + // stretch por defecto en el Row), con sangría a izquierda. + let bar = View::new(Style { + size: Size { + width: length(2.0_f32), + height: percent(1.0_f32), + }, + margin: Rect { + left: length(8.0_f32), + right: length(6.0_f32), + top: length(0.0_f32), + bottom: length(0.0_f32), + }, + ..Default::default() + }) + .fill(color) + .radius(1.0); + + out.push( + View::new(Style { + flex_direction: FlexDirection::Row, + size: Size { + width: percent(1.0_f32), + height: length(block_h), + }, + ..Default::default() + }) + .children(vec![bar, col]) + // Desplegar/plegar la captura de la etapa con transición. Key en + // un namespace propio (etapa) para no chocar con cuerpo/resumen. + .animated_inout(((block << 8) | (i as u64 & 0xff)) ^ (1 << 62), COLLAPSE_ANIM), + ); + height += block_h; + + // Fila de acciones sobre la etapa: las capturas dejan de ser un + // registro muerto — se filtran (IA), copian, guardan o explican + // direccionando `%cN.K`. Filtrar/guardar prellenan el input (el + // usuario completa instrucción/archivo); copiar/explicar corren ya. + let mk_action = |txt: String, msg: Msg, color: llimphi_ui::llimphi_raster::peniko::Color| { + View::new(Style { + size: Size { + width: Dimension::auto(), + height: length(18.0_f32), + }, + padding: Rect { + left: length(7.0_f32), + right: length(7.0_f32), + top: length(0.0_f32), + bottom: length(0.0_f32), + }, + ..Default::default() + }) + .fill(theme.bg_input) + .radius(3.0) + .hover_fill(theme.bg_row_hover) + .text_aligned(txt, 11.0, color, Alignment::Start) + .on_click(lift(msg)) + }; + let actions = View::new(Style { + flex_direction: FlexDirection::Row, + size: Size { + width: percent(1.0_f32), + height: length(STAGES_H), + }, + align_items: Some(AlignItems::Center), + padding: Rect { + left: length(16.0_f32), + right: length(0.0_f32), + top: length(0.0_f32), + bottom: length(0.0_f32), + }, + gap: Size { + width: length(5.0_f32), + height: length(0.0_f32), + }, + ..Default::default() + }) + .children(vec![ + mk_action( + "🜲 filtrar".to_string(), + Msg::PrefillInput(format!(":filtra %c{block}.{i} ")), + theme.accent, + ), + mk_action( + "copiar".to_string(), + Msg::RunLine(format!(":yank %c{block}.{i}")), + theme.fg_muted, + ), + mk_action( + "guardar".to_string(), + Msg::PrefillInput(format!(":write %c{block}.{i} ")), + theme.fg_muted, + ), + mk_action( + "explicar".to_string(), + Msg::RunLine(format!(":explica %c{block}.{i}")), + theme.fg_muted, + ), + ]); + out.push(actions); + height += STAGES_H; + } + + (out, height) +} + +/// Una fila de texto de alto `h`, ancho completo, sin padding lateral — +/// la sangría la da la barra-guía del bloque de etapa. +fn row_text(h: f32) -> View { + View::new(Style { + size: Size { + width: percent(1.0_f32), + height: length(h), + }, + ..Default::default() + }) +} + +/// Bytes a etiqueta compacta: `840`, `1.2K`, `3.4M`. Sin espacio para que +/// quepa en el chip. +pub(crate) fn humanize_bytes(n: usize) -> String { + if n < 1024 { + format!("{n}B") + } else if n < 1024 * 1024 { + format!("{:.1}K", n as f32 / 1024.0) + } else { + format!("{:.1}M", n as f32 / (1024.0 * 1024.0)) + } +} + +/// Color estable por índice de etapa — para que cada etapa del pipe lea +/// distinto de un vistazo (chip + sus líneas + su barra-guía). +pub(crate) fn stage_color(i: usize) -> llimphi_ui::llimphi_raster::peniko::Color { + use llimphi_ui::llimphi_raster::peniko::Color; + let (r, g, b) = STAGE_PALETTE[i % STAGE_PALETTE.len()]; + Color::from_rgba8(r, g, b, 255) +} + +/// Misma tinta, atenuada (alfa 80%) — para el texto de las líneas +/// capturadas: menos peso visual que el chip que las titula. +pub(crate) fn stage_color_dim(i: usize) -> llimphi_ui::llimphi_raster::peniko::Color { + use llimphi_ui::llimphi_raster::peniko::Color; + let (r, g, b) = STAGE_PALETTE[i % STAGE_PALETTE.len()]; + Color::from_rgba8(r, g, b, 204) +} + diff --git a/02_ruway/shuma/sandbox/shuma-module-shell/src/view/completion.rs b/02_ruway/shuma/sandbox/shuma-module-shell/src/view/completion.rs new file mode 100644 index 0000000..21aa47e --- /dev/null +++ b/02_ruway/shuma/sandbox/shuma-module-shell/src/view/completion.rs @@ -0,0 +1,273 @@ +//! Panel de **completado** del input del shell — el ÚNICO renderer (el popup +//! plano viejo fue eliminado; éste es el "bonito" que nació como surface +//! flotante de pata y bajó al módulo para servir a todos los frontends): +//! candidatos en capas (apps tier 0 con su ícono XDG real, tokens tier 1, +//! líneas/grupos de historial tiers 2/3), la fila resaltada en el acento, +//! etiqueta de origen a la derecha (app/historial/grupo), sombra elevada, +//! borde y animación de aparición. +//! +//! Lee el `State` (`completion`/`completion_extra`/`completion_index`) en el +//! **orden global** que expone [`completion_rows`] — el mismo que navega el +//! teclado — y resuelve el ícono de cada app con [`crate::app_icons`]. El clic +//! en una fila emite [`Msg::PickCompletion`] (índice global) por el `lift` del +//! host. + +use llimphi_theme::{elevation, radius, Theme}; +use llimphi_ui::llimphi_layout::taffy::{ + prelude::{auto, length, percent, FlexDirection, Size, Style}, + AlignItems, JustifyContent, Rect as TaffyRect, +}; +use llimphi_ui::llimphi_raster::peniko::Color; +use llimphi_ui::llimphi_text::Alignment; +use llimphi_ui::{Shadow, View}; + +use crate::types::{State, SugKind}; +use crate::update::{completion_rows, enter_acepta_completion, CompRow}; +use crate::Msg; + +/// Máximo de filas visibles a la vez (ventana deslizante centrada en la +/// selección). +const MAX_ROWS: usize = 8; +/// Alto de una fila (px) — holgado para alojar el ícono de la app. +const ROW_H: f32 = 30.0; + +/// El ícono de una fila-app: el ícono real del tema XDG si el hint es un nombre +/// freedesktop / path (lo que trae casi toda `.desktop`), o el glifo corto de la +/// suite tawasuyu, o un marcador `▸` genérico. Ocupa un badge cuadrado. +fn icono_app( + hint: Option<&str>, + color: Color, +) -> View { + // 1) Ícono real: sólo nombres largos (freedesktop) o paths resuelven a un + // archivo; los glifos de la suite son de 1–2 chars. + if let Some(name) = hint.filter(|s| s.chars().count() > 2 || s.starts_with('/')) { + if let Some(icon) = crate::app_icons::get_or_load(name) { + return View::new(Style { + size: Size { width: percent(1.0_f32), height: percent(1.0_f32) }, + align_items: Some(AlignItems::Center), + justify_content: Some(JustifyContent::Center), + ..Default::default() + }) + .children(vec![icon.view::()]); + } + } + // 2/3) Glifo corto renderable, o marcador genérico. + let glifo = hint + .filter(|s| s.chars().count() <= 2 && !s.chars().any(|c| c.is_ascii_alphanumeric())) + .unwrap_or("▸"); + View::new(Style { + size: Size { width: percent(1.0_f32), height: percent(1.0_f32) }, + align_items: Some(AlignItems::Center), + justify_content: Some(JustifyContent::Center), + ..Default::default() + }) + .text_aligned(glifo.to_string(), 14.0, color, Alignment::Center) +} + +/// Etiqueta corta del **origen** del candidato, para que el ojo discrimine de +/// un vistazo qué es cada fila. Los tokens del PATH van a ras (sin etiqueta: +/// son el caso base). +fn rotulo_origen(kind: Option) -> Option<&'static str> { + match kind { + Some(SugKind::App) => Some("app"), + Some(SugKind::Line) => Some("historial"), + Some(SugKind::Group) => Some("grupo"), + None => None, + } +} + +/// Una fila del panel. `sel` la resalta; `navegado` distingue una elección +/// DELIBERADA (el usuario recorrió el popup) de un mero default preseleccionado. +/// El clic la acepta por su índice global. +fn fila( + display: &str, + kind: Option, + icon_hint: Option<&str>, + global_idx: usize, + sel: bool, + navegado: bool, + theme: &Theme, + lift: &(impl Fn(Msg) -> HostMsg + Clone + Send + Sync + 'static), +) -> View { + // Dos grados de realce, para que "marcado" signifique lo que Enter hará: + // · elegido (sel + navegado): acento fuerte — Enter lo APLICA. + // · default (sel sin navegar): realce SUAVE — es sólo la sugerencia de + // arranque (Tab/→ la completan); Enter aún ejecuta lo tipeado. Sin este + // matiz el default se veía idéntico a una elección y "dar Enter borraba + // todo" en vez de aceptar. + let (bg, fg) = if sel && navegado { + (theme.accent, theme.bg_panel) + } else if sel { + (theme.bg_row_hover, theme.fg_text) + } else { + // Los tiers "altos" (app/línea/grupo) se leen como otra capa: tenue. + let fg = match kind { + Some(_) => theme.fg_muted, + None => theme.fg_text, + }; + (theme.bg_input, fg) + }; + + let mut hijos: Vec> = Vec::new(); + // Badge de ícono: sólo para apps (los demás tiers ya traen su marcador en el + // texto — ↪ línea, ⊞/↻ grupo — y los tokens van a ras). + if matches!(kind, Some(SugKind::App)) { + hijos.push( + View::new(Style { + size: Size { width: length(22.0_f32), height: length(22.0_f32) }, + flex_shrink: 0.0, + align_items: Some(AlignItems::Center), + justify_content: Some(JustifyContent::Center), + ..Default::default() + }) + .children(vec![icono_app(icon_hint, fg)]), + ); + } + hijos.push( + View::new(Style { + size: Size { width: percent(1.0_f32), height: percent(1.0_f32) }, + flex_grow: 1.0, + align_items: Some(AlignItems::Center), + ..Default::default() + }) + .text_aligned(display.to_string(), 13.0, fg, Alignment::Start), + ); + // Etiqueta de origen a la derecha (app/historial/grupo) — la discriminación + // pedida: que se distinga una `.desktop` de una línea del historial. + if let Some(rot) = rotulo_origen(kind) { + hijos.push( + View::new(Style { + flex_shrink: 0.0, + align_items: Some(AlignItems::Center), + ..Default::default() + }) + .text_aligned(rot.to_string(), 10.0, fg, Alignment::End), + ); + } + + View::new(Style { + flex_direction: FlexDirection::Row, + size: Size { width: percent(1.0_f32), height: length(ROW_H) }, + padding: TaffyRect { + left: length(10.0_f32), + right: length(10.0_f32), + top: length(0.0_f32), + bottom: length(0.0_f32), + }, + gap: Size { width: length(8.0_f32), height: length(0.0_f32) }, + align_items: Some(AlignItems::Center), + ..Default::default() + }) + .fill(bg) + .radius(radius::SM) + .hover_fill(if sel && navegado { theme.accent } else { theme.bg_row_hover }) + .on_click(lift(Msg::PickCompletion(global_idx))) + .children(hijos) +} + +/// El panel de candidatos (columna elevada con sombra + borde + animación de +/// aparición). `anim` (0..1) modula el fade. Devuelve `None` si no hay popup. +pub fn completion_panel( + state: &State, + theme: &Theme, + width: f32, + anim: f32, + lift: impl Fn(Msg) -> HostMsg + Clone + Send + Sync + 'static, +) -> Option> { + let comp = state.completion.as_ref()?; + let rows = completion_rows(state); + if rows.is_empty() { + return None; + } + let n = rows.len(); + let sel = state.completion_index.min(n - 1); + // Realce fuerte + pie "Enter aplica" cuando Enter aceptará el resaltado: + // navegado o app-default que aún se completa. "Marcado ⟺ Enter lo aplica". + let navegado = enter_acepta_completion(state); + // Ventana deslizante centrada en la selección. + let start = sel.saturating_sub(MAX_ROWS / 2).min(n.saturating_sub(MAX_ROWS)); + let end = (start + MAX_ROWS).min(n); + + let mut hijos: Vec> = Vec::with_capacity(end - start + 1); + for (global_idx, row) in rows.iter().enumerate().take(end).skip(start) { + let (display, kind, icon): (String, Option, Option) = match *row { + CompRow::Token(i) => ( + comp.candidates.get(i).cloned().unwrap_or_default(), + None, + None, + ), + CompRow::Extra(i) => match state.completion_extra.get(i) { + Some(sug) => (sug.display.clone(), Some(sug.kind), sug.icon.clone()), + None => continue, + }, + }; + hijos.push(fila( + &display, + kind, + icon.as_deref(), + global_idx, + global_idx == sel, + navegado, + theme, + &lift, + )); + } + + // Pie con conteo + atajos cuando hay más de lo que entra. + if n > MAX_ROWS { + hijos.push( + View::new(Style { + size: Size { width: percent(1.0_f32), height: length(20.0_f32) }, + padding: TaffyRect { + left: length(10.0_f32), + right: length(10.0_f32), + top: length(0.0_f32), + bottom: length(0.0_f32), + }, + align_items: Some(AlignItems::Center), + ..Default::default() + }) + .text_aligned( + // El pie refleja qué hará Enter según el estado: elegido = + // aplica; default = ejecuta lo tipeado (Tab/→ completan). + if navegado { + format!("{}/{} · ↑↓ navega · Enter aplica · Esc", sel + 1, n) + } else { + format!("{}/{} · Tab/→ completa · Enter ejecuta · Esc", sel + 1, n) + }, + 10.0, + theme.fg_muted, + Alignment::Start, + ), + ); + } + + let (a, blur, dy) = elevation::E4; + // Fade de aparición (smoothstep) sobre el alfa del sombreado del panel. + let e = anim * anim * (3.0 - 2.0 * anim); + let panel = View::new(Style { + flex_direction: FlexDirection::Column, + size: Size { width: length(width), height: auto() }, + padding: TaffyRect { + left: length(4.0_f32), + right: length(4.0_f32), + top: length(4.0_f32), + bottom: length(4.0_f32), + }, + gap: Size { width: length(0.0_f32), height: length(2.0_f32) }, + ..Default::default() + }) + .fill(theme.bg_panel) + .radius(radius::LG) + .border(1.0, theme.border) + .shadow(Shadow { + color: Color::from_rgba8(0, 0, 0, (a as f32 * e) as u8), + blur, + dx: 0.0, + dy, + spread: 0.0, + }) + .alpha(e.clamp(0.0, 1.0)) + .children(hijos); + Some(panel) +} diff --git a/02_ruway/shuma/sandbox/shuma-module-shell/src/view/gpu_grid_tests.rs b/02_ruway/shuma/sandbox/shuma-module-shell/src/view/gpu_grid_tests.rs new file mode 100644 index 0000000..290986e --- /dev/null +++ b/02_ruway/shuma/sandbox/shuma-module-shell/src/view/gpu_grid_tests.rs @@ -0,0 +1,114 @@ +use super::*; + +fn snap_of(cells: &[&[(char, vt100::Color, vt100::Color)]]) -> TuiSnapshot { + let rows = cells.len() as u16; + let cols = cells.first().map(|r| r.len()).unwrap_or(0) as u16; + let mut grid: Vec> = Vec::with_capacity(rows as usize); + for row in cells { + grid.push( + row.iter() + .map(|(ch, fg, bg)| TuiCell { + ch: ch.to_string(), + fg: *fg, + bg: *bg, + }) + .collect(), + ); + } + TuiSnapshot { + cells: grid, + rows, + cols, + cursor_r: 0, + cursor_c: 0, + hide_cursor: true, + images: Vec::new(), + } +} + +fn atlas() -> llimphi_widget_terminal::GlyphAtlas { + llimphi_widget_terminal::GlyphAtlas::new( + llimphi_ui::llimphi_text::MONO_FONT_BYTES, + 14.0, + 16, + 4, + ) + .expect("atlas") +} + +fn rect_400_200() -> llimphi_ui::PaintRect { + llimphi_ui::PaintRect { + x: 0.0, + y: 0.0, + w: 400.0, + h: 200.0, + } +} + +#[test] +fn build_skip_blanks_con_bg_default() { + let snap = snap_of(&[&[ + (' ', vt100::Color::Default, vt100::Color::Default), + (' ', vt100::Color::Default, vt100::Color::Default), + ]]); + let mut a = atlas(); + let theme = llimphi_theme::Theme::dark(); + let cells = build_cell_instances(&snap, &mut a, theme, rect_400_200()); + assert!(cells.is_empty(), "celdas vacías con bg default no van"); +} + +#[test] +fn build_emite_un_instance_por_celda_con_contenido() { + let snap = snap_of(&[ + &[ + ('h', vt100::Color::Default, vt100::Color::Default), + ('i', vt100::Color::Default, vt100::Color::Default), + ], + &[ + (' ', vt100::Color::Default, vt100::Color::Default), + ('!', vt100::Color::Default, vt100::Color::Default), + ], + ]); + let mut a = atlas(); + let theme = llimphi_theme::Theme::dark(); + let cells = build_cell_instances(&snap, &mut a, theme, rect_400_200()); + // Tres chars no-blank (h, i, !), el ' ' con bg default se salta. + assert_eq!(cells.len(), 3); + // El primer instance debe arrancar en (pad, pad). + assert_eq!(cells[0].cell_x, 6.0); + assert_eq!(cells[0].cell_y, 6.0); +} + +#[test] +fn build_no_skip_si_bg_explicito() { + // Una celda con ' ' pero bg explícito (Idx) SÍ se emite (el bg + // tiene que pintarse aunque el char sea blank). + let snap = snap_of(&[&[ + (' ', vt100::Color::Default, vt100::Color::Idx(1)), + (' ', vt100::Color::Default, vt100::Color::Default), + ]]); + let mut a = atlas(); + let theme = llimphi_theme::Theme::dark(); + let cells = build_cell_instances(&snap, &mut a, theme, rect_400_200()); + // Sólo el primero (bg explícito); el segundo (bg default) se salta. + assert_eq!(cells.len(), 1); +} + +#[test] +fn build_uv_y_color_son_consistentes() { + let snap = snap_of(&[&[('A', vt100::Color::Default, vt100::Color::Default)]]); + let mut a = atlas(); + let theme = llimphi_theme::Theme::dark(); + let cells = build_cell_instances(&snap, &mut a, theme, rect_400_200()); + assert_eq!(cells.len(), 1); + let (acw, ach) = a.cell_size(); + // UV apunta al slot 0 (primer glifo rasterizado). + assert_eq!(cells[0].uv_x, 0.0); + assert_eq!(cells[0].uv_y, 0.0); + assert_eq!(cells[0].uv_w, acw as f32); + assert_eq!(cells[0].uv_h, ach as f32); + // fg y bg no son 0 (fg = theme.fg_text, bg = default → alpha 0 + // pero los componentes no se chequean — basta con que el instance + // se haya armado sin pánico). + assert_ne!(cells[0].fg_rgba, 0); +} diff --git a/02_ruway/shuma/sandbox/shuma-module-shell/src/view/history_panel.rs b/02_ruway/shuma/sandbox/shuma-module-shell/src/view/history_panel.rs new file mode 100644 index 0000000..b35f3d5 --- /dev/null +++ b/02_ruway/shuma/sandbox/shuma-module-shell/src/view/history_panel.rs @@ -0,0 +1,164 @@ +use super::*; + +/// Overlay de búsqueda Ctrl-R. Vive como hijo extra del root cuando +/// `state.history_search` está activo; un input + lista de matches. +pub(crate) fn history_search_panel( + state: &State, + theme: &Theme, +) -> View { + let search = state + .history_search + .as_ref() + .expect("panel sólo se construye con search activo"); + let matches: Vec = { + let history = state.history.lock().unwrap(); + history + .fuzzy_search(&search.query, 50) + .into_iter() + .map(|e| e.line.clone()) + .collect() + }; + let label = format!("Ctrl-R › {}", search.query); + let mut children: Vec> = vec![View::new(Style { + size: Size { + width: percent(1.0_f32), + height: length(20.0_f32), + }, + ..Default::default() + }) + .text_aligned(label, 12.0, theme.accent, Alignment::Start)]; + + for (i, m) in matches.iter().enumerate().take(8) { + let color = if i == search.selected { + theme.accent + } else { + theme.fg_text + }; + let bg = if i == search.selected { + theme.bg_selected + } else { + theme.bg_panel + }; + children.push( + View::new(Style { + size: Size { + width: percent(1.0_f32), + height: length(18.0_f32), + }, + ..Default::default() + }) + .fill(bg) + .text_aligned(m.clone(), 12.0, color, Alignment::Start), + ); + } + + View::new(Style { + flex_direction: FlexDirection::Column, + size: Size { + width: percent(1.0_f32), + height: Dimension::auto(), + }, + padding: Rect { + left: length(10.0_f32), + right: length(10.0_f32), + top: length(8.0_f32), + bottom: length(8.0_f32), + }, + gap: Size { + width: length(0.0_f32), + height: length(2.0_f32), + }, + ..Default::default() + }) + .fill(theme.bg_panel) + .radius(3.0) + .children(children) +} + +/// Panel de grupos `[RUN]` a la izquierda: una card por grupo guardado +/// (`:save`), clickable para ejecutarlo, con su tecla F. Ancho fijo. El +/// caller ya garantizó que hay ≥1 grupo. +pub(crate) fn groups_panel( + state: &State, + theme: &Theme, + lift: &(impl Fn(Msg) -> HostMsg + Clone + Send + Sync + 'static), +) -> View { + const PANEL_W: f32 = 176.0; + let mut children: Vec> = vec![View::new(Style { + size: Size { + width: percent(1.0_f32), + height: length(18.0_f32), + }, + ..Default::default() + }) + .text_aligned("GRUPOS".to_string(), 10.0, theme.fg_muted, Alignment::Start)]; + + for (i, g) in state.groups.iter().enumerate() { + let title = format!("F{} {}", i + 1, g.name); + let sub = format!("{} cmds", g.lines.len()); + let card = View::new(Style { + flex_direction: FlexDirection::Column, + size: Size { + width: percent(1.0_f32), + height: length(38.0_f32), + }, + padding: Rect { + left: length(6.0_f32), + right: length(6.0_f32), + top: length(3.0_f32), + bottom: length(3.0_f32), + }, + gap: Size { + width: length(0.0_f32), + height: length(1.0_f32), + }, + ..Default::default() + }) + .fill(theme.bg_input) + .radius(4.0) + .hover_fill(theme.bg_row_hover) + .on_click(lift(Msg::RunGroup(i))) + .children(vec![ + View::new(Style { + size: Size { + width: percent(1.0_f32), + height: length(16.0_f32), + }, + ..Default::default() + }) + .text_aligned(title, 12.0, theme.accent, Alignment::Start), + View::new(Style { + size: Size { + width: percent(1.0_f32), + height: length(14.0_f32), + }, + ..Default::default() + }) + .text_aligned(sub, 10.0, theme.fg_muted, Alignment::Start), + ]); + children.push(card); + } + + View::new(Style { + flex_direction: FlexDirection::Column, + size: Size { + width: length(PANEL_W), + height: percent(1.0_f32), + }, + flex_shrink: 0.0, + padding: Rect { + left: length(6.0_f32), + right: length(6.0_f32), + top: length(6.0_f32), + bottom: length(6.0_f32), + }, + gap: Size { + width: length(0.0_f32), + height: length(4.0_f32), + }, + ..Default::default() + }) + .fill(theme.bg_panel) + .radius(3.0) + .children(children) +} diff --git a/02_ruway/shuma/sandbox/shuma-module-shell/src/view/input.rs b/02_ruway/shuma/sandbox/shuma-module-shell/src/view/input.rs new file mode 100644 index 0000000..7c9fc05 --- /dev/null +++ b/02_ruway/shuma/sandbox/shuma-module-shell/src/view/input.rs @@ -0,0 +1,454 @@ +use super::*; + +use llimphi_widget_text_input::{ + text_area_view_rico, Adornos, CaretEstilo, Metricas, Placeholder, TextInputPalette, +}; + +/// Cap de líneas VISIBLES del input multilínea: más allá, el contenido +/// scrollea adentro anclado a la línea del cursor (el motor compartido mantiene +/// el caret dentro del viewport). +pub(crate) const MAX_INPUT_LINES: usize = 12; + +/// Alto de una fila del input en px (a zoom 1). El **host** lo necesita para +/// darle a su superficie el alto que el input va a pedir: en un layer-shell la +/// vista no puede desbordar la surface, así que crecer es una decisión del +/// host, no del widget. +pub const ALTO_FILA_INPUT: f32 = 18.0; + +/// La [`Metricas`] del input: **monoespaciada** y escalada por el zoom. +/// +/// Mono no es una preferencia estética: el input del shell alinea comandos, +/// rutas y salidas contra el texto de la conversación, que es mono. Y la +/// métrica tiene que ser *una sola* — la usan el pintado, el ajuste blando y el +/// mapeo click→carácter; si difieren, el caret cae entre glifos que no son los +/// que se ven. +pub(crate) fn metricas_input(state: &State) -> Metricas { + Metricas { + // El aire lateral que tenía el pintor propio (10 px), conservado para + // que la caja no cambie de forma con la migración. + pad_x: 10.0, + pad_r: 10.0, + pad_y: 8.0, + ..Metricas::mono(13.0).con_zoom(state.font_zoom) + } +} + +/// Cuántas **filas visuales** ocupa hoy el input: el ajuste blando del motor +/// compartido, más el anticipo y el trinquete que hacen que la caja no salte, +/// capeado a [`MAX_INPUT_LINES`]. +/// +/// El **anticipo** abre la fila antes de que la palabra salte (si no, el +/// crecimiento cae encima del reflujo y se ve un tirón). El **trinquete** +/// ([`State::input_filas_pico`]) evita que borrar encoja la caja: sólo vuelve a +/// una fila cuando el input queda vacío. +pub fn input_filas_visuales(state: &State) -> usize { + let ed = state.input.ed(); + let texto = state.input.text(); + let filas = ed.renglones_visuales(); + // Anticipo: si a la última fila le queda poco, ya contamos la siguiente. Se + // mide preguntándole al motor cuánto ocuparía el texto con unos caracteres + // de más — así el anticipo usa el MISMO ajuste blando que el pintado, en vez + // de una cuenta de columnas paralela que se desfasa con la fuente. + const ANTICIPO: &str = " "; + let crudo = if texto.is_empty() { + 1 + } else { + filas.max(ed.renglones_visuales_de(&format!("{texto}{ANTICIPO}")).min(filas + 1)) + }; + let crudo = crudo.clamp(1, MAX_INPUT_LINES); + let Ok(mut pico) = state.input_filas_pico.lock() else { + return crudo; + }; + if texto.is_empty() { + *pico = 0; // vacío: la caja vuelve a su tamaño + return 1; + } + *pico = (*pico).max(crudo); + *pico +} + +/// Constante de tiempo (ms) de la persecución del alto: a mayor, más lento. +const ALTO_TAU_MS: f32 = 90.0; + +/// Alto **animado** de la caja del input en px — persigue al alto que piden las +/// filas con una exponencial en vez de saltar. Lo consultan el propio input +/// (para su contenedor) y el host (para su franja), así que va por el mismo +/// canal y no se pueden desfasar. +pub fn input_alto_px(state: &State) -> f32 { + let m = metricas_input(state); + let objetivo = 2.0 * m.pad_y + m.line_h * input_filas_visuales(state) as f32; + let ahora = crate::history_helpers::now_unix_millis(); + let Ok(mut anim) = state.input_alto_anim.lock() else { + return objetivo; + }; + let (actual, desde) = *anim; + if actual <= 0.0 || desde == 0 { + *anim = (objetivo, ahora); // primer cuadro: sin animación de entrada + return objetivo; + } + let dt = ahora.saturating_sub(desde) as f32; + if dt <= 0.0 { + return actual; // mismo milisegundo (el input y el host preguntan juntos) + } + let k = 1.0 - (-dt / ALTO_TAU_MS).exp(); + let nuevo = actual + (objetivo - actual) * k; + // Cerca del objetivo, clavarlo: si no, queda un temblor de sub-píxel eterno. + let nuevo = if (objetivo - nuevo).abs() < 0.5 { objetivo } else { nuevo }; + *anim = (nuevo, ahora); + nuevo +} + +/// Cuánto creció la caja por encima de una sola fila, en px (ya animado). Es lo +/// que el host suma a su franja. +pub fn input_alto_extra_px(state: &State) -> f32 { + let m = metricas_input(state); + (input_alto_px(state) - (2.0 * m.pad_y + m.line_h)).max(0.0) +} + +/// Hasta dónde llega el texto por la derecha, en caracteres, y cuántos entran +/// en una fila. Es lo que un overlay pegado a la derecha del input necesita +/// para saber si el texto lo está por alcanzar. Lo resuelve el motor con el +/// mismo ajuste blando del pintado. +pub fn input_avance_en_fila(state: &State) -> (usize, usize) { + state.input.ed().avance_en_renglon() +} + +/// Ancho de un carácter del input en px, con la métrica viva. Lo usa el host +/// para traducir anchos suyos a caracteres. +pub fn input_char_w_px(state: &State) -> f32 { + state.input.ed().ancho_caracter() +} + +/// Color por `TokenKind` — paleta diseñada para que el comando salte y +/// los flags/strings tengan su propio tono. +pub(crate) fn token_color( + kind: TokenKind, + theme: &Theme, +) -> llimphi_ui::llimphi_raster::peniko::Color { + use llimphi_ui::llimphi_raster::peniko::Color; + match kind { + TokenKind::Command => theme.accent, + TokenKind::Argument => theme.fg_text, + TokenKind::Flag => Color::from_rgba8(220, 200, 120, 255), // amarillo + TokenKind::StringLit => Color::from_rgba8(160, 210, 140, 255), // verde + TokenKind::Variable => Color::from_rgba8(200, 160, 220, 255), // violeta + TokenKind::Pipe | TokenKind::Redirect | TokenKind::Operator => theme.accent, + TokenKind::Comment | TokenKind::Whitespace => theme.fg_muted, + TokenKind::Unknown => theme.fg_destructive, + } +} + +/// Los tokens del shell como **tramos de color** (rangos de byte) para el +/// widget. Se tokeniza el texto ENTERO —no fila por fila— porque el ajuste +/// blando es del widget: partir en filas acá para colorear volvería a meter una +/// segunda noción de "dónde corta la línea", que es exactamente la duplicación +/// que esta migración vino a sacar. +pub(crate) fn tramos_de_tokens( + state: &State, + theme: &Theme, +) -> Vec<(usize, usize, llimphi_ui::llimphi_raster::peniko::Color)> { + let texto = state.input.text(); + shuma_line::tokenize(&texto, state_dialect_default()) + .into_iter() + .filter(|t| t.kind != TokenKind::Whitespace) + .map(|t| (t.start, t.end, token_color(t.kind, theme))) + .collect() +} + +/// El placeholder del input vacío: la **marquesina** (si el host fijó un aviso +/// no silencioso) o el hint clásico, con su color por urgencia. +/// +/// Con un programa PTY inline vivo (claude, watch…) no hay placeholder: el +/// input ES el prompt de ESE programa, y un «tipea un comando…» ahí miente. +fn placeholder_rico(state: &State, theme: &Theme) -> Option { + use llimphi_ui::llimphi_raster::peniko::Color; + if !state.input.is_empty() || programa_pty_inline(state).is_some() { + return None; + } + if current_ghost(state).is_some() { + return None; + } + let alerta = Color::from_rgba8(0xE0, 0xB2, 0x4A, 255); + let m = state + .marquesina + .as_ref() + .filter(|m| m.urgencia != shuma_module::Urgencia::Silencio) + .cloned() + .unwrap_or_else(|| shuma_module::Marquesina::calma("tipea un comando…")); + let color = match m.urgencia { + shuma_module::Urgencia::Calma => theme.fg_placeholder, + shuma_module::Urgencia::Leve => theme.fg_muted, + shuma_module::Urgencia::Urgente => { + if state.marquesina_fase % 2 == 0 { + alerta + } else { + alerta.with_alpha(0.4) + } + } + shuma_module::Urgencia::Silencio => theme.fg_placeholder, + }; + Some(Placeholder { + // El fundido lo resuelve el propio widget con el alpha que le pasamos: + // el host sólo estampa entro/sale y acá se recomputa cada cuadro, así + // el fade es suave aunque el aviso se empuje a 1 Hz. + alpha: m.alpha(now_unix_millis()), + italic: m.urgencia == shuma_module::Urgencia::Calma, + icono: m.icono, + icono_color: m + .icono_rgb + .map(|[r, g, b]| Color::from_rgba8(r, g, b, 255)), + texto: m.texto.clone(), + color, + }) +} + +/// Paleta del input para el widget compartido. El **fondo va transparente**: el +/// input es hijo del cuerpo de la barra, que ya pinta su color y deja pasar el +/// frost del compositor; cualquier relleno propio se SUMA sobre ese fondo, sube +/// la alfa combinada y tapa el cristal («no glasea»). +fn paleta_input(state: &State, theme: &Theme) -> TextInputPalette { + let mut p = TextInputPalette::from_theme(theme); + p.bg = theme.bg_panel_alt.with_alpha(0.0); + p.bg_focus = p.bg; + p.caret = theme.accent; + p.selection = theme.bg_selected; + p.fg_text = theme.fg_text; + p.relieve = false; + let _ = state; + p +} + +/// Renderiza la línea de entrada sobre el **editor compartido** de Llimphi: +/// tokens coloreados, sugerencia fantasma, placeholder con ícono y caret con +/// estela, encima del motor que trae selección por palabra, undo, IME y +/// doble/triple click. +pub(crate) fn shell_input_view( + state: &State, + theme: &Theme, + lift: impl Fn(Msg) -> HostMsg + Clone + Send + Sync + 'static, +) -> View { + let metricas = metricas_input(state); + // La métrica se publica ANTES de pintar: el hit-testing del `update` corre + // sin vista a mano y mediría con la default (sans, 13 px) si no. + state.input.set_metricas(&metricas); + + let filas = input_filas_visuales(state); + // CRECIDO la caja se para encima de la conversación, y sobre texto el + // cristal no se lee: ahí sí toma fondo opaco. + let bg = if filas > 1 { + theme.bg_panel_alt.with_alpha(1.0) + } else { + theme.bg_panel_alt.with_alpha(0.0) + }; + // MODO CONSOLA (PTY inline vivo): el input es el prompt del programa — el + // borde va SIEMPRE en acento, inconfundible. + let consola = programa_pty_inline(state).is_some(); + let border = if consola { + theme.accent + } else if state.focused { + theme.border_focus + } else { + theme.border + }; + + let adornos = Adornos { + tramos: tramos_de_tokens(state, theme), + ghost: current_ghost(state), + placeholder: placeholder_rico(state, theme), + caret: CaretEstilo::vivo(), + ahora_ms: now_unix_millis(), + // El marco lo pone la caja de abajo: el borde del widget es un relleno + // opaco de lado a lado que taparía el frost de la barra. + marco: false, + }; + + let lift_area = lift.clone(); + let inner = text_area_view_rico( + state.input.ed(), + state.focused, + filas, + &paleta_input(state, theme), + &metricas, + &adornos, + move |ev| lift_area(Msg::InputArea(ev)), + ); + + // La caja del input (marco + fondo). Crece (flex_grow) para dejar sitio al + // botón de micrófono a la derecha — el «llamado shuma» también vive acá. + let input_box = View::new(Style { + size: Size { + width: Dimension::auto(), + height: length(input_alto_px(state)), + }, + flex_grow: 1.0, + // El input nunca se reduce: cuando la ventana se achica, el body (con + // flex_grow=1) se shrinkea hasta `min_size.height=0` y el input mantiene + // su alto. Sin esto, taffy podía shrink el input a 0 y se "perdía". + flex_shrink: 0.0, + ..Default::default() + }) + .fill(bg) + // Borde REAL (no un relleno del color del borde con padding): el relleno + // opaco bloqueaba el backdrop frosted del compositor y el glass no llegaba + // al input. + .border(1.2, border) + .radius(4.0) + // `hover_fill` TRANSPARENTE: sólo existe para que el hit-test de hover elija + // este nodo y dispare `on_pointer_enter` (Llimphi sólo hoverea nodos con + // hover_fill). Con alpha 0 no pinta nada pero sigue siendo hit-testeable. + .hover_fill(theme.bg_panel_alt.with_alpha(0.0)) + // Pasar el mouse sobre la línea la re-foca: el Enter vuelve a arrancar + // comandos (deja de alimentar el stdin de un job). + .on_pointer_enter(lift(Msg::FocusInput)) + // Sacar el mouse la DESENFOCA: sin esto el foco visual (caret + marco + // brillante) se quedaba pegado tras un simple hover. + .on_pointer_leave(lift(Msg::BlurInput)) + .children(vec![inner]); + + // Botón de la derecha: **micrófono** con el input vacío, **enviar** + // (avioncito) en cuanto hay texto — mismo tamaño para que el swap no salte + // el layout. + let texto_actual = state.input.text(); + let hay_texto = !texto_actual.trim().is_empty(); + let boton_derecho = if hay_texto { + shuma_voz_ui::boton_enviar(26.0, theme, lift(Msg::Submit)) + } else { + shuma_voz_ui::boton_mic( + state.escucha, + false, + state.voz_reloj_ms, + 26.0, + theme, + lift(Msg::ToggleMic), + ) + }; + + // Preview de intención SIN prefijo: si lo tecleado clasifica como lenguaje + // natural, un chip sutil avisa que Enter va a la IA. Refleja EXACTO lo que + // hará `run_submitted` (misma función `clasificar`). + let mut fila: Vec> = vec![input_box]; + if !consola + && hay_texto + && crate::intent::clasificar(&texto_actual, |w| state.completion_source.es_comando(w)) + == crate::intent::Intencion::Preguntar + { + fila.push(intent_chip_ia::(theme)); + } + fila.push(boton_derecho); + + View::new(Style { + flex_direction: FlexDirection::Row, + size: Size { + width: percent(1.0_f32), + height: Dimension::auto(), + }, + flex_shrink: 0.0, + align_items: Some(AlignItems::Center), + gap: Size { + width: length(6.0_f32), + height: length(0.0_f32), + }, + ..Default::default() + }) + .children(fila) +} + +/// Programa del PTY inline vivo (claude, watch…) si el input está en modo +/// consola: PTY activo, canvas a la vista y SIN alt-screen (vim maneja su +/// propio input). `None` = input normal del shell. +fn programa_pty_inline(state: &State) -> Option { + if !state.canvas_visible { + return None; + } + let arc = state.running.as_ref()?; + let g = arc.try_lock().ok()?; + let tui = g.tui.as_ref()?; + if tui.parser.screen().alternate_screen() { + return None; + } + // Nombre corto (basename) del programa. + Some( + tui.program + .rsplit('/') + .next() + .unwrap_or(&tui.program) + .to_string(), + ) +} + +/// El chip "✦ IA ↵" del preview de intención: aparece a la derecha del input +/// cuando lo tecleado ruteará a la IA (no al shell). No es interactivo — es un +/// aviso; el ruteo real lo hace [`crate::intent`] en el submit. +fn intent_chip_ia(theme: &Theme) -> View { + View::new(Style { + flex_shrink: 0.0, + align_items: Some(AlignItems::Center), + padding: Rect { + left: length(8.0_f32), + right: length(8.0_f32), + top: length(3.0_f32), + bottom: length(3.0_f32), + }, + ..Default::default() + }) + .fill(theme.bg_input) + .radius(5.0) + .text("✦ IA ↵".to_string(), 10.0, theme.accent) +} + +/// Dialect por defecto para el coloreo — el `InputShuma` lo guarda pero +/// mientras todos los usos sean bash alcanza con este getter. +pub(crate) fn state_dialect_default() -> shuma_line::Dialect { + shuma_line::Dialect::default() +} + +pub(crate) fn shell_header( + state: &State, + theme: &Theme, +) -> View { + let status = if let Some(arc) = state.running.as_ref() { + // try_lock: si el lector del PTY está dentro del mutex (drenando una + // ráfaga grande de output), no bloqueamos el render — el header pinta + // un placeholder vivo (`· ⟳ …`) y el comando real reaparece en el + // siguiente frame. Antes el lock duro pasmaba la pantalla en negro + // mientras el PTY drenaba. + let cmd = match arc.try_lock() { + Ok(g) => g.command.clone(), + Err(_) => "…".to_string(), + }; + let queued = state.queue.len(); + if queued > 0 { + format!(" · ⟳ {cmd} (+{queued} en cola)") + } else { + format!(" · ⟳ {cmd}") + } + } else { + String::new() + }; + // Rama git del cwd, si estamos en un repo (`· (main)`). La fuente del + // shell no trae el glifo ⎇, así que usamos la convención de paréntesis. + let branch = match git_branch(&state.cwd) { + Some(b) => format!(" · ({b})"), + None => String::new(), + }; + let label = format!( + "Shell · {} · cwd: {}{}{}", + state.source.label(), + pretty_path(&state.cwd), + branch, + status, + ); + let color = if state.is_running() { + theme.accent + } else { + theme.fg_text + }; + View::new(Style { + size: Size { + width: percent(1.0_f32), + height: length(24.0_f32), + }, + ..Default::default() + }) + .text_aligned(label, 12.0, color, Alignment::Start) +} diff --git a/02_ruway/shuma/sandbox/shuma-module-shell/src/view/mod.rs b/02_ruway/shuma/sandbox/shuma-module-shell/src/view/mod.rs new file mode 100644 index 0000000..e291c94 --- /dev/null +++ b/02_ruway/shuma/sandbox/shuma-module-shell/src/view/mod.rs @@ -0,0 +1,551 @@ +use llimphi_ui::llimphi_layout::taffy::style::Position; +use super::*; + +mod completion; +mod input; +mod tui; +mod ansi; +mod history_panel; +mod surface_view; +pub(crate) mod command_card; +mod output_line; +#[cfg(test)] +mod gpu_grid_tests; + +pub use completion::completion_panel; +pub(crate) use input::*; +pub use input::{ + input_alto_extra_px, input_alto_px, input_avance_en_fila, input_char_w_px, input_filas_visuales, + ALTO_FILA_INPUT, +}; +pub(crate) use tui::*; +pub(crate) use ansi::*; +pub(crate) use history_panel::*; +pub(crate) use surface_view::*; +pub(crate) use command_card::*; +pub(crate) use output_line::*; + +/// Vista pública del **input vivo** del shell, aislado del resto del shell. Lo +/// usan los frontends que quieren hospedar la línea de entrada en su propio +/// chasis (p. ej. la barra de pata: el cabezal de la barra ES este input, no un +/// placeholder). Comparte estado con [`body_view`] — los dos pintan distintas +/// partes del mismo `State` y se enrutan los `Msg` por el mismo `lift`. +pub fn input_view( + state: &State, + theme: &Theme, + lift: impl Fn(Msg) -> HostMsg + Clone + Send + Sync + 'static, +) -> View { + shell_input_view(state, theme, lift) +} + +/// Vista pública del **cuerpo** del shell sin el input: header + panel +/// principal (cards/PTY/TUI) + popups internos (completado, búsqueda de +/// historial, menú contextual). La usa pata para el drawer mientras el input +/// real vive en la barra. +pub fn body_view( + state: &State, + theme: &Theme, + lift: impl Fn(Msg) -> HostMsg + Clone + Send + Sync + 'static, + // `true` = el input está ARRIBA (barra top): el popup de completado baja + // DESDE el input → va al tope del cuerpo, pegado a la barra, y cascadea hacia + // abajo. `false` = input abajo: el popup sube hacia él (al pie, histórico). + popup_al_tope: bool, +) -> View { + let header = shell_header(state, theme); + // OJO estructura estable: la RAMA se decide con `tui_skin_vivo` (espejo + // sin lock), no con `is_tui_active()` — el try_lock contendido bajo + // streaming hacía saltar la vista entre consola/grid/surface cada frame. + let tui_vivo = state.tui_skin_vivo.is_some(); + let main_panel: View = if state.tui_altscreen_vivo { + tui_panel::(state, theme, lift.clone()) + } else if tui_vivo { + // PTY inline: el grid vivo RECORTADO (todo el TUI menos el input box, + // que vive en la barra de shuma). Skin claude → vista consola: el + // renderer nuevo de claude (2.1.x) nunca repinta la historia (sólo la + // cola), así que el grid solo ya no alcanza — la historia cosechada + // (desplanizada en secciones) fluye arriba y la cola viva abajo. + consola_o_inline::(state, theme, &lift) + } else { + output_pane_surface::(state, theme, &lift) + }; + let body: View = if !state.groups.is_empty() && !tui_vivo { + View::new(Style { + flex_direction: FlexDirection::Row, + size: Size { + width: percent(1.0_f32), + height: Dimension::auto(), + }, + flex_basis: length(0.0_f32), + flex_grow: 1.0, + min_size: Size { + width: Dimension::auto(), + height: length(0.0_f32), + }, + gap: Size { + width: length(8.0_f32), + height: length(0.0_f32), + }, + align_items: Some(AlignItems::Stretch), + ..Default::default() + }) + .children(vec![groups_panel::(state, theme, &lift), main_panel]) + } else { + main_panel + }; + + let mut children = vec![header, body]; + if state.history_search.is_some() { + children.push(history_search_panel::(state, theme)); + } + // Panel de completado (Tab / as-you-type): el input vive en la barra del + // host (pata) y alimenta `state.completion`; el panel se pinta aquí, en el + // cuerpo adyacente — el MISMO renderer bonito de la surface flotante + // (apps con ícono XDG, etiquetas de origen, sombra), no un popup aparte. + // La dirección la fija el host según dónde vive el input (barra arriba/ + // abajo): al tope del cuerpo (cascadea hacia abajo) o al pie. + if let Some(popup) = completion_panel(state, theme, 480.0, 1.0, lift.clone()) { + if popup_al_tope { + children.insert(0, popup); + } else { + children.push(popup); + } + } + + View::new(Style { + flex_direction: FlexDirection::Column, + size: Size { + width: percent(1.0_f32), + height: percent(1.0_f32), + }, + padding: Rect { + left: length(12.0_f32), + right: length(12.0_f32), + top: length(10.0_f32), + bottom: length(10.0_f32), + }, + gap: Size { + width: length(0.0_f32), + height: length(8.0_f32), + }, + ..Default::default() + }) + .fill(theme.bg_app) + .children(children) +} + +pub fn view( + state: &State, + theme: &Theme, + lift: impl Fn(Msg) -> HostMsg + Clone + Send + Sync + 'static, +) -> View { + let header = shell_header(state, theme); + // Render según la señal dura de alt-screen: pantalla completa (grid/vim) + // sólo si el PTY entró a alternate screen; un PTY en modo líneas (p. ej. + // `watch`) se lee como IDE-text; sin PTY, las cards de comandos. + // Misma estructura estable que `body_view`: la rama sale del espejo + // `tui_skin_vivo`, no del try_lock contendible de `is_tui_active()`. + let tui_vivo = state.tui_skin_vivo.is_some(); + let main_panel: View = if state.tui_altscreen_vivo { + tui_panel::(state, theme, lift.clone()) + } else if tui_vivo { + // PTY inline — misma lógica que `body_view` (vista consola si es + // claude, grid recortado si no). + consola_o_inline::(state, theme, &lift) + } else { + // El output va por la superficie de terminal virtualizada (única vía + // desde la Fase 5 del SDD-TERMINAL: el `output_pane` viejo + las cards + // per-comando IDE fueron borrados). + output_pane_surface::(state, theme, &lift) + }; + // Panel de grupos [RUN] a la izquierda (rescate del shell GPUI): cada + // grupo guardado (`:save`) es una card clickable que lo ejecuta, con su + // tecla F. Sólo aparece si hay grupos y no estamos en un TUI fullscreen. + let body: View = if !state.groups.is_empty() && !tui_vivo { + View::new(Style { + flex_direction: FlexDirection::Row, + size: Size { + width: percent(1.0_f32), + height: Dimension::auto(), + }, + flex_basis: length(0.0_f32), + flex_grow: 1.0, + min_size: Size { + width: Dimension::auto(), + height: length(0.0_f32), + }, + gap: Size { + width: length(8.0_f32), + height: length(0.0_f32), + }, + align_items: Some(AlignItems::Stretch), + ..Default::default() + }) + .children(vec![groups_panel::(state, theme, &lift), main_panel]) + } else { + main_panel + }; + let input = shell_input_view(state, theme, lift.clone()); + + let mut children = vec![header, body]; + // Banner de reprocess: el próximo comando recibe por stdin el stdout + // del bloque armado. Click → cancela (toggle). + if let Some(src) = state.reprocess_source { + children.push( + View::new(Style { + size: Size { + width: percent(1.0_f32), + height: length(18.0_f32), + }, + padding: Rect { + left: length(8.0_f32), + right: length(8.0_f32), + top: length(0.0_f32), + bottom: length(0.0_f32), + }, + ..Default::default() + }) + .fill(theme.bg_input) + .radius(3.0) + .hover_fill(theme.bg_row_hover) + .on_click(lift(Msg::SetReprocess(src))) + .text_aligned( + format!("reprocesando la salida del bloque #{src} — Enter ejecuta · click cancela"), + 10.0, + theme.accent, + Alignment::Start, + ), + ); + } + // Panel de completado: justo encima del input, candidatos con el resaltado + // actual (el renderer único: íconos XDG + etiquetas de origen). Tab/flechas + // navegan, Enter acepta, Esc cierra. + if let Some(popup) = completion_panel(state, theme, 480.0, 1.0, lift.clone()) { + children.push(popup); + } + if let Some(banner) = input_focus_banner::(state, theme, &lift) { + children.push(banner); + } + // A1 — oferta de coreografía: discreta, justo sobre el input. A2 — oferta + // de alias para una línea larga repetida: el gemelo de A1, pero sólo si no + // hay coreografía pendiente (una sola oferta a la vez, sin apilar chips). + if let Some(chip) = choreography_chip::(state, theme, &lift) { + children.push(chip); + } else if let Some(chip) = alias_chip::(state, theme, &lift) { + children.push(chip); + } + children.push(input); + if state.history_search.is_some() { + children.push(history_search_panel::(state, theme)); + } + // El menú contextual del output (click derecho) lo arma y pinta la propia + // superficie (`surf_context_menu`, dentro de `output_pane_surface`), sobre + // su selección del stream — ya no hay un menú legacy a nivel del root. + + let lift_scale = lift.clone(); + View::new(Style { + flex_direction: FlexDirection::Column, + size: Size { + width: percent(1.0_f32), + height: percent(1.0_f32), + }, + padding: Rect { + left: length(12.0_f32), + right: length(12.0_f32), + top: length(10.0_f32), + bottom: length(10.0_f32), + }, + gap: Size { + width: length(0.0_f32), + height: length(8.0_f32), + }, + ..Default::default() + }) + .fill(theme.bg_app) + // Ctrl+rueda (o pinch de trackpad) sobre cualquier parte del shell = zoom + // del texto. Va por `on_scale` —que el runtime resuelve ANTES que el + // `on_scroll` de la superficie de output— para que la rueda con Ctrl no se + // la coma el scroll del cuerpo (era el bug del "zoom con mouse que falta"). + // `factor` es el cambio multiplicativo incremental (>1 agranda); `ZoomBy` + // lo aplica igual que el pinch. + .on_scale(move |_phase, factor, _fx, _fy| Some(lift_scale(Msg::ZoomBy(factor)))) + .children(children) +} + +/// Panel principal para un PTY inline (sin alt-screen). Skin claude → **vista +/// consola**: SÓLO la historia cosechada del block (que `detect_claude` +/// desplaniza en prosa + herramientas plegadas, con colores) en la surface +/// virtualizada — una sola terminal, sin cola viva abajo (era un segundo +/// terminal duplicado con su propio input). Los demás skins conservan el grid +/// recortado completo. +fn consola_o_inline( + state: &State, + theme: &Theme, + lift: &(impl Fn(Msg) -> HostMsg + Clone + Send + Sync + 'static), +) -> View { + // El skin sale del espejo `tui_skin_vivo` — `running_skin()` hace + // try_lock y bajo streaming continuo devolvía `None` (el drain tiene el + // mutex casi siempre), aplanando la consola al grid crudo cada frame. + let es_claude = matches!(state.tui_skin_vivo, Some(crate::AppSkin::Claude)); + if !es_claude { + return tui_inline_panel::(state, theme, lift.clone(), None); + } + // Vista consola de claude — modelo DIRECTO + ESTRUCTURADO (2026-07-20): el + // BUFFER LIMPIO del terminal (scrollback deduplicado + pantalla viva) pasa + // por `detect_claude` → secciones/tablas y se rinde con la surface bonita + // (paneles legibles, tablas desplanadas, scroll). Sin cosecha ni basura + // repetida; sin volver a terminales planos. + let paneles = surface_view::consola_surface_claude::(state, theme, lift); + // Señal de VIDA: tira SLIM (una línea, sin caja) con spinner animado cuando + // `claude_ocupado`. El área viva ya se ve en los paneles, pero la tira lo + // hace inconfundible. Idle = sólo los paneles. + if !state.claude_ocupado { + return paneles; + } + const GLIFOS: [&str; 6] = ["✻", "✳", "✶", "✽", "✢", "∗"]; + let g = GLIFOS[((crate::history_helpers::now_unix_millis() / 130) as usize) % GLIFOS.len()]; + let tira = View::new(Style { + size: Size { width: percent(1.0_f32), height: length(22.0_f32) }, + flex_shrink: 0.0, + align_items: Some(AlignItems::Center), + padding: Rect { + left: length(10.0_f32), + right: length(10.0_f32), + top: length(2.0_f32), + bottom: length(2.0_f32), + }, + ..Default::default() + }) + .text_aligned( + format!("{g} claude está trabajando… · Esc interrumpe"), + 12.0, + theme.accent, + Alignment::Start, + ) + .mono() + .max_lines(1); + View::new(Style { + flex_direction: FlexDirection::Column, + size: Size { width: percent(1.0_f32), height: Dimension::auto() }, + flex_basis: length(0.0_f32), + flex_grow: 1.0, + min_size: Size { width: Dimension::auto(), height: length(0.0_f32) }, + gap: Size { width: length(0.0_f32), height: length(2.0_f32) }, + ..Default::default() + }) + .children(vec![paneles, tira]) +} + +/// Banner sobre la línea que avisa a qué comando vivo va el Enter (stdin), +/// cuando el input está dirigido a un job en vez de a la línea. `None` cuando +/// el foco es la línea (arrancar comandos). Click → vuelve a la línea. +pub(crate) fn input_focus_banner( + state: &State, + theme: &Theme, + lift: &(impl Fn(Msg) -> HostMsg + Clone + Send + Sync + 'static), +) -> Option> { + let block = state.input_focus?; + // Sólo si el destino sigue vivo (si murió, el update ya limpió el foco; este + // chequeo cubre el frame intermedio). + let arc = state.job_by_block(block)?; + let cmd = arc.lock().ok().map(|g| g.command.clone())?; + let label = format!("→ Enter va al stdin de «{cmd}» · click o mouse sobre la línea para volver a tipear comandos"); + Some( + View::new(Style { + size: Size { + width: percent(1.0_f32), + height: length(18.0_f32), + }, + flex_shrink: 0.0, + padding: Rect { + left: length(8.0_f32), + right: length(8.0_f32), + top: length(0.0_f32), + bottom: length(0.0_f32), + }, + ..Default::default() + }) + .fill(theme.bg_input_focus) + .radius(3.0) + .hover_fill(theme.bg_row_hover) + .on_click(lift(Msg::FocusInput)) + .text_aligned(label, 10.0, theme.accent, Alignment::Start) + .mono() + .max_lines(1), + ) +} + +/// A1 — chip de coreografía sobre el input: cuando una secuencia repetida +/// supera el umbral, ofrece guardarla como grupo ejecutable. El shell propone, +/// el usuario acepta con un click («guardar» → F-key) o la descarta. `None` +/// si no hay ninguna coreografía que ofrecer. Discreto y descartable: nunca +/// bloquea, nunca ejecuta nada solo. +pub(crate) fn choreography_chip( + state: &State, + theme: &Theme, + lift: &(impl Fn(Msg) -> HostMsg + Clone + Send + Sync + 'static), +) -> Option> { + let p = choreography_suggestion(state)?; + let name = p.suggested_name(); + let preview = p.example.join(" → "); + let label = format!( + "↻ lo corriste {} veces · guardar «{name}» como grupo? ({preview})", + p.occurrences + ); + let sig = p.signature.clone(); + + // Chip de acción reutilizable (innermost-wins: gana el click sobre el banner). + let action = |text: &str, + fill: llimphi_ui::llimphi_raster::peniko::Color, + fg: llimphi_ui::llimphi_raster::peniko::Color, + msg: Msg| + -> View { + View::new(Style { + size: Size { width: Dimension::auto(), height: length(16.0_f32) }, + flex_shrink: 0.0, + padding: Rect { + left: length(7.0_f32), + right: length(7.0_f32), + top: length(0.0_f32), + bottom: length(0.0_f32), + }, + ..Default::default() + }) + .fill(fill) + .radius(3.0) + .hover_fill(theme.bg_row_hover) + .on_click(lift(msg)) + .text_aligned(text.to_string(), 10.0, fg, Alignment::Start) + .mono() + }; + + Some( + View::new(Style { + flex_direction: FlexDirection::Row, + size: Size { + width: percent(1.0_f32), + height: length(20.0_f32), + }, + flex_shrink: 0.0, + align_items: Some(AlignItems::Center), + gap: Size { width: length(6.0_f32), height: length(0.0_f32) }, + padding: Rect { + left: length(8.0_f32), + right: length(8.0_f32), + top: length(0.0_f32), + bottom: length(0.0_f32), + }, + ..Default::default() + }) + .fill(theme.bg_input) + .radius(4.0) + .children(vec![ + View::new(Style { + size: Size { width: Dimension::auto(), height: length(16.0_f32) }, + flex_grow: 1.0, + ..Default::default() + }) + .text_aligned(label, 10.0, theme.accent, Alignment::Start) + .mono() + .max_lines(1), + action( + "guardar", + theme.accent, + theme.bg_panel, + Msg::AcceptChoreography(sig.clone()), + ), + action( + "descartar", + theme.bg_input, + theme.fg_muted, + Msg::DismissChoreography(sig), + ), + ]), + ) +} + +/// A2 — chip de alias sobre el input: cuando una **línea larga** se repitió +/// varias veces idéntica, ofrece bautizarla con un nombre corto (`[aliases]` +/// del shumarc, vía `upsert_key`). Mismo molde que la coreografía (A1), otra +/// fuente: A1 abstrae una *secuencia*, A2 acorta *una* línea. El shell propone, +/// el usuario acepta con un click («aliasar» → aprendido al rc) o la descarta. +/// `None` si no hay ninguna línea que valga acortar. +pub(crate) fn alias_chip( + state: &State, + theme: &Theme, + lift: &(impl Fn(Msg) -> HostMsg + Clone + Send + Sync + 'static), +) -> Option> { + let sug = alias_suggestion(state)?; + let label = format!( + "⌁ lo tecleaste {} veces · acortar a «{}»? ({})", + sug.count, sug.name, sug.line + ); + let line = sug.line.clone(); + + let action = |text: &str, + fill: llimphi_ui::llimphi_raster::peniko::Color, + fg: llimphi_ui::llimphi_raster::peniko::Color, + msg: Msg| + -> View { + View::new(Style { + size: Size { width: Dimension::auto(), height: length(16.0_f32) }, + flex_shrink: 0.0, + padding: Rect { + left: length(7.0_f32), + right: length(7.0_f32), + top: length(0.0_f32), + bottom: length(0.0_f32), + }, + ..Default::default() + }) + .fill(fill) + .radius(3.0) + .hover_fill(theme.bg_row_hover) + .on_click(lift(msg)) + .text_aligned(text.to_string(), 10.0, fg, Alignment::Start) + .mono() + }; + + Some( + View::new(Style { + flex_direction: FlexDirection::Row, + size: Size { + width: percent(1.0_f32), + height: length(20.0_f32), + }, + flex_shrink: 0.0, + align_items: Some(AlignItems::Center), + gap: Size { width: length(6.0_f32), height: length(0.0_f32) }, + padding: Rect { + left: length(8.0_f32), + right: length(8.0_f32), + top: length(0.0_f32), + bottom: length(0.0_f32), + }, + ..Default::default() + }) + .fill(theme.bg_input) + .radius(4.0) + .children(vec![ + View::new(Style { + size: Size { width: Dimension::auto(), height: length(16.0_f32) }, + flex_grow: 1.0, + ..Default::default() + }) + .text_aligned(label, 10.0, theme.accent, Alignment::Start) + .mono() + .max_lines(1), + action( + "aliasar", + theme.accent, + theme.bg_panel, + Msg::AcceptAlias(line.clone()), + ), + action( + "descartar", + theme.bg_input, + theme.fg_muted, + Msg::DismissAlias(line), + ), + ]), + ) +} diff --git a/02_ruway/shuma/sandbox/shuma-module-shell/src/view/output_line.rs b/02_ruway/shuma/sandbox/shuma-module-shell/src/view/output_line.rs new file mode 100644 index 0000000..8ca03af --- /dev/null +++ b/02_ruway/shuma/sandbox/shuma-module-shell/src/view/output_line.rs @@ -0,0 +1,516 @@ +use super::*; + +/// `true` si la línea es una notice de cierre (`✔/✘/⏹`) — para que tanto +/// `update` (que no tiene theme) como la `view` calculen el cuerpo igual. +pub(crate) fn is_status_line(text: &str) -> bool { + let t = text.trim_start(); + t.starts_with('✔') || t.starts_with('✘') || t.starts_with('⏹') +} + +/// Estado de cierre de un comando, para el badge (icono + color en vez del +/// crudo "exit N"). +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub(crate) enum CmdStatus { + Running, + Ok, + Fail, + Cancelled, +} + +impl CmdStatus { + /// Deriva el estado de la notice de cierre (`✔ exit 0`, `✘ exit N`, + /// `⏹ cancel…`). `None` si no es una notice de estado. + pub(crate) fn from_notice(text: &str) -> Option { + let t = text.trim_start(); + if t.starts_with('✔') { + Some(Self::Ok) + } else if t.starts_with('⏹') { + Some(Self::Cancelled) + } else if t.starts_with('✘') { + Some(Self::Fail) + } else { + None + } + } + + /// Icono vectorial + color del badge. + pub(crate) fn icon_color( + self, + theme: &Theme, + ) -> (llimphi_icons::Icon, llimphi_ui::llimphi_raster::peniko::Color) { + use llimphi_icons::Icon; + use llimphi_ui::llimphi_raster::peniko::Color; + match self { + CmdStatus::Ok => (Icon::Check, Color::from_rgba8(120, 200, 140, 255)), + CmdStatus::Fail => (Icon::X, theme.fg_destructive), + CmdStatus::Cancelled => (Icon::Stop, theme.fg_destructive), + CmdStatus::Running => (Icon::Play, theme.accent), + } + } +} + +/// Formato corto de bytes para el header de un run vivo: `B/KB/MB/GB` +/// sin decimales — entra cómodo en 96 px de slot. "0 B" tras arrancar +/// el run, "12 KB" mientras crece, "2 MB" para outputs gordos. +pub(crate) fn format_bytes_short(n: u64) -> String { + const KB: u64 = 1024; + const MB: u64 = 1024 * 1024; + const GB: u64 = 1024 * 1024 * 1024; + if n < KB { + format!("{n} B") + } else if n < MB { + format!("{} KB", n / KB) + } else if n < GB { + format!("{} MB", n / MB) + } else { + format!("{} GB", n / GB) + } +} + +/// Tiempo relativo legible ("hace 4 minutos", "hace 2 h", "hace 3 d"…). +/// `then`/`now` en segundos unix. Vacío si `then == 0` (sin timestamp). +/// Cubre del segundo al año; el foco es la lectura rápida del año en curso. +pub(crate) fn relative_time(then: u64, now: u64) -> String { + if then == 0 { + return String::new(); + } + let d = now.saturating_sub(then); + if d < 5 { + "recién".to_string() + } else if d < 60 { + format!("hace {d} s") + } else if d < 3600 { + let m = d / 60; + format!("hace {m} min") + } else if d < 86_400 { + let h = d / 3600; + format!("hace {h} h") + } else if d < 7 * 86_400 { + let days = d / 86_400; + format!("hace {days} d") + } else if d < 30 * 86_400 { + let w = d / (7 * 86_400); + format!("hace {w} sem") + } else if d < 365 * 86_400 { + let mo = d / (30 * 86_400); + format!("hace {mo} mes{}", if mo == 1 { "" } else { "es" }) + } else { + let y = d / (365 * 86_400); + format!("hace {y} año{}", if y == 1 { "" } else { "s" }) + } +} + +/// Líneas del **cuerpo** de un bloque, en orden del buffer: stdout/stderr +/// y notices que no son de cierre, excluyendo el Prompt (header) y las +/// líneas de etapa (tee). Es exactamente lo que `command_card` pinta en el +/// cuerpo IDE-text; `update` la usa para mapear el puntero a (línea, col) +/// sobre el mismo texto. El editor las une con `\n`. +pub(crate) fn body_lines_for_block(state: &State, block: u64) -> Vec { + state + .output + .iter() + .filter(|l| { + l.block == block + && l.kind != OutputKind::Prompt + && l.stage.is_none() + && !is_status_line(&l.text) + }) + .map(|l| l.text.clone()) + .collect() +} + +/// Kinds de las líneas del cuerpo, alineados 1:1 con +/// [`body_lines_for_block`] — para tintar stderr sin perder el resto. +pub(crate) fn body_kinds_for_block(state: &State, block: u64) -> Vec { + state + .output + .iter() + .filter(|l| { + l.block == block + && l.kind != OutputKind::Prompt + && l.stage.is_none() + && !is_status_line(&l.text) + }) + .map(|l| l.kind) + .collect() +} + +/// Titular semáforo (A5) de un bloque colapsado: resumen determinista +/// contado desde las decoraciones `Severity` del cuerpo — +/// *«3 errores · 12 avisos · 48 líneas · 4 s»*. El nerdo habitual escanea la +/// columna de headers como un log semáforo sin desplegar nada. `dur_secs` es +/// la duración del bloque (`block_ended − block_started`); se omite si < 1 s. +/// Una línea cuenta como error si contiene alguna palabra/glifo de severidad +/// Error; si no, como aviso si contiene alguno de Warn. El color lo decide el +/// llamador según [`titular_tiene_error`]/[`titular_tiene_aviso`]. +pub(crate) fn semaforo_titular(lines: &[String], cwd: &std::path::Path, dur_secs: Option) -> String { + let mut errores = 0usize; + let mut avisos = 0usize; + for l in lines { + let mut linea_err = false; + let mut linea_warn = false; + for d in shuma_line::decorate::decorate_line(l, cwd) { + match d.kind { + shuma_line::decorate::DecorationKind::Severity( + shuma_line::decorate::Severity::Error, + ) => linea_err = true, + shuma_line::decorate::DecorationKind::Severity( + shuma_line::decorate::Severity::Warn, + ) => linea_warn = true, + _ => {} + } + } + if linea_err { + errores += 1; + } else if linea_warn { + avisos += 1; + } + } + let plural = |n: usize, uno: &str, varios: &str| { + if n == 1 { + format!("{n} {uno}") + } else { + format!("{n} {varios}") + } + }; + let mut partes: Vec = Vec::new(); + if errores > 0 { + partes.push(plural(errores, "error", "errores")); + } + if avisos > 0 { + partes.push(plural(avisos, "aviso", "avisos")); + } + partes.push(plural(lines.len(), "línea", "líneas")); + if let Some(secs) = dur_secs { + if secs >= 1 { + partes.push(format!("{secs} s")); + } + } + partes.join(" · ") +} + +/// `true` si el titular semáforo reporta al menos un error (→ tinte rojo). +pub(crate) fn titular_tiene_error(titular: &str) -> bool { + titular.contains("error") +} + +/// `true` si el titular semáforo reporta avisos (→ tinte ámbar; subordinado +/// al rojo de error en el llamador). +pub(crate) fn titular_tiene_aviso(titular: &str) -> bool { + titular.contains("aviso") +} + +/// Mezcla lineal de dos colores sRGB (`t=0` → `a`, `t=1` → `b`). Vivía en el +/// `output_pane` viejo (borrado en la Fase 5 del SDD-TERMINAL); ahora reside +/// aquí, junto a su único consumidor (`body_editor_palette`). +pub(crate) fn mix_color( + a: llimphi_ui::llimphi_raster::peniko::Color, + b: llimphi_ui::llimphi_raster::peniko::Color, + t: f32, +) -> llimphi_ui::llimphi_raster::peniko::Color { + use llimphi_ui::llimphi_raster::peniko::Color; + let t = t.clamp(0.0, 1.0); + let ca = a.components; + let cb = b.components; + Color::from_rgba8( + ((ca[0] + (cb[0] - ca[0]) * t) * 255.0).round() as u8, + ((ca[1] + (cb[1] - ca[1]) * t) * 255.0).round() as u8, + ((ca[2] + (cb[2] - ca[2]) * t) * 255.0).round() as u8, + 255, + ) +} + +/// Métricas del editor de cuerpo: mono 12px con `line_height` clavado a +/// `ROW_H` para que la contabilidad de alturas del scroll (que asume +/// ROW_H por línea) siga cuadrando. +pub(crate) fn body_editor_metrics() -> llimphi_widget_text_editor::EditorMetrics { + let mut m = llimphi_widget_text_editor::EditorMetrics::for_font_size(12.0); + m.line_height = ROW_H; + m +} + +/// Paleta del editor de cuerpo: fondo de la card (`bg_panel_alt`), gutter +/// sutil, resto desde el theme. +pub(crate) fn body_editor_palette(theme: &Theme) -> llimphi_widget_text_editor::EditorPalette { + let mut p = llimphi_widget_text_editor::EditorPalette::from_theme(theme); + p.bg = theme.bg_panel_alt; + // Gutter un escalón más hundido que el cuerpo: la columna de numeración se + // lee como gutter (look IDE), no flotando sobre el mismo fondo. + p.bg_gutter = mix_color(theme.bg_panel_alt, theme.sunken(), 0.6); + p +} + +/// Panel de un PTY en **modo líneas** (sin alt-screen): pinta la pantalla +/// del programa como text de IDE read-only (numeración + mono), no como una +/// grilla apretada. Sin selección interactiva por ahora (el contenido viene +/// del screen vt100, no del buffer de OutputLine). Las teclas siguen yendo +/// al PTY (`is_tui_active`). +pub(crate) fn pty_lines_panel( + state: &State, + theme: &Theme, +) -> View { + // Filas + estilos (colores fg/bg, bold/italic) desde las celdas del vt100: + // claude se lee con SUS colores. (El colapso de escena que obligó al bypass + // /tmp/pata-sin-editor murió con el cap de clips de llimphi.) + let (mut lines, mut estilos) = pty_lines_estilizadas(state, theme) + .unwrap_or_else(|| (vec![String::new()], vec![Vec::new()])); + // pty_lines_estilizadas ya arranca en lo no cosechado; un tope de + // seguridad por si aún no se cosechó nada (evita una cola gigante). + let cola_max = (state.cola_filas.round().max(5.0) as usize).max(24); + if lines.len() > cola_max { + let desde = lines.len() - cola_max; + lines.drain(..desde); + estilos.drain(..desde); + } + let n = lines.len().max(1); + let mut ed = llimphi_widget_text_editor::EditorState::new(); + ed.set_text(&lines.join("\n")); + // Métrica TERMINAL: sin gutter (una terminal no numera líneas). + let mut metrics = llimphi_widget_text_editor::EditorMetrics::terminal(12.0); + metrics.line_height = ROW_H; + let mut palette = body_editor_palette(theme); + // OPACO a propósito, como el grid y el panel de vim: una terminal no se + // lee a través del glass del drawer — claude (inline, sin alt-screen) se + // pintaba aquí y salía "transparente". + palette.bg = theme.sunken().with_alpha(1.0); + palette.bg_gutter = theme.sunken().with_alpha(1.0); + let editor = llimphi_widget_text_editor::text_editor_view_styled::( + &ed, + &palette, + metrics, + n, + &estilos, + &[], + |_ev| None, + ); + // Publica el rect pintado en `last_tui_rect`, igual que el grid y el panel + // de vim: el resize por tick del PTY (`drain_run`) y el tamaño de spawn + // dependen de esto. Sin publicarlo, un programa interactivo SIN alt-screen + // (claude/Ink renderiza inline) quedaba con el PTY en las dims del spawn —o + // las del último TUI fullscreen— y pintaba «un pedacito» o «la mitad», + // según qué hubiera corrido antes. El publisher es un hijo absoluto + // invisible que sólo anota el rect. + let rect_slot = std::sync::Arc::clone(&state.last_tui_rect); + let publisher = View::new(Style { + position: llimphi_ui::llimphi_layout::taffy::prelude::Position::Absolute, + inset: Rect { + left: length(0.0_f32), + top: length(0.0_f32), + right: length(0.0_f32), + bottom: length(0.0_f32), + }, + ..Default::default() + }) + .paint_with(move |_scene, _ts, rect| { + if let Ok(mut g) = rect_slot.lock() { + *g = (rect.w, rect.h); + } + }); + View::new(Style { + flex_direction: FlexDirection::Column, + // Alto EXPLÍCITO al 100% (no `auto`+grow): hospedado en el drawer de + // pata el panel colapsaba al alto de su contenido — con un PTY recién + // nacido en 24×80 quedaba una cajita del ~9% de la pantalla, publicaba + // ese rect chico, el PTY nunca se agrandaba y el bucle se trababa en el + // tamaño de spawn ("claude incompleto"). Llenando el cuerpo, el rect + // publicado es el área real y el resize del tick agranda el PTY. + size: Size { + width: percent(1.0_f32), + height: percent(1.0_f32), + }, + flex_basis: length(0.0_f32), + flex_grow: 1.0, + min_size: Size { + width: Dimension::auto(), + height: length(0.0_f32), + }, + padding: Rect { + left: length(8.0_f32), + right: length(8.0_f32), + top: length(6.0_f32), + bottom: length(6.0_f32), + }, + ..Default::default() + }) + .fill(theme.sunken().with_alpha(1.0)) + .radius(3.0) + .clip(true) + .children(vec![editor, publisher]) +} + +/// Color por tipo de archivo, estilo `ls --color` — para que el `ls` (y +/// cualquier listado con paths) deje de verse plano. +pub(crate) fn kind_color( + kind: shuma_line::FileKind, + theme: &Theme, +) -> llimphi_ui::llimphi_raster::peniko::Color { + use llimphi_ui::llimphi_raster::peniko::Color; + use shuma_line::FileKind as K; + match kind { + K::Folder => Color::from_rgba8(100, 160, 235, 255), // azul + K::Symlink => Color::from_rgba8(90, 200, 205, 255), // cyan + K::Image => Color::from_rgba8(200, 140, 210, 255), // magenta + K::Audio => Color::from_rgba8(210, 165, 120, 255), // ámbar + K::Video => Color::from_rgba8(210, 140, 165, 255), // rosa + K::Archive => Color::from_rgba8(210, 120, 110, 255), // rojo + K::Document => Color::from_rgba8(205, 200, 140, 255), // amarillo + K::Code => Color::from_rgba8(130, 185, 225, 255), // azul claro + K::Data => Color::from_rgba8(150, 200, 160, 255), // verde agua + K::Font => Color::from_rgba8(190, 170, 220, 255), // violeta + K::Executable => Color::from_rgba8(130, 205, 140, 255), // verde + K::Generic => theme.fg_text, + } +} + +/// Color de una decoración (path/url/grep/sha/issue/box) — el mismo +/// vocabulario semántico que el render por-línea viejo, ahora como runs de +/// color para el editor del cuerpo. +pub(crate) fn decoration_color( + kind: &shuma_line::DecorationKind, + theme: &Theme, +) -> llimphi_ui::llimphi_raster::peniko::Color { + use llimphi_ui::llimphi_raster::peniko::Color; + use shuma_line::DecorationKind as Dk; + match kind { + Dk::Path { + abs, + is_dir, + is_executable, + is_symlink, + } => kind_color( + shuma_line::file_kind(abs, *is_dir, *is_executable, *is_symlink), + theme, + ), + Dk::Url(_) => Color::from_rgba8(110, 180, 220, 255), + Dk::GrepRef { .. } => theme.accent, + Dk::GitSha(_) => Color::from_rgba8(210, 165, 120, 255), + Dk::IssueRef(_) => Color::from_rgba8(200, 200, 140, 255), + Dk::BoxDraw => theme.fg_muted, + // Coloreo semántico de relleno: tonos suaves, claramente por + // debajo de los accionables (paths/urls) en saturación. + Dk::Number => Color::from_rgba8(209, 154, 102, 255), // naranja suave + Dk::DateTime => Color::from_rgba8(126, 166, 180, 255), // teal apagado + Dk::Severity(shuma_line::Severity::Error) => theme.fg_destructive, + Dk::Severity(shuma_line::Severity::Warn) => Color::from_rgba8(220, 200, 120, 255), + Dk::Severity(shuma_line::Severity::Ok) => Color::from_rgba8(130, 205, 140, 255), + Dk::Version => Color::from_rgba8(187, 160, 220, 255), // violeta + Dk::Percent => Color::from_rgba8(100, 200, 200, 255), // cian + Dk::PermMask => Color::from_rgba8(140, 152, 175, 255), // gris azulado + } +} + +/// Runs de color `(byte_start, byte_end, Color)` por cada línea del cuerpo +/// de `block`, alimentando `text_editor_view_colored`: stderr en rojo, y +/// las decoraciones de `shuma-line` (paths por tipo, urls, grep, sha…) +/// coloreadas. Devuelve un vec alineado 1:1 con `body_lines_for_block`. +pub(crate) fn body_color_runs( + state: &State, + block: u64, + theme: &Theme, +) -> Vec> { + // Runs REALES por línea (cosecha del PTY: los colores del programa), + // alineados 1:1 con body_lines_for_block (mismo filtro). + let propios: Vec>> = state + .output + .iter() + .filter(|l| { + l.block == block + && l.kind != OutputKind::Prompt + && l.stage.is_none() + && !is_status_line(&l.text) + }) + .map(|l| l.runs.clone()) + .collect(); + let lines = body_lines_for_block(state, block); + let kinds = body_kinds_for_block(state, block); + lines + .iter() + .enumerate() + .map(|(i, text)| { + // Colores propios del programa (claude, etc.): mandan. + if let Some(Some(runs)) = propios.get(i) { + if !runs.is_empty() { + use llimphi_ui::llimphi_raster::peniko::Color; + // Los runs vienen en columnas de CHAR; el render espera + // rangos de BYTE sobre `text`. + let byte_de = |col: u32| -> usize { + text.char_indices() + .nth(col as usize) + .map(|(b, _)| b) + .unwrap_or(text.len()) + }; + return runs + .iter() + .map(|(d, h, c)| { + (byte_de(*d), byte_de(*h), Color::from_rgba8(c[0], c[1], c[2], c[3])) + }) + .collect(); + } + } + // stderr: toda la línea en rojo (señal de error, además del tinte). + if matches!(kinds.get(i), Some(OutputKind::Stderr)) { + return vec![(0usize, text.len(), theme.fg_destructive)]; + } + // IA: toda la línea en el acento, para distinguir la voz del LLM del + // stdout del comando de un vistazo. + if matches!(kinds.get(i), Some(OutputKind::Ai)) { + return vec![(0usize, text.len(), theme.accent)]; + } + shuma_line::decorate_line(text, &state.cwd) + .into_iter() + .filter(|d| d.start < d.end && d.end <= text.len()) + .map(|d| (d.start, d.end, decoration_color(&d.kind, theme))) + .collect() + }) + .collect() +} + +pub(crate) fn pretty_path(p: &std::path::Path) -> String { + let full = p.display().to_string(); + if let Ok(home) = std::env::var("HOME") { + if full == home { + return "~".into(); + } + if let Some(rest) = full.strip_prefix(&format!("{home}/")) { + return format!("~/{rest}"); + } + } + full +} + +#[cfg(test)] +mod a5_titular_tests { + use super::semaforo_titular; + + fn cwd() -> std::path::PathBuf { + std::path::PathBuf::from("/tmp") + } + + #[test] + fn cuenta_errores_avisos_y_duracion() { + let lines = vec![ + "Compiling shuma v0.1.0".to_string(), + "error[E0308]: mismatched types".to_string(), + "error: could not compile".to_string(), + "warning: unused variable `x`".to_string(), + "Finished".to_string(), + ]; + let t = semaforo_titular(&lines, &cwd(), Some(4)); + assert_eq!(t, "2 errores · 1 aviso · 5 líneas · 4 s"); + } + + #[test] + fn limpio_sin_severidad() { + let lines = vec!["total 248".to_string(), "CLAUDE.md".to_string()]; + // Sin errores/avisos y duración 0 → sólo el conteo de líneas. + let t = semaforo_titular(&lines, &cwd(), Some(0)); + assert_eq!(t, "2 líneas"); + } + + #[test] + fn singular_un_error() { + let lines = vec!["error: boom".to_string()]; + let t = semaforo_titular(&lines, &cwd(), None); + assert_eq!(t, "1 error · 1 línea"); + } +} diff --git a/02_ruway/shuma/sandbox/shuma-module-shell/src/view/surface_view.rs b/02_ruway/shuma/sandbox/shuma-module-shell/src/view/surface_view.rs new file mode 100644 index 0000000..95031ea --- /dev/null +++ b/02_ruway/shuma/sandbox/shuma-module-shell/src/view/surface_view.rs @@ -0,0 +1,2751 @@ +use super::*; + +// ── Superficie de terminal virtualizada (la ÚNICA vía de output desde la Fase 5) ── +// +// El `output_pane` viejo + las cards per-comando IDE fueron borrados; esta es la +// única superficie de output (salvo PTY/TUI fullscreen). Mapea el modelo del shell +// (`OutputLine` + bloques + `collapsed` + `block_command`) al modelo de bloques +// de `llimphi-widget-terminal`: cada comando = un header (chrome) + su cuerpo +// (rango de líneas en un `Scrollback`); colapsar = no emitir el cuerpo. El +// scroll del widget vive en la superficie (no en un `transform`), evitando de +// raíz el bug clip+transform; convertimos `scroll_px` (px desde el fondo, el +// modelo del shell) ↔ `scroll_y` (px desde arriba, el del widget). + +/// Alto fijo del header de comando en la superficie (px). +const SURFACE_HEADER_H: f32 = 22.0; + +/// Offsets desde los que vale la pena indexar una fila del terminal, para que +/// el color se reencuentre pese a los recortes del detector: la fila entera, la +/// fila sin sangrado, y la fila sin su viñeta (`●`/`⏺`/`⎿`) ni el espacio que +/// le sigue. Devuelve los cortes en bytes, sin repetir. +fn cortes_normalizados(texto: &str) -> Vec { + let mut cortes = vec![0usize]; + let sin_sangrado = texto.len() - texto.trim_start().len(); + if sin_sangrado > 0 { + cortes.push(sin_sangrado); + } + let resto = &texto[sin_sangrado..]; + let sin_vineta = resto.trim_start_matches(['●', '⏺', '⎿']); + if sin_vineta.len() != resto.len() { + let tras = sin_vineta.len() - sin_vineta.trim_start().len(); + cortes.push(texto.len() - sin_vineta.len() + tras); + } + cortes +} + +/// Guarda los tramos de una fila bajo la clave que empieza en `corte`, +/// rebasando los offsets a ese nuevo cero. No pisa una clave ya presente: gana +/// la primera aparición, que es la de más arriba en la pantalla. +fn indexar_tramos( + mapa: &mut std::collections::HashMap< + String, + Vec<(usize, usize, llimphi_ui::llimphi_raster::peniko::Color)>, + >, + texto: &str, + corte: usize, + tramos: &[(usize, usize, llimphi_ui::llimphi_raster::peniko::Color)], +) { + if corte > texto.len() || !texto.is_char_boundary(corte) { + return; + } + let clave = &texto[corte..]; + if clave.is_empty() { + return; + } + let ts: Vec<_> = tramos + .iter() + .filter(|(_, fin, _)| *fin > corte) + .map(|(ini, fin, c)| (ini.saturating_sub(corte), fin - corte, *c)) + .collect(); + if ts.is_empty() { + return; + } + mapa.entry(clave.to_string()).or_insert(ts); +} + +/// Ancho (px) que se le RESTA al panel antes de declararle su tamaño al +/// programa. Lo que el texto NO puede usar, sumado de verdad en vez de a ojo: +/// +/// - el **gutter** de numeración del widget (`gutter_width`: dígitos × ancho de +/// celda + 10) más su padding de 4 px hasta el primer carácter; +/// - la **barra de scroll**, 10 px a la derecha; +/// - la **sangría máxima** del contenido anidado, que se escribe como espacios +/// dentro de la propia línea y por lo tanto le come columnas al programa. +/// +/// Los dígitos del gutter se fijan en 5 (hasta 99.999 líneas) a propósito: si +/// se midieran en vivo, la reserva cambiaría al cruzar cada potencia de diez, +/// el PTY se redimensionaría y claude re-renderizaría todo — el terremoto que +/// ya sufrimos. Vale desperdiciar dos columnas a cambio de un ancho estable. +/// +/// Antes acá había un `36.0 + 24.0` puesto a ojo que ignoraba gutter y barra, +/// y por eso las líneas indentadas se leían cortadas por la derecha. +fn reserva_ancho_px(zoom: f32) -> f32 { + const DIGITOS_GUTTER: f32 = 5.0; + const GUTTER_EXTRA_PX: f32 = 10.0; + const TEXT_LEFT_PADDING_PX: f32 = 4.0; + const BARRA_SCROLL_PX: f32 = 10.0; + let char_w = crate::update::run_exec::ancho_celda_px(zoom); + let gutter = char_w * DIGITOS_GUTTER + GUTTER_EXTRA_PX + TEXT_LEFT_PADDING_PX; + // `sangria_cols` satura en el nivel 6; ese tope es el peor caso real. + let sangria = char_w * sangria_cols(usize::MAX) as f32; + gutter + BARRA_SCROLL_PX + sangria +} + +/// Sangría por nivel de anidamiento, **en caracteres** para el contenido y en +/// píxeles para la caja de los headers. Un panel dentro de otro arranca más +/// adentro, y su contenido más adentro todavía: es lo único que hace legible la +/// recursión cuando hay tres niveles. +pub(crate) fn sangria_cols(nivel: usize) -> usize { + nivel.min(6) * 2 +} + +/// La misma sangría, en píxeles, para desplazar la CAJA del header (no su +/// texto): el cuadro entero arranca indentado, no el rótulo dentro de un cuadro +/// que sigue a ras. +pub(crate) fn sangria_px(nivel: usize) -> f32 { + nivel.min(6) as f32 * 18.0 +} + +/// Aire (px) por arriba y por abajo de cada header — media línea de las 15 px +/// que mide una fila. Sin él los headers se pegan al texto de la respuesta +/// anterior y la conversación se lee como un bloque continuo. Es **parte del +/// alto declarado** del item (el widget virtualiza por alto), así que va +/// sumado en las constantes y descontado del cuerpo pintado. +pub(crate) const AIRE_HEADER: f32 = 7.0; + +/// Alto del header de una sub-sección (sub-collapsable dentro de un block), +/// con su aire incluido. Un pelo más bajo que `SURFACE_HEADER_H` para destacar +/// la jerarquía. +pub(crate) const SECTION_HEADER_H: f32 = 20.0 + AIRE_HEADER * 2.0; + +/// Alto del header de columnas de una tabla de sección (px). +const SECTION_TABLE_HEADER_H: f32 = 22.0; +/// Alto de una fila de tabla de sección (px). +const SECTION_TABLE_ROW_H: f32 = 20.0; + +/// Cap de filas renderizadas por sección-tabla. Más allá, agregamos una +/// fila final "+N filas …" en lugar de pintar 5000 Views. Cuando el usuario +/// ordena por una columna, la limitación sigue aplicando (ve los top-N +/// según ese orden) — útil para tablas muy gordas tipo `ls -lR /usr`. +pub(crate) const SECTION_TABLE_MAX_ROWS: usize = 200; + +/// Padding inferior (px) bajo una imagen horneada en el scrollback. +const IMAGE_PAD: f32 = 6.0; + +/// Tamaño en px (ancho, alto-con-padding) de una imagen horneada (kitty/sixel) +/// dadas las métricas de la superficie. Si el protocolo pidió celdas +/// (`cols`/`rows`) se respetan; si no, se encaja el tamaño en píxeles a un +/// ancho máximo razonable preservando el aspecto. +fn baked_image_size( + img: &crate::types::TermImage, + m: llimphi_widget_terminal::TermMetrics, +) -> (f32, f32) { + let cw = m.char_width.max(1.0); + let ch = m.line_height.max(1.0); + let target_w = if img.cols > 0 { + img.cols as f32 * cw + } else { + (img.px_w as f32).min(72.0 * cw) + }; + let target_h = if img.rows > 0 { + img.rows as f32 * ch + } else { + let aspect = img.px_h as f32 / img.px_w.max(1) as f32; + target_w * aspect + }; + (target_w, target_h + IMAGE_PAD) +} + +/// Chrome de una imagen horneada: un nodo del ancho del card con la imagen +/// alineada a la izquierda, encajada (`Contain`) en su caja `w`×`h`. +fn baked_image_view( + img: &crate::types::TermImage, + w: f32, + h: f32, +) -> View { + let inner = View::new(Style { + size: Size { + width: length(w), + height: length((h - IMAGE_PAD).max(1.0)), + }, + ..Default::default() + }) + .image(img.image.clone()) + .image_fit(llimphi_ui::ImageFit::Contain); + View::new(Style { + size: Size { + width: percent(1.0_f32), + height: length(h), + }, + ..Default::default() + }) + .children(vec![inner]) +} + +/// Alto total de una tabla con `n_rows` filas (capeado por SECTION_TABLE_MAX_ROWS, +/// +1 fila para el mensaje "+N filas …" cuando aplica). +pub(crate) fn section_table_height(n_rows: usize) -> f32 { + let visible = n_rows.min(SECTION_TABLE_MAX_ROWS); + let truncado = if n_rows > SECTION_TABLE_MAX_ROWS { 1.0 } else { 0.0 }; + SECTION_TABLE_HEADER_H + (visible as f32 + truncado) * SECTION_TABLE_ROW_H +} + +/// Pinta una sub-sección como tabla con headers clickeables (ordenar +/// asc/desc/sin orden) + filas mono striped. Las filas se ordenan según +/// `sort = (col, ascending)`; si `None`, orden natural del output. +pub(crate) fn section_table_view( + block: u64, + section: usize, + columns: &[String], + rows: &[Vec], + sort: Option<(usize, bool)>, + theme: &Theme, + lift: &(impl Fn(Msg) -> HostMsg + Clone + Send + Sync + 'static), +) -> View { + use llimphi_ui::llimphi_raster::peniko::Color; + // Anchos heurísticos por columna — mejor sería medir, pero + // `ls -l` tiene anchos típicos predecibles. + fn col_width(idx: usize, n: usize, name: &str) -> f32 { + match name { + "permisos" => 100.0, + "links" => 50.0, + "owner" | "group" => 80.0, + "size" => 80.0, + "fecha" => 100.0, + "comando" => 200.0, // :stats — nombres de binario + flags cortas + "variable" => 180.0, // env — nombres de variable + "hash" => 90.0, // git log --oneline + _ if idx == n - 1 => 0.0, // última = flex + _ => 90.0, + } + } + let n = columns.len(); + // Header row. + let mut header_children: Vec> = Vec::with_capacity(n); + for (col, name) in columns.iter().enumerate() { + let arrow = match sort { + Some((c, true)) if c == col => " ▲", + Some((c, false)) if c == col => " ▼", + _ => "", + }; + let w = col_width(col, n, name); + let mut style = Style { + size: Size { width: length(w.max(40.0)), height: percent(1.0_f32) }, + align_items: Some(AlignItems::Center), + padding: Rect { + left: length(8.0_f32), + right: length(8.0_f32), + top: length(0.0_f32), + bottom: length(0.0_f32), + }, + flex_shrink: 0.0, + ..Default::default() + }; + if w == 0.0 { + style.size.width = Dimension::auto(); + style.flex_grow = 1.0; + } + header_children.push( + View::new(style) + .hover_fill(theme.bg_row_hover) + .on_click(lift(Msg::SortSectionColumn { block, section, col })) + .text_aligned( + format!("{name}{arrow}"), + 11.0, + theme.fg_placeholder, + Alignment::Start, + ) + .mono(), + ); + } + let header = View::new(Style { + flex_direction: FlexDirection::Row, + size: Size { width: percent(1.0_f32), height: length(SECTION_TABLE_HEADER_H) }, + ..Default::default() + }) + .fill(theme.bg_panel_alt) + .children(header_children); + + // Rows. Aplica orden si lo hay (clon para no mutar el state). + let mut order: Vec = (0..rows.len()).collect(); + if let Some((col, asc)) = sort { + order.sort_by(|&a, &b| { + let ax = rows[a].get(col).map(|s| s.as_str()).unwrap_or(""); + let bx = rows[b].get(col).map(|s| s.as_str()).unwrap_or(""); + // Si parecen números, orden numérico; si no, lexicográfico. + let cmp = match (ax.parse::().ok(), bx.parse::().ok()) { + (Some(an), Some(bn)) => an.cmp(&bn), + _ => ax.cmp(bx), + }; + if asc { cmp } else { cmp.reverse() } + }); + } + let total_rows = order.len(); + let visible_rows = total_rows.min(SECTION_TABLE_MAX_ROWS); + let truncated = total_rows > SECTION_TABLE_MAX_ROWS; + let mut row_views: Vec> = Vec::with_capacity(visible_rows + 1); + // Stripe sutil: en vez de alternar bg_panel/transparente (saltaba a la + // vista), las filas pares llevan un velo apenas perceptible del fg — + // guía el ojo sin armar un tablero de ajedrez. + let stripe_tint = { + let c = theme.fg_text.to_rgba8(); + Color::from_rgba8(c.r, c.g, c.b, 10) + }; + for (vis_idx, &ri) in order.iter().take(visible_rows).enumerate() { + let row = &rows[ri]; + let stripe = if vis_idx % 2 == 0 { + stripe_tint + } else { + Color::from_rgba8(0, 0, 0, 0) // transparente + }; + // Tipo de entrada según la máscara de permisos (col "permisos"): + // colorea el nombre (dir = accent, ejecutable = verde, symlink = + // cian) — el `ls -l` se lee como un explorador. + let perms = columns + .iter() + .position(|c| c == "permisos") + .and_then(|ci| row.get(ci)) + .map(|s| s.as_str()) + .unwrap_or(""); + let name_color = if perms.starts_with('d') { + theme.accent + } else if perms.starts_with('l') { + Color::from_rgba8(100, 200, 200, 255) + } else if perms.contains('x') { + Color::from_rgba8(130, 205, 140, 255) + } else { + theme.fg_text + }; + let mut cells: Vec> = Vec::with_capacity(n); + for col in 0..n { + let name = columns.get(col).map(|s| s.as_str()).unwrap_or(""); + let w = col_width(col, n, name); + // Color por columna: metadata en tonos propios, nombre según + // tipo — espeja el coloreo semántico del cuerpo de output. + let cell_color = match name { + "permisos" => Color::from_rgba8(140, 152, 175, 255), + "links" | "owner" | "group" => theme.fg_muted, + "size" => Color::from_rgba8(209, 154, 102, 255), + "fecha" => Color::from_rgba8(126, 166, 180, 255), + _ if col == n - 1 => name_color, + _ => theme.fg_text, + }; + let mut style = Style { + size: Size { width: length(w.max(40.0)), height: percent(1.0_f32) }, + align_items: Some(AlignItems::Center), + padding: Rect { + left: length(8.0_f32), + right: length(8.0_f32), + top: length(0.0_f32), + bottom: length(0.0_f32), + }, + flex_shrink: 0.0, + ..Default::default() + }; + if w == 0.0 { + style.size.width = Dimension::auto(); + style.flex_grow = 1.0; + } + cells.push( + View::new(style) + .text_aligned( + row.get(col).cloned().unwrap_or_default(), + 11.0, + cell_color, + Alignment::Start, + ) + .mono() + .max_lines(1), + ); + } + row_views.push( + View::new(Style { + flex_direction: FlexDirection::Row, + size: Size { width: percent(1.0_f32), height: length(SECTION_TABLE_ROW_H) }, + ..Default::default() + }) + .fill(stripe) + .hover_fill(theme.bg_row_hover) + .children(cells), + ); + } + // Mensaje de truncado: si la tabla tiene más filas que SECTION_TABLE_MAX_ROWS, + // mostramos una última fila informativa. + if truncated { + let extra = total_rows - visible_rows; + row_views.push( + View::new(Style { + size: Size { width: percent(1.0_f32), height: length(SECTION_TABLE_ROW_H) }, + padding: Rect { + left: length(8.0_f32), + right: length(8.0_f32), + top: length(0.0_f32), + bottom: length(0.0_f32), + }, + align_items: Some(AlignItems::Center), + ..Default::default() + }) + .text_aligned( + format!("… +{extra} filas (sort por una columna para acotar)"), + 10.0, + theme.fg_muted, + Alignment::Start, + ) + .mono(), + ); + } + + let mut all = vec![header]; + all.extend(row_views); + View::new(Style { + flex_direction: FlexDirection::Column, + size: Size { + width: percent(1.0_f32), + height: length(section_table_height(rows.len())), + }, + ..Default::default() + }) + .children(all) +} + +/// `true` si la sub-sección `idx` del bloque `block` debe arrancar colapsada +/// por default. Heurística: dirs con profundidad ≥ 2 (al menos un `/` +/// después del primero) — para que `ls -R` en un árbol grande no rinda +/// miles de filas al toque. El usuario togglea con click; el set +/// `section_collapsed` guarda el OVERRIDE del default (no el estado). +pub(crate) fn section_default_collapsed(title: &str) -> bool { + // Log de claude: la BIENVENIDA (▚) y las HERRAMIENTAS (●) arrancan + // PLEGADAS (contexto / detalle de ejecución); los MENSAJES del asistente + // (▸) arrancan EXPANDIDOS (son lo legible). Ver `sections::detect_claude`. + if title.starts_with('▚') || title.starts_with('●') || title.starts_with('⏺') { + return true; + } + if title.starts_with('▸') || title.starts_with('❯') { + return false; + } + // `./` y `.` siempre expandidos. `./algo` también (depth 1). `./a/b` + // ya cierra (depth 2). + let stripped = title.trim_start_matches("./"); + stripped.matches('/').count() >= 1 +} + +/// Estado efectivo de plegado de una sub-sección: el default (heurística +/// por profundidad) flippeado por el override del usuario. +pub(crate) fn is_section_collapsed(state: &State, block: u64, idx: usize, title: &str) -> bool { + let default_col = section_default_collapsed(title); + let user_toggled = state.section_collapsed.contains(&(block, idx)); + default_col ^ user_toggled +} + +/// Header clickeable de una sub-sección. Pinta chevron + título + el conteo +/// de líneas; click emite `Msg::ToggleSection`. `idx` se usa como número +/// visible ("1.", "2.", …) para navegar listas largas. +/// Emite una sección (y sus subsecciones, recursivamente) al `items`. `sidx` +/// es un contador DFS pre-orden compartido: cada sección/subsección consume +/// uno, así el estado de expansión `(block, sidx)` identifica cada panel del +/// árbol sin ambigüedad. `nivel` da la indentación (paneles dentro de paneles). +/// El `'v` ata los items a los datos que sus chrome van a leer cuando —y si— +/// se materialicen: la sección, el tema y el `lift`. Nada se clona ni se +/// construye acá; el widget decide qué armar según lo que caiga en pantalla. +#[allow(clippy::too_many_arguments)] +fn emit_section<'v, HostMsg: Clone + 'static>( + sec: &'v crate::sections::Section, + block: u64, + sidx: &mut usize, + nivel: usize, + items: &mut Vec>, + store: &mut llimphi_widget_terminal::Scrollback, + styles: &mut Vec<(bool, Vec<(usize, usize, llimphi_ui::llimphi_raster::peniko::Color)>)>, + // Tramos de color por texto de línea, cosechados del snapshot del terminal. + colores: &std::collections::HashMap< + String, + Vec<(usize, usize, llimphi_ui::llimphi_raster::peniko::Color)>, + >, + state: &State, + theme: &'v Theme, + lift: &'v (impl Fn(Msg) -> HostMsg + Clone + Send + Sync + 'static), +) { + use crate::sections::SectionKind; + let mi_idx = *sidx; + *sidx += 1; + let col = is_section_collapsed(state, block, mi_idx, &sec.title); + let unidad = if matches!(sec.kind, SectionKind::Group(_)) { + "subsecciones" + } else if matches!(sec.kind, SectionKind::Table { .. }) { + "filas" + } else { + "líneas" + }; + if !sec.title.is_empty() { + // Turno del usuario (título ❯) = panel PRINCIPAL: header sticky con el + // input completo (multilínea), sin indentar. El resto = section_header + // normal, indentado por `nivel` (secundario/terciario). + let es_turno = nivel == 0 && sec.title.starts_with('❯'); + if es_turno { + let alto = turn_header_height(&sec.title); + items.push(llimphi_widget_terminal::Item::chrome_sticky_perezoso( + alto, + move || turn_header(block, mi_idx, &sec.title, col, alto, theme, lift), + )); + } else { + let count = sec.kind.count(); + items.push(llimphi_widget_terminal::Item::chrome_perezoso( + SECTION_HEADER_H, + move || { + section_header( + block, mi_idx, &sec.title, count, col, nivel, unidad, theme, lift, + ) + }, + )); + } + } + if col { + // Plegada: igual hay que CONSUMIR los idx de las subsecciones (para + // que los hermanos posteriores mantengan su idx estable). + if let SectionKind::Group(hijos) = &sec.kind { + for h in hijos { + consumir_idx(h, sidx); + } + } + return; + } + match &sec.kind { + SectionKind::Lines(secl) => { + let start = store.len(); + // El CONTENIDO entra un escalón más adentro que su propio header, + // para que la recursión se lea: lo de adentro está adentro. La + // sangría va en el texto (el store es un scrollback de líneas + // planas, sin desplazamiento por item); por eso son espacios y no + // píxeles, y por eso se cuentan aparte al colorear. + let sangria = " ".repeat(sangria_cols(nivel + 1)); + for line in secl { + // Los tramos vienen medidos sobre la línea SIN sangría: hay que + // correrlos, o el color arranca unos caracteres antes de su + // texto. Es la deuda que dejó indentar con espacios. + let tramos = colores + .get(line.as_str()) + .map(|ts| { + ts.iter() + .map(|(i, f, c)| (i + sangria.len(), f + sangria.len(), *c)) + .collect() + }) + .unwrap_or_default(); + styles.push((false, tramos)); + store.push_line(&format!("{sangria}{line}")); + } + items.push(llimphi_widget_terminal::Item::lines(start, store.len())); + } + SectionKind::Table { columns, rows } => { + let sort = state.section_sort.get(&(block, mi_idx)).copied(); + let h = section_table_height(rows.len()); + // La tabla es el chrome más caro del stream (hasta 200 filas × + // columnas de nodos). Diferirla es la mitad del arreglo. + items.push(llimphi_widget_terminal::Item::chrome_perezoso(h, move || { + section_table_view(block, mi_idx, columns, rows, sort, theme, lift) + })); + } + SectionKind::Group(hijos) => { + for h in hijos { + emit_section(h, block, sidx, nivel + 1, items, store, styles, colores, state, theme, lift); + } + } + } +} + +/// Consume los idx DFS de una sección plegada y su subárbol (sin emitir), +/// para que los índices de los hermanos siguientes no se corran. +fn consumir_idx(sec: &crate::sections::Section, sidx: &mut usize) { + *sidx += 1; + if let crate::sections::SectionKind::Group(hijos) = &sec.kind { + for h in hijos { + consumir_idx(h, sidx); + } + } +} + +pub(crate) fn section_header( + block: u64, + idx: usize, + title: &str, + line_count: usize, + collapsed: bool, + nivel: usize, + unidad: &str, + theme: &Theme, + lift: &(impl Fn(Msg) -> HostMsg + Clone + Send + Sync + 'static), +) -> View { + let chevron = if collapsed { + llimphi_icons::Icon::ChevronRight + } else { + llimphi_icons::Icon::ChevronDown + }; + let marker = View::new(Style { + size: Size { width: length(12.0_f32), height: length(12.0_f32) }, + flex_shrink: 0.0, + ..Default::default() + }) + .children(vec![llimphi_icons::icon_view(chevron, theme.fg_muted, 1.6)]); + let title_v = View::new(Style { + size: Size { width: Dimension::auto(), height: length(14.0_f32) }, + flex_grow: 1.0, + ..Default::default() + }) + .text_aligned( + format!("{}. {}", idx + 1, title), + 11.0, + theme.fg_text, + Alignment::Start, + ) + .mono() + .max_lines(1); + let count = View::new(Style { + size: Size { width: length(60.0_f32), height: length(14.0_f32) }, + flex_shrink: 0.0, + ..Default::default() + }) + .text_aligned( + format!("{line_count} {unidad}"), + 10.0, + theme.fg_muted, + Alignment::End, + ) + .mono(); + let base = View::new(Style { + flex_direction: FlexDirection::Row, + size: Size { + width: percent(1.0_f32), + height: length(SECTION_HEADER_H - AIRE_HEADER * 2.0), + }, + align_items: Some(AlignItems::Center), + gap: Size { width: length(6.0_f32), height: length(0.0_f32) }, + padding: Rect { + left: length(14.0_f32), + right: length(8.0_f32), + top: length(0.0_f32), + bottom: length(0.0_f32), + }, + // La CAJA ENTERA arranca indentada, no sólo el rótulo adentro de un + // cuadro que sigue pegado al margen: así el panel hijo se ve + // literalmente metido dentro del padre, y su borde izquierdo marca el + // escalón. El contenido de la sección entra un escalón más (ver + // `sangria_cols`). + margin: Rect { + left: length(sangria_px(nivel)), + right: length(0.0_f32), + top: length(0.0_f32), + bottom: length(0.0_f32), + }, + ..Default::default() + }) + .fill(theme.bg_panel) + .hover_fill(theme.bg_row_hover) + .on_click(lift(Msg::ToggleSection { block, idx })); + // Relieve más sutil que el header de bloque (radius menor): jerarquía. + con_aire( + con_relieve(base, theme.bg_panel, 3.0).children(vec![marker, title_v, count]), + SECTION_HEADER_H, + ) +} + +/// Alto del header de un TURNO (panel principal): crece con el input para +/// mostrarlo COMPLETO sin fraccionarlo (respeta `\n` y envuelve líneas largas). +pub(crate) fn turn_header_height(input: &str) -> f32 { + // Las filas vienen YA envueltas por la terminal (el título se arma + // absorbiendo las filas de continuación del input), y el header es más + // ancho que el grid, así que cada `\n` es exactamente un renglón pintado. + // El `div_ceil` de 72 columnas de antes era una envoltura imaginaria: con + // el ancho real —más del doble— inflaba el alto de los mensajes largos. + let lineas = input.split('\n').count().clamp(1, 10); + lineas as f32 * 15.0 + 10.0 + AIRE_HEADER * 2.0 +} + +/// Fondo del panel de un turno del usuario. Se distingue del resto a propósito: +/// es lo único que escribió la persona en toda la conversación, y sirve de +/// ancla visual al scrollear. Un velo del `accent` sobre el fondo de panel — +/// derivado del tema, no un color fijo, para que siga a cualquier paleta. +pub(crate) fn bg_turno(theme: &Theme) -> llimphi_ui::llimphi_raster::peniko::Color { + use llimphi_ui::llimphi_raster::peniko::Color; + let base = theme.bg_panel_alt.to_rgba8(); + let ac = theme.accent.to_rgba8(); + let mezcla = |b: u8, a: u8| -> u8 { + // 18% de accent: se reconoce de un vistazo sin pelearle al texto. + ((b as f32) * 0.82 + (a as f32) * 0.18).round().clamp(0.0, 255.0) as u8 + }; + Color::from_rgba8(mezcla(base.r, ac.r), mezcla(base.g, ac.g), mezcla(base.b, ac.b), 255) +} + +/// Header del panel PRINCIPAL (un turno del usuario): el INPUT completo, +/// multilínea, sin indentar, sticky. No lleva el prefijo `N.` — es el título de +/// la conversación, no una subsección. El cuerpo (respuestas) va indentado como +/// secundario/terciario. +pub(crate) fn turn_header( + block: u64, + idx: usize, + title: &str, + collapsed: bool, + alto: f32, + theme: &Theme, + lift: &(impl Fn(Msg) -> HostMsg + Clone + Send + Sync + 'static), +) -> View { + let chevron = if collapsed { + llimphi_icons::Icon::ChevronRight + } else { + llimphi_icons::Icon::ChevronDown + }; + let marker = View::new(Style { + size: Size { width: length(14.0_f32), height: length(14.0_f32) }, + flex_shrink: 0.0, + ..Default::default() + }) + .children(vec![llimphi_icons::icon_view(chevron, theme.accent, 1.8)]); + // Título multilínea: el input entero, envuelto, sin truncar. + let title_v = View::new(Style { + size: Size { width: Dimension::auto(), height: percent(1.0_f32) }, + flex_grow: 1.0, + ..Default::default() + }) + .text_aligned(title.to_string(), 12.0, theme.fg_text, Alignment::Start) + .mono() + .max_lines(10); + let base = View::new(Style { + flex_direction: FlexDirection::Row, + // El aire va FUERA del relieve (en el envoltorio), así que el cuerpo + // pintado se queda con el resto del alto declarado. + size: Size { + width: percent(1.0_f32), + height: length((alto - AIRE_HEADER * 2.0).max(1.0)), + }, + align_items: Some(AlignItems::Start), + gap: Size { width: length(6.0_f32), height: length(0.0_f32) }, + padding: Rect { + left: length(12.0_f32), + right: length(8.0_f32), + top: length(5.0_f32), + bottom: length(5.0_f32), + }, + ..Default::default() + }) + .fill(bg_turno(theme)) + .hover_fill(theme.bg_row_hover) + .on_click(lift(Msg::ToggleSection { block, idx })); + con_aire(con_relieve(base, bg_turno(theme), 4.0).children(vec![marker, title_v]), alto) +} + +/// Envuelve un header en su **aire**: un contenedor transparente del alto +/// declarado con [`AIRE_HEADER`] de padding arriba y abajo. Transparente a +/// propósito — el fondo y el relieve son del header, no del aire. +pub(crate) fn con_aire( + inner: View, + alto: f32, +) -> View { + View::new(Style { + size: Size { width: percent(1.0_f32), height: length(alto) }, + padding: Rect { + left: length(0.0_f32), + right: length(0.0_f32), + top: length(AIRE_HEADER), + bottom: length(AIRE_HEADER), + }, + ..Default::default() + }) + .children(vec![inner]) +} + +/// Header de un comando como **chrome** de la superficie: chevron + `$ comando` +/// + badge de estado (icono + "hace N"). Click → pliega/despliega el bloque. +/// Chrome header del bloque de líneas spilleadas: rotula "Archivado de +/// spill (N visibles · M total)" y avisa al usuario que el resto se ve +/// con `:scrollback open`. Sin click handler (informativo). +fn spilled_archive_header( + loaded: usize, + above: u64, + theme: &Theme, +) -> View { + let label = if above > 0 { + format!( + "≡ Archivado ({loaded} cargadas · ▲ {above} más arriba — scrollea al tope · `:scrollback open` para todo)" + ) + } else { + format!("≡ Archivado · inicio del historial ({loaded} líneas)") + }; + View::new(Style { + flex_direction: FlexDirection::Row, + size: Size { width: percent(1.0_f32), height: length(SURFACE_HEADER_H) }, + align_items: Some(AlignItems::Center), + padding: Rect { + left: length(8.0_f32), + right: length(8.0_f32), + top: length(0.0_f32), + bottom: length(0.0_f32), + }, + ..Default::default() + }) + .fill(theme.bg_panel) + .text_aligned(label, 11.0, theme.fg_muted, Alignment::Start) + .mono() +} + +/// `has_stdout` (param 6) gatea el chip de reprocess (sin stdout, no hay +/// nada que reprocesar). +/// Alto del notice «¿quisiste decir…?» (A4). +const DID_YOU_MEAN_H: f32 = 18.0; + +/// A4 — fila clickeable bajo un bloque fallido: *«¿`cargo build` en vez de +/// `cagro build`? → click lo lleva al input»*. No ejecuta nada solo; deja la +/// línea corregida lista para revisar y Enter. +fn did_you_mean_notice( + block: u64, + corregida: &str, + theme: &Theme, + lift: &(impl Fn(Msg) -> HostMsg + Clone + Send + Sync + 'static), +) -> View { + View::new(Style { + size: Size { width: percent(1.0_f32), height: length(DID_YOU_MEAN_H) }, + flex_shrink: 0.0, + align_items: Some(AlignItems::Center), + padding: Rect { + left: length(8.0_f32), + right: length(8.0_f32), + top: length(0.0_f32), + bottom: length(0.0_f32), + }, + ..Default::default() + }) + .fill(theme.bg_input) + .radius(3.0) + .hover_fill(theme.bg_row_hover) + .on_click(lift(Msg::AcceptDidYouMean(block))) + .text_aligned( + format!("¿quisiste decir «{corregida}»? · click lo lleva al input"), + 10.0, + theme.accent, + Alignment::Start, + ) + .mono() + .max_lines(1) +} + +// ── Relieve 3D de los headers (paridad con el `llimphi-widget-button`) ────── + +use llimphi_ui::llimphi_raster::peniko::Color; + +const RELIEVE_WHITE: Color = Color::from_rgba8(255, 255, 255, 255); +const RELIEVE_BLACK: Color = Color::from_rgba8(0, 0, 0, 255); + +/// Mezcla lineal `a → b` por `t∈[0,1]`, conservando el alpha de `a`. +fn relieve_mix(a: Color, b: Color, t: f32) -> Color { + let (ca, cb) = (a.components, b.components); + Color { + components: [ + ca[0] + (cb[0] - ca[0]) * t, + ca[1] + (cb[1] - ca[1]) * t, + ca[2] + (cb[2] - ca[2]) * t, + ca[3], + ], + ..a + } +} + +/// Luminancia percibida (Rec. 709) — decide dirección/intensidad del relieve. +fn relieve_lum(c: Color) -> f32 { + let k = c.components; + 0.2126 * k[0] + 0.7152 * k[1] + 0.0722 * k[2] +} + +/// Viste un header de chrome con el relieve 3D canónico: **sombra** ceñida +/// (separa los bloques y los levanta un pelo del panel hundido), **borde** +/// rim-light hairline (canto), y un **sheen** vertical translúcido pintado por +/// `paint_with` (luz arriba → sombra abajo) con un **specular** pegado al borde +/// superior. El sheen va sobre el fill (respeta el `hover_fill`) y debajo del +/// texto/chips (orden de pintado del compositor: fill → painter → texto → +/// hijos). `base` es el color de fondo del header; su luminancia modula todo +/// para que lea igual de fino en themes claros y oscuros. Mismo lenguaje visual +/// que los botones — «pareado con nada más que exista». +fn con_relieve( + v: View, + base: Color, + radius: f64, +) -> View { + let lum = relieve_lum(base); + let dark = (1.0 - lum) as f64; + let shadow = llimphi_ui::Shadow { + color: Color::from_rgba8(0, 0, 0, 46), + blur: 5.0, + dx: 0.0, + dy: 1.5, + spread: -1.0, + }; + let border_col = if lum < 0.5 { + relieve_mix(base, RELIEVE_WHITE, 0.16) + } else { + relieve_mix(base, RELIEVE_BLACK, 0.14) + }; + let top_a = ((0.10 + 0.12 * dark).min(0.22)) as f32; + let spec_a = ((0.20 + 0.20 * dark).min(0.48)) as f32; + let sheen_top = Color { components: [1.0, 1.0, 1.0, top_a], ..RELIEVE_WHITE }; + let sheen_mid = Color::from_rgba8(255, 255, 255, 0); + let sheen_bot = Color::from_rgba8(0, 0, 0, 24); + v.radius(radius) + .shadow(shadow) + .border(1.0, border_col) + .paint_with(move |scene, _ts, rect| { + use llimphi_ui::llimphi_raster::kurbo::{Affine, Point, RoundedRect}; + use llimphi_ui::llimphi_raster::peniko::{Fill, Gradient}; + if rect.w <= 0.0 || rect.h <= 0.0 { + return; + } + let x0 = rect.x as f64; + let y0 = rect.y as f64; + let x1 = (rect.x + rect.w) as f64; + let y1 = (rect.y + rect.h) as f64; + let rr = RoundedRect::new(x0, y0, x1, y1, radius); + let sheen = Gradient::new_linear(Point::new(x0, y0), Point::new(x0, y1)) + .with_stops([sheen_top, sheen_mid, sheen_bot].as_slice()); + scene.fill(Fill::NonZero, Affine::IDENTITY, &sheen, None, &rr); + // Specular: hairline brillante pegado al borde superior interno. + let inset = radius.clamp(0.5, 1.0); + let cap = RoundedRect::new(x0 + inset, y0 + 0.6, x1 - inset, y0 + 1.8, radius * 0.5); + let spec = Color { components: [1.0, 1.0, 1.0, spec_a], ..RELIEVE_WHITE }; + scene.fill(Fill::NonZero, Affine::IDENTITY, &spec, None, &cap); + }) +} + +#[allow(clippy::too_many_arguments)] +fn surface_header( + block: u64, + header_text: &str, + status: Option, + expandable: bool, + collapsed: bool, + has_stdout: bool, + titular: Option<&str>, + state: &State, + theme: &Theme, + lift: &(impl Fn(Msg) -> HostMsg + Clone + Send + Sync + 'static), +) -> View { + let chevron = if collapsed { + llimphi_icons::Icon::ChevronRight + } else { + llimphi_icons::Icon::ChevronDown + }; + let marker = View::new(Style { + size: Size { width: length(14.0_f32), height: length(14.0_f32) }, + flex_shrink: 0.0, + ..Default::default() + }) + .children(if expandable { + vec![llimphi_icons::icon_view(chevron, theme.fg_muted, 1.6)] + } else { + Vec::new() + }); + + let cmd_color = if expandable || status == Some(CmdStatus::Running) { + theme.accent + } else { + theme.fg_muted + }; + let cmd = View::new(Style { + size: Size { width: Dimension::auto(), height: length(16.0_f32) }, + flex_grow: 1.0, + ..Default::default() + }) + .text_aligned(header_text.to_string(), 12.0, cmd_color, Alignment::Start) + .mono() + .max_lines(1); + + let running = status == Some(CmdStatus::Running); + let is_input_focus = state.input_focus == Some(block); + // E2 — tag `%cN` clickeable: hace visible el número del bloque (para + // referenciarlo en `%cN | grep …`) y, al click, inserta la ref en el + // input. Sólo en bloques con stdout (los que son fuente de datos útil). + let mut children = if has_stdout { + let ref_tag = View::new(Style { + size: Size { width: Dimension::auto(), height: length(14.0_f32) }, + flex_shrink: 0.0, + padding: Rect { + left: length(3.0_f32), + right: length(3.0_f32), + top: length(0.0_f32), + bottom: length(0.0_f32), + }, + ..Default::default() + }) + .radius(3.0) + .hover_fill(theme.bg_row_hover) + .on_click(lift(Msg::InsertBlockRef(block))) + .text_aligned(format!("%c{block}"), 9.0, theme.fg_muted, Alignment::Start) + .mono(); + vec![marker, ref_tag, cmd] + } else { + vec![marker, cmd] + }; + // Titular semáforo (A5): cuando el bloque está colapsado, el header gana + // el resumen contado del cuerpo (errores/avisos/líneas/duración). El nerdo + // habitual escanea la columna de headers como un log semáforo sin + // desplegar nada. Color = dosis de alarma: rojo si hubo errores, ámbar si + // sólo avisos, tenue si limpio. + if let Some(t) = titular { + let color = if titular_tiene_error(t) { + theme.fg_destructive + } else if titular_tiene_aviso(t) { + llimphi_ui::llimphi_raster::peniko::Color::from_rgba8(220, 190, 120, 255) + } else { + theme.fg_muted + }; + children.push( + View::new(Style { + size: Size { width: Dimension::auto(), height: length(16.0_f32) }, + // Crece con base 0 (como el comando): se lleva el espacio + // sobrante y el texto, alineado a la derecha, no se mide + // contra un ancho apretado (que lo recortaba/envolvía). + flex_grow: 1.0, + flex_basis: length(0.0_f32), + ..Default::default() + }) + .text_aligned(t.to_string(), 10.0, color, Alignment::End) + .mono() + .max_lines(1), + ); + } + // Chip de foco de input: sólo en comandos vivos. Marca/dirige a quién le + // va el Enter de la línea (stdin). Click lo fija; el header entero también + // foca al pasar el mouse (`on_pointer_enter`, abajo). Cuando ESTE es el + // destino, se pinta encendido (acento) para que se vea de un vistazo a + // cuál de los comandos en paralelo está escuchando la línea. + if running { + let (fill, fg, label) = if is_input_focus { + (theme.accent, theme.bg_panel, "⌨ recibe input") + } else { + (theme.bg_input, theme.fg_muted, "⌨ dar input") + }; + children.push( + View::new(Style { + size: Size { width: Dimension::auto(), height: length(16.0_f32) }, + flex_shrink: 0.0, + padding: Rect { + left: length(5.0_f32), + right: length(5.0_f32), + top: length(0.0_f32), + bottom: length(0.0_f32), + }, + ..Default::default() + }) + .fill(fill) + .radius(3.0) + .hover_fill(theme.bg_row_hover) + .on_click(lift(Msg::FocusJob(block))) + .text_aligned(label.to_string(), 10.0, fg, Alignment::Start) + .mono(), + ); + } + // Chip de reprocess: alimenta el stdout de este bloque al stdin del + // próximo comando (paridad con el `command_card` del path viejo). Clic + // arma/desarma; el hit-test innermost-wins le da prioridad sobre el + // header (que pliega el bloque). Colapsado = modo escaneo: el titular + // semáforo reemplaza los chips de acción para no saturar la fila. + if has_stdout && !collapsed { + let armed = state.reprocess_source == Some(block); + let (fill, fg) = if armed { + (theme.accent, theme.bg_panel) + } else { + (theme.bg_input, theme.fg_muted) + }; + children.push( + View::new(Style { + size: Size { width: Dimension::auto(), height: length(16.0_f32) }, + flex_shrink: 0.0, + padding: Rect { + left: length(5.0_f32), + right: length(5.0_f32), + top: length(0.0_f32), + bottom: length(0.0_f32), + }, + ..Default::default() + }) + .fill(fill) + .radius(3.0) + .hover_fill(theme.bg_row_hover) + .on_click(lift(Msg::SetReprocess(block))) + .text_aligned("» stdin".to_string(), 10.0, fg, Alignment::Start) + .mono(), + ); + } + // Chip "🜲 filtrar": filtro IA sobre la salida del bloque. Prellena el input + // `:filtra %cN ` y deja el cursor para la instrucción (no auto-ejecuta). + // Sólo en bloques con cuerpo; oculto al colapsar. + if expandable && !collapsed { + children.push( + View::new(Style { + size: Size { width: Dimension::auto(), height: length(16.0_f32) }, + flex_shrink: 0.0, + padding: Rect { + left: length(5.0_f32), + right: length(5.0_f32), + top: length(0.0_f32), + bottom: length(0.0_f32), + }, + ..Default::default() + }) + .fill(theme.bg_input) + .radius(3.0) + .hover_fill(theme.bg_row_hover) + .on_click(lift(Msg::PrefillInput(format!(":filtra %c{block} ")))) + .text_aligned("🜲 filtrar".to_string(), 10.0, theme.accent, Alignment::Start) + .mono(), + ); + } + // Botón chico "$": copia SÓLO el comando (sin salida). Más compacto que el + // "copiar" (ancho fijo, un glifo). Disponible en cualquier bloque no + // colapsado —incluso los sin salida—, porque siempre hay comando que copiar. + if !collapsed { + children.push( + View::new(Style { + size: Size { width: length(16.0_f32), height: length(16.0_f32) }, + flex_shrink: 0.0, + align_items: Some(AlignItems::Center), + ..Default::default() + }) + .fill(theme.bg_input) + .radius(3.0) + .hover_fill(theme.bg_row_hover) + .on_click(lift(Msg::CopyCommandOnly(block))) + .aria_label("Copiar sólo el comando".to_string()) + .text_aligned("$".to_string(), 10.0, theme.fg_muted, Alignment::Center) + .mono(), + ); + } + // Chip "copiar": copia el bloque entero (comando + stdout + stderr) al + // clipboard, sin depender de una selección — paridad con el "copy command + // + output" de las terminales modernas. Sólo en bloques con cuerpo. Click + // propio (innermost-wins) para no plegar el bloque. Oculto al colapsar + // (modo escaneo: manda el titular semáforo). + if expandable && !collapsed { + children.push( + View::new(Style { + size: Size { width: Dimension::auto(), height: length(16.0_f32) }, + flex_shrink: 0.0, + padding: Rect { + left: length(5.0_f32), + right: length(5.0_f32), + top: length(0.0_f32), + bottom: length(0.0_f32), + }, + ..Default::default() + }) + .fill(theme.bg_input) + .radius(3.0) + .hover_fill(theme.bg_row_hover) + .on_click(lift(Msg::CopyCommandBlock(block))) + .text_aligned("copiar".to_string(), 10.0, theme.fg_muted, Alignment::Start) + .mono(), + ); + } + // Chip "⇄ comparar": cotejo de un clic. Marca este bloque como ancla; con + // otro ya marcado, dispara `:compara %cA %cB` entre ambos. Resalta (acento) + // cuando ESTE es el bloque marcado; muestra contra cuál cotejará si el + // ancla es otro. Sólo en bloques con cuerpo; oculto al colapsar. + if expandable && !collapsed { + let (label, color, fill) = match state.compare_anchor { + Some(a) if a == block => ("⇄ elegido".to_string(), theme.bg_panel, theme.accent), + Some(a) => (format!("⇄ vs %c{a}"), theme.accent, theme.bg_input), + None => ("⇄ comparar".to_string(), theme.fg_muted, theme.bg_input), + }; + children.push( + View::new(Style { + size: Size { width: Dimension::auto(), height: length(16.0_f32) }, + flex_shrink: 0.0, + padding: Rect { + left: length(5.0_f32), + right: length(5.0_f32), + top: length(0.0_f32), + bottom: length(0.0_f32), + }, + ..Default::default() + }) + .fill(fill) + .radius(3.0) + .hover_fill(theme.bg_row_hover) + .on_click(lift(Msg::CompareWith(block))) + .text_aligned(label, 10.0, color, Alignment::Start) + .mono(), + ); + } + if let Some(st) = status { + let (icon, color) = st.icon_color(theme); + children.push( + View::new(Style { + size: Size { width: length(12.0_f32), height: length(12.0_f32) }, + flex_shrink: 0.0, + ..Default::default() + }) + .children(vec![llimphi_icons::icon_view(icon, color, 1.8)]), + ); + // Mientras corre, mostrar bytes recibidos en vivo en el slot del + // timestamp — feedback inmediato de que el stream está moviendo + // datos (más útil que "hace 0 s"). Al terminar, vuelve al "hace…". + let right_text = if st == CmdStatus::Running && state.current_block == block { + format_bytes_short(state.current_run_bytes) + } else { + relative_time( + state.block_started.get(&block).copied().unwrap_or(0), + now_unix_secs(), + ) + }; + children.push( + View::new(Style { + size: Size { width: length(96.0_f32), height: length(16.0_f32) }, + flex_shrink: 0.0, + ..Default::default() + }) + .text_aligned(right_text, 10.0, theme.fg_muted, Alignment::End) + .mono(), + ); + } + + // El header del comando vivo que recibe el input se tiñe (bg_input_focus) + // para distinguirlo de los otros en paralelo. + let header_fill = if running && is_input_focus { + theme.bg_input_focus + } else { + theme.bg_panel + }; + let mut v = View::new(Style { + flex_direction: FlexDirection::Row, + size: Size { width: percent(1.0_f32), height: length(SURFACE_HEADER_H) }, + align_items: Some(AlignItems::Center), + gap: Size { width: length(6.0_f32), height: length(0.0_f32) }, + padding: Rect { + left: length(6.0_f32), + right: length(8.0_f32), + top: length(0.0_f32), + bottom: length(0.0_f32), + }, + ..Default::default() + }) + .fill(header_fill); + if expandable { + v = v.on_click(lift(Msg::ToggleBlock(block))); + } + // Mientras corre, pasar el mouse por encima dirige el input a este comando + // (el "mousemove" del pedido). Es no destructivo: re-focar la línea (mouse + // sobre el input) o hover en otro job vivo cambia el destino al instante. + // El `hover_fill` no es sólo cosmético: el hit-test de hover de Llimphi sólo + // elige nodos con `hover_fill`, así que es lo que hace que el + // `on_pointer_enter` dispare al pasar el mouse por el header. + if running { + v = v + .hover_fill(theme.bg_input_focus) + .on_pointer_enter(lift(Msg::FocusJob(block))); + } + // Relieve 3D: sombra + borde rim-light + sheen. El header se levanta del + // panel hundido y lee como un panel desplegable con dimensión. + con_relieve(v, header_fill, 4.0).children(children) +} + +/// `true` salvo opt-out explícito (`SHUMA_FONDO_QUIETO=1`): el fondo del +/// output respira con una deriva lenta del accent. Leído una vez por proceso. +fn fondo_vivo_enabled() -> bool { + use std::sync::OnceLock; + static EN: OnceLock = OnceLock::new(); + *EN.get_or_init(|| std::env::var_os("SHUMA_FONDO_QUIETO").is_none()) +} + +/// Pinta el **fondo vivo** sobre el panel hundido: dos lóbulos radiales del +/// accent con alpha bajísimo (≤ 4%) cuyo centro deriva en una curva de +/// Lissajous con períodos primos entre sí (~37 s y ~53 s) — nunca repite +/// exactamente, nunca distrae. El texto va por encima con contraste intacto. +fn paint_fondo_vivo( + scene: &mut vello::Scene, + rect: llimphi_ui::PaintRect, + accent: llimphi_ui::llimphi_raster::peniko::Color, +) { + use llimphi_ui::llimphi_raster::kurbo::{Affine, Rect as KurboRect}; + use llimphi_ui::llimphi_raster::peniko::{Color, Fill, Gradient}; + use vello::kurbo::Point; + + let t = now_unix_millis() as f64 / 1000.0; + let a = accent.to_rgba8(); + let bounds = KurboRect::new( + rect.x as f64, + rect.y as f64, + (rect.x + rect.w) as f64, + (rect.y + rect.h) as f64, + ); + // Cada lóbulo: (período x, período y, fase, alpha pico, radio relativo). + let lobulos: [(f64, f64, f64, u8, f64); 2] = [ + (37.0, 53.0, 0.0, 10, 0.85), + (53.0, 41.0, 2.4, 7, 0.65), + ]; + for (px, py, fase, alpha, rr) in lobulos { + let cx = rect.x as f64 + + rect.w as f64 * (0.5 + 0.38 * (t * std::f64::consts::TAU / px + fase).sin()); + let cy = rect.y as f64 + + rect.h as f64 * (0.5 + 0.38 * (t * std::f64::consts::TAU / py + fase * 0.7).cos()); + let radio = (rect.w.max(rect.h) as f64) * rr; + let grad = Gradient::new_radial(Point::new(cx, cy), radio as f32).with_stops( + [ + Color::from_rgba8(a.r, a.g, a.b, alpha), + Color::from_rgba8(a.r, a.g, a.b, 0), + ] + .as_slice(), + ); + scene.fill(Fill::NonZero, Affine::IDENTITY, &grad, None, &bounds); + } +} + +/// Vista consola de claude (modelo DIRECTO + estructurado): toma el BUFFER +/// LIMPIO del terminal ([`capture_tui_completo`]: scrollback ya deduplicado + +/// pantalla viva), lo pasa por `detect_claude` → secciones (prosa, herramientas, +/// TABLAS desplanadas, turnos plegables) y las rinde con la MISMA surface bonita +/// que el output normal (`emit_section` + `block_surface_with_scroll`): paneles +/// legibles, tablas, scroll + virtualización — sin cosecha ni basura repetida +/// (el terminal ya distinguió temporal de permanente). Filosofía del sistema: +/// desplanar, no terminales planos (feedback del usuario 2026-07-20). +pub(crate) fn consola_surface_claude( + state: &State, + theme: &Theme, + lift: &(impl Fn(Msg) -> HostMsg + Clone + Send + Sync + 'static), +) -> View { + use llimphi_ui::llimphi_raster::peniko::Color; + use llimphi_widget_terminal::{ + block_surface_with_scroll, blocks_height, LineStyle, Scrollback, SelectionConfig, + TermMetrics, TermPalette, + }; + + // 1. Buffer limpio → líneas de texto (una por fila del grid) **y sus tramos + // de color**. Las celdas del snapshot traen `fg` por celda; aplanar a + // `String` la tiraba, y por eso la consola salía monocroma aunque el + // renderer sabe pintar tramos desde siempre. + let snapshot = state + .running + .as_ref() + .and_then(|arc| arc.try_lock().ok()) + .and_then(|mut g| crate::view::capture_tui_completo(&mut g)); + let mut lines: Vec = Vec::new(); + // El detector de secciones trabaja sobre texto pelado y no devuelve de qué + // fila salió cada línea, así que el color se reencuentra **por el texto**. + // Colisión = dos filas idénticas, que en un terminal tienen el mismo color; + // el que no aparezca sale sin color, nunca con el color ajeno. + let mut colores: std::collections::HashMap> = + std::collections::HashMap::new(); + if let Some(snap) = snapshot.as_ref() { + for row in &snap.cells { + let mut texto = String::new(); + let mut tramos: Vec<(usize, usize, Color)> = Vec::new(); + for celda in row { + let ini = texto.len(); + texto.push_str(&celda.ch); + if celda.ch.trim().is_empty() { + continue; // el blanco no lleva color propio + } + let col = crate::view::vt_color(celda.fg, theme.clone(), false); + // Se funde con el tramo anterior si es el mismo color y quedó + // pegado: menos tramos, mismo resultado. + match tramos.last_mut() { + Some((_, fin, c)) if *c == col && *fin == ini => *fin = texto.len(), + _ => tramos.push((ini, texto.len(), col)), + } + } + let texto = texto.trim_end().to_string(); + tramos.retain(|(ini, _, _)| *ini < texto.len()); + for t in tramos.iter_mut() { + t.1 = t.1.min(texto.len()); + } + // El detector no guarda las líneas como vinieron: les come el + // sangrado y los glifos de viñeta (`●`, `⏺`, `⎿`). Si sólo + // indexáramos la fila cruda, la búsqueda fallaría en casi todas. + // Se indexan también las variantes recortadas, con los tramos + // rebasados al nuevo cero. + for corte in cortes_normalizados(&texto) { + indexar_tramos(&mut colores, &texto, corte, &tramos); + } + lines.push(texto); + } + } + + // 2. Estructurar con el detector de claude (secciones + tablas + turnos). + let secs = crate::sections::detect_claude(&lines).unwrap_or_default(); + + // CAPTURA DE LA CAJA ❯ — sin throttle, en CADA frame: la respuesta sugerida + // (ghost tenue) y el texto tipeado pasan por la caja viva y pueden durar un + // solo tick, así que un muestreo cada 2 s los pierde. Anexa append-only. + if let Some(g) = state.running.as_ref().and_then(|a| a.try_lock().ok()) { + if let Some(tui) = g.tui.as_ref() { + let screen = tui.parser.screen(); + let (rows, cols) = screen.size(); + // ZONA BAJA COMPLETA: la respuesta sugerida no pasa por la fila ❯, + // así que registramos las últimas filas enteras con su color/atributo + // para localizar dónde y cómo la dibuja claude. + let mut bloque = String::new(); + for r in rows.saturating_sub(8)..rows { + let mut linea = String::new(); + let mut cols_fg: std::collections::BTreeSet = Default::default(); + let mut dim = false; + let mut it = false; + for c in 0..cols { + if let Some(cell) = screen.cell(r, c) { + if cell.has_contents() { + linea.push_str(cell.contents()); + if !cell.contents().trim().is_empty() { + cols_fg.insert(match cell.fgcolor() { + vt100::Color::Default => "def".to_string(), + vt100::Color::Idx(i) => format!("i{i}"), + vt100::Color::Rgb(rr, gg, bb) => { + format!("#{rr:02x}{gg:02x}{bb:02x}") + } + }); + it |= cell.italic(); + dim |= cell.bold(); + } + } else { + linea.push(' '); + } + } + } + let t = linea.trim_end(); + if !t.trim().is_empty() { + bloque.push_str(&format!( + "r{r:2}[{}{}] fg={cols_fg:?} |{t}\n", + if dim { "B" } else { "." }, + if it { "I" } else { "." } + )); + } + } + if !bloque.trim().is_empty() { + use std::sync::Mutex; + static ULTIMO: Mutex = Mutex::new(String::new()); + if let Ok(mut u) = ULTIMO.lock() { + // Tope de 4 MB: /tmp es RAM y el spinner cambia cada frame. + let lleno = std::fs::metadata("/tmp/shuma-sugerencia.log") + .map(|m| m.len() > 4 * 1024 * 1024) + .unwrap_or(false); + if *u != bloque && !lleno { + *u = bloque.clone(); + let _ = std::fs::OpenOptions::new() + .create(true) + .append(true) + .open("/tmp/shuma-sugerencia.log") + .and_then(|mut f| { + use std::io::Write; + f.write_all(format!("--- frame ---\n{bloque}").as_bytes()) + }); + } + } + } + } + } + + // DIAGNÓSTICO (temporal): volcar el buffer real + la estructura detectada a + // un archivo cada ~2 s, para ver qué produce claude en el buffer de shuma y + // por qué `detect_claude` no estructura. Quitar tras diagnosticar. + { + use std::sync::atomic::{AtomicU64, Ordering}; + static LAST: AtomicU64 = AtomicU64::new(0); + let now = crate::history_helpers::now_unix_millis(); + if now.saturating_sub(LAST.load(Ordering::Relaxed)) > 2000 { + LAST.store(now, Ordering::Relaxed); + let mut out = String::new(); + out.push_str(&format!("=== BUFFER: {} lineas ===\n", lines.len())); + for (i, l) in lines.iter().enumerate() { + // Con los COLORES de la línea. Los backticks de un bloque de + // código no sobreviven al renderer de claude (llegan como una + // línea indentada más, indistinguible de la prosa), así que si + // hay una señal para pintarles fondo tiene que ser el color con + // que claude los pinta. Sin verlos, cualquier detector sería + // adivinanza. + let tramos = colores.get(l).map(|v| { + let mut cs: Vec = v + .iter() + .map(|(_, _, c)| { + let k = c.components; + format!( + "#{:02x}{:02x}{:02x}", + (k[0] * 255.0) as u8, + (k[1] * 255.0) as u8, + (k[2] * 255.0) as u8 + ) + }) + .collect(); + cs.dedup(); + cs.join(",") + }); + // Marca H4: qué fondo gana esta línea, con los MISMOS tramos que + // ve el render — así el volcado certifica por texto (cod=código, + // add=diff añadido, del=diff quitado, --=nada). + let marca = match colores.get(l).map(|v| crate::codigo::fondo_de_linea(v)) { + Some(crate::codigo::Fondo::Codigo) => "cod", + Some(crate::codigo::Fondo::Anadida) => "add", + Some(crate::codigo::Fondo::Quitada) => "del", + _ => "--", + }; + match tramos { + Some(c) if !c.is_empty() => { + out.push_str(&format!("{i:4}|{marca}|[{c}] {l}\n")) + } + _ => out.push_str(&format!("{i:4}|{marca}|[-] {l}\n")), + } + } + out.push_str(&format!("\n=== SECCIONES detect_claude: {} ===\n", secs.len())); + fn dump_sec(out: &mut String, s: &crate::sections::Section, nivel: usize) { + let ind = " ".repeat(nivel); + let tipo = match &s.kind { + crate::sections::SectionKind::Lines(_) => "Lines", + crate::sections::SectionKind::Table { .. } => "Table", + crate::sections::SectionKind::Group(_) => "Group", + }; + out.push_str(&format!("{ind}[{tipo} x{}] {}\n", s.kind.count(), s.title)); + if let crate::sections::SectionKind::Group(h) = &s.kind { + for c in h { + dump_sec(out, c, nivel + 1); + } + } + } + for s in &secs { + dump_sec(&mut out, s, 0); + } + // LIVE SCREEN crudo con COLOR + atributos: las últimas filas traen la + // caja de input de claude (que normalmente recortamos) donde vive la + // RESPUESTA SUGERIDA (ghost tenue). Para ver cómo la marca claude + // (color/atenuación/italic) y poder reconocerla. + fn fmt_color(c: vt100::Color) -> String { + match c { + vt100::Color::Default => "def".to_string(), + vt100::Color::Idx(i) => format!("i{i}"), + vt100::Color::Rgb(r, g, b) => format!("#{r:02x}{g:02x}{b:02x}"), + } + } + if let Some(g) = state.running.as_ref().and_then(|a| a.try_lock().ok()) { + if let Some(tui) = g.tui.as_ref() { + let screen = tui.parser.screen(); + let (rows, cols) = screen.size(); + out.push_str(&format!( + "\n=== LIVE SCREEN {rows}x{cols} (ultimas 16 filas, con color/attrs) ===\n" + )); + let start = rows.saturating_sub(16); + for r in start..rows { + let mut txt = String::new(); + let mut colores: std::collections::BTreeSet = Default::default(); + let mut b = false; + let mut it = false; + for c in 0..cols { + if let Some(cell) = screen.cell(r, c) { + let ch = if cell.has_contents() { + cell.contents().to_string() + } else { + " ".to_string() + }; + if !ch.trim().is_empty() { + colores.insert(fmt_color(cell.fgcolor())); + b |= cell.bold(); + it |= cell.italic(); + } + txt.push_str(&ch); + } + } + out.push_str(&format!( + "{r:3}[{}{}] fg={colores:?} |{}\n", + if b { "B" } else { "." }, + if it { "I" } else { "." }, + txt.trim_end() + )); + } + // CAPTURA PERSISTENTE: la caja de input de claude (fila con ❯ + // pegada al footer) donde vive la respuesta SUGERIDA. Se anexa a + // un log append-only en cuanto trae contenido, para que sobreviva + // aunque yo (claude) me ponga ocupado y sobrescriba el dump. Así + // rompemos el huevo-y-gallina de capturar una sugerencia real. + } + } + out.push_str(&format!( + "\n=== SUGERENCIA detectada por el reconocedor: {:?} ===\n", + state.claude_sugerencia + )); + let _ = std::fs::write("/tmp/shuma-consola-dump.txt", out); + } + } + + // 3. Métricas/paleta iguales al output normal. + let zoom = state.font_zoom.clamp(0.5, 3.0); + let row_h = ROW_H * zoom; + let metrics = TermMetrics { + font_size: 12.0 * zoom, + line_height: row_h, + char_width: 12.0 * 0.6 * zoom, + }; + let mut palette = TermPalette::from_theme(theme); + palette.bg = Color::from_rgba8(0, 0, 0, 0); + + // 4. Emitir las secciones a items/store con el MISMO renderer (headers + // plegables, tablas, groups). Block sintético estable para el plegado. + const BLOCK_CONSOLA: u64 = u64::MAX - 8; + let mut store = Scrollback::new(0); + let mut items: Vec> = Vec::new(); + let mut styles: Vec<(bool, Vec<(usize, usize, Color)>)> = Vec::new(); + let mut sidx = 0usize; + for sec in &secs { + emit_section(sec, BLOCK_CONSOLA, &mut sidx, 0, &mut items, &mut store, &mut styles, &colores, state, theme, lift); + } + + // 5. Scroll: mismo modelo que la surface (scroll_px desde el fondo → scroll_y + // desde arriba; pinned al fondo por default). + let measured = state.out_viewport_h.lock().map(|g| *g).unwrap_or(0.0); + let content_h = blocks_height(&items, row_h); + let viewport_h = if measured >= 1.0 { measured } else { 600.0 }; + // Marca de agua alta (F1): reservar el hueco dejado por efímeros que se van, + // con un espaciador vacío al FONDO — la vista no rebota y lo nuevo cae al + // principio del hueco. `blocks_height` es suma pura → alto efectivo = +gap. + let gap = hwm_gap(state, content_h, row_h); + if gap > 0.5 { + items.push(llimphi_widget_terminal::Item::chrome_perezoso( + gap, + || View::new(Style::default()), + )); + } + let overflow = (content_h + gap - viewport_h).max(0.0); + if let Ok(mut g) = state.out_overflow.lock() { + *g = overflow; + } + let scroll_y = if state.scroll_px <= 0.5 { + overflow + } else { + (state.surf_scroll_anchor - state.scroll_px).clamp(0.0, overflow) + }; + // Fondos por línea (H4): slate para código, verde/rojo para diff añadido/ + // quitado (claude colorea el marcador `+`/`-`). Ver `crate::codigo`. + let codigo_bg = { + let c = theme.bg_button.to_rgba8(); + llimphi_ui::llimphi_raster::peniko::Color::from_rgba8(c.r, c.g, c.b, 150) + }; + let anadida_bg = llimphi_ui::llimphi_raster::peniko::Color::from_rgba8(0x50, 0xc8, 0x50, 64); + let quitada_bg = llimphi_ui::llimphi_raster::peniko::Color::from_rgba8(0xdc, 0x5a, 0x5a, 64); + let line_style = move |idx: usize, _t: &str| match styles.get(idx) { + Some((_is_err, runs)) => LineStyle { + fg: None, + runs: runs.clone(), + bg: match crate::codigo::fondo_de_linea(runs) { + crate::codigo::Fondo::Anadida => Some(anadida_bg), + crate::codigo::Fondo::Quitada => Some(quitada_bg), + crate::codigo::Fondo::Codigo => Some(codigo_bg), + crate::codigo::Fondo::Ninguno => None, + }, + }, + None => LineStyle::default(), + }; + let lift_scroll = (*lift).clone(); + let on_scroll = move |delta: f32| lift_scroll(Msg::Scroll(-delta)); + + // Publicá el layout de ESTE frame para que el `update` resuelva la + // selección de mouse y el copy-mode contra su geometría real —la misma vía + // que el output de sesión—. La consola no lleva bloques con comando: + // `block_ranges` vacío ⇒ el copiado nunca prepende comando. + let items_geo: Vec = + items.iter().map(|it| it.geo()).collect(); + let gw = llimphi_widget_terminal::gutter_width(&store, metrics); + if let Ok(mut g) = state.surf_layout.lock() { + *g = Some(crate::SurfLayout { + items_geo, + scroll_y, + viewport_h, + metrics, + gutter_w: gw, + store: std::sync::Arc::new(store.clone()), + block_ranges: Vec::new(), + }); + } + let lift_drag = (*lift).clone(); + let on_drag = + std::sync::Arc::new(move |phase, lx0, ly0, dx, dy| -> Option { + Some(lift_drag(Msg::SurfSelectDrag { phase, dx, dy, ax: lx0, ay: ly0 })) + }); + let lift_dbl = (*lift).clone(); + let on_double_click = + std::sync::Arc::new(move |lx, ly, rect_w, rect_h| -> Option { + Some(lift_dbl(Msg::SurfDoubleClick { lx, ly, rect_w, rect_h })) + }); + let sel_cfg = SelectionConfig { + range: state.surf_selection.as_ref(), + on_drag: Some(on_drag), + on_double_click: Some(on_double_click), + caret: state + .surf_copy_mode + .then(|| state.surf_selection.map(|s| s.head)) + .flatten(), + }; + let surface = block_surface_with_scroll::( + &store, + items, + scroll_y, + 0.0, + viewport_h, + metrics, + &palette, + line_style, + on_scroll, + None, + sel_cfg, + ); + + // 6. Contenedor hundido que mide su alto (viewport del próximo frame) y + // **negocia el tamaño del PTY**. + let slot = Arc::clone(&state.out_viewport_h); + let rect_slot = Arc::clone(&state.last_tui_rect); + let glow_accent = theme.accent; + let painter = move |scene: &mut vello::Scene, + _ts: &mut llimphi_ui::llimphi_text::Typesetter, + rect: llimphi_ui::PaintRect| { + if let Ok(mut g) = slot.lock() { + *g = rect.h; + } + // La vista consola NO publicaba su rect —lo hacían sólo el grid y el + // panel de vim—, así que el PTY se quedaba con las dimensiones del + // spawn: las 80×24 de siempre. Claude escribía a 80 columnas dentro de + // un panel de mil y pico de píxeles, y eso era el «ocupa un tercio de + // la pantalla»: no le habíamos dicho de qué tamaño es la ventana. + // + // Se declara un poco MENOS que el ancho real, para que la sangría de + // los paneles anidados quepa sin empujar el texto fuera de la caja. + if let Ok(mut g) = rect_slot.lock() { + *g = ((rect.w - reserva_ancho_px(zoom)).max(1.0), rect.h); + } + if fondo_vivo_enabled() && rect.w > 1.0 && rect.h > 1.0 { + paint_fondo_vivo(scene, rect, glow_accent); + } + }; + View::new(Style { + flex_direction: FlexDirection::Column, + size: Size { width: percent(1.0_f32), height: Dimension::auto() }, + flex_basis: length(0.0_f32), + flex_grow: 1.0, + min_size: Size { width: Dimension::auto(), height: length(0.0_f32) }, + ..Default::default() + }) + .fill(theme.sunken()) + .radius(3.0) + // Borde de acento cuando copy-mode está activo (transparente si no). + .border( + 2.0, + if state.surf_copy_mode { theme.accent } else { theme.accent.with_alpha(0.0) }, + ) + .clip(true) + .paint_with(painter) + // Botón medio: pega el cuasi-clipboard PRIMARY (lo último seleccionado). + .on_middle_click(lift(Msg::PrimaryPaste)) + // Click derecho: menú contextual (Copiar / Copiar todo / Seleccionar todo). + .on_right_click_at({ + let lift_menu = (*lift).clone(); + move |x, y, _w, _h| Some(lift_menu(Msg::SurfOpenMenu { x, y })) + }) + .children({ + let mut kids: Vec> = vec![surface.cursor(llimphi_ui::Cursor::Text)]; + // Banner de copy-mode (arriba-centro), sólo si está activo. + if let Some(banner) = copy_mode_banner::(state, theme) { + kids.push(banner); + } + if let Some(menu) = surf_context_menu(state, theme, lift) { + kids.push(menu); + } + kids + }) +} + +/// Tope del hueco reservado por la marca de agua, en **filas**. El hueco existe +/// para absorber efímeros (spinner, «pensando…», una línea de progreso): unos +/// pocos renglones. Sin tope, un contenido que encogía mucho dejaba un vacío de +/// **una pantalla entera** al fondo (bug del 25-jul). Escala con el zoom porque +/// se multiplica por `row_h`. +const GAP_MAX_FILAS: f32 = 6.0; + +/// **Marca de agua alta monótona (F1).** Dado el alto vivo del contenido, +/// devuelve el tamaño del **hueco a reservar** al fondo (px) para que la vista no +/// rebote cuando un efímero desaparece. El HWM se resetea en cortes naturales +/// (`clear`, comando nuevo) vía [`State::reset_content_hwm`]. El caller empuja un +/// espaciador vacío de este alto al FINAL de `items`, así lo efímero nuevo cae al +/// PRINCIPIO del hueco. +/// +/// Tres reglas, y las tres salieron del bug del 25-jul («aparece un hueco de toda +/// una pantalla abajo de todo; si subo un punto lo salta de golpe y brinca más de +/// una página; luego de un rato se acomoda»): +/// +/// 1. **El hueco se topa en [`GAP_MAX_FILAS`] renglones.** Es para efímeros, no +/// para pantallas. +/// 2. **Si el contenido encogió MÁS que ese tope, el HWM se re-basa** al alto +/// vivo: eso ya no es un efímero que se fue sino un cambio estructural (una +/// TUI que terminó, el scrollback recortado, un re-wrap por zoom o por ancho), +/// y ahí reservar es sostener un vacío enorme sin motivo. +/// 3. **Scrolled-up devuelve el hueco YA reservado, no `0`.** Devolver `0` lo +/// evaporaba en el primer paso de rueda: el contenido se acortaba de golpe +/// bajo el dedo y la vista brincaba todo el hueco (de ahí el «brinca más de +/// una página»). Congelado, subir un punto sube un punto. +pub(crate) fn hwm_gap(state: &State, content_h: f32, row_h: f32) -> f32 { + let publicar = |gap: f32| { + if let Ok(mut g) = state.content_gap.lock() { + *g = gap; + } + gap + }; + if state.scroll_px > 0.5 { + // Congelado: el alto del contenido no puede cambiar por el mero hecho + // de que el usuario haya empezado a scrollear. + return match state.content_gap.lock() { + Ok(g) => *g, + Err(p) => *p.into_inner(), + }; + } + let max_gap = (GAP_MAX_FILAS * row_h).max(0.0); + let mut g = match state.content_hwm.lock() { + Ok(g) => g, + Err(p) => p.into_inner(), + }; + if content_h > *g { + *g = content_h; + } + if *g - content_h > max_gap { + *g = content_h; // cambio estructural, no un efímero → re-basar + } + publicar((*g - content_h).clamp(0.0, max_gap)) +} + +/// `output_pane` reimplementado sobre `llimphi-widget-terminal::block_surface`. +/// Mismo modelo de datos, virtualización real (sólo se materializa lo visible). +pub(crate) fn output_pane_surface( + state: &State, + theme: &Theme, + lift: &(impl Fn(Msg) -> HostMsg + Clone + Send + Sync + 'static), +) -> View { + use llimphi_widget_terminal::{ + blocks_height, Item, LineStyle, Scrollback, TermMetrics, TermPalette, + }; + + // Agrupar por bloque preservando el orden de primera aparición (igual que + // el camino viejo). Con superficie no hace falta capar a 400: el widget + // virtualiza, así que pasamos todo el buffer vigente. + let mut order: Vec = Vec::new(); + let mut groups: std::collections::HashMap> = + std::collections::HashMap::new(); + for line in &state.output { + if !groups.contains_key(&line.block) { + order.push(line.block); + } + groups.entry(line.block).or_default().push(line); + } + + // Zoom multiplica font_size, row_h y char_width. Lo controla + // Ctrl+rueda y Ctrl+- / Ctrl+= / Ctrl+0. Clampeo [0.5, 3.0]. + let zoom = state.font_zoom.clamp(0.5, 3.0); + let row_h = ROW_H * zoom; + let metrics = TermMetrics { + font_size: 12.0 * zoom, + line_height: row_h, + char_width: 12.0 * 0.6 * zoom, + }; + use llimphi_ui::llimphi_raster::peniko::Color; + let mut palette = TermPalette::from_theme(theme); + // La superficie entera es el panel hundido; los cuerpos se leen sobre él. + // El bg del widget va TRANSPARENTE: el nodo exterior pinta el hundido + + // el fondo vivo (deriva lenta del accent) y se ve a través del widget. + palette.bg = Color::from_rgba8(0, 0, 0, 0); + + // Refresh del cache de spilled visibles (Fase 5.11): lee desde el spill + // file sólo si `spilled_count` cambió desde el último frame. El cache + // es up-to-`MAX_SPILLED_VISIBLE` líneas; el view las prepende al store. + crate::refresh_surf_spilled_visible(&state.surf_history, &state.surf_spilled_visible); + let (spilled_cache_lines, spilled_first_id): (Vec, u64) = state + .surf_spilled_visible + .lock() + .map(|c| (c.lines.clone(), c.first_id)) + .unwrap_or_default(); + + // Store de scrollback + items + estilo por línea (alineado al índice del + // store, que crece en lockstep con `push_line`). + // Las secciones se detectan POR BLOQUE dentro del loop, pero los chrome + // perezosos las leen después, al pintarse. La arena las mantiene vivas + // hasta que se arma la surface, sin clonarlas ni pedir dos pasadas. Va + // ANTES de `items`: los locales se destruyen en orden inverso y los items + // la referencian. + let arena_secciones: typed_arena::Arena> = + typed_arena::Arena::new(); + let mut store = Scrollback::new(0); + let mut items: Vec> = Vec::new(); + let mut styles: Vec<(bool, Vec<(usize, usize, llimphi_ui::llimphi_raster::peniko::Color)>)> = + Vec::new(); + // Rango de líneas del store por bloque (para prepender el comando al copiar). + let mut block_ranges: Vec<(usize, usize, u64)> = Vec::new(); + + // Prepend de las líneas spilleadas: arrancan en `store[0..]`. Tinte + // discreto (fg_muted) para marcarlas visualmente como archive y un chrome + // header antes. `first_id` = cuántas líneas quedan AÚN más arriba de la + // ventana cargada (Fase 5.12): scrollear al tope las pagina hacia atrás; + // más allá del tope de carga, `:scrollback open`. + if !spilled_cache_lines.is_empty() { + items.push(Item::chrome( + SURFACE_HEADER_H, + spilled_archive_header::( + spilled_cache_lines.len(), + spilled_first_id, + theme, + ), + )); + let start = store.len(); + for text in &spilled_cache_lines { + // Las spilled van en `fg_muted` para diferenciarlas del live. + let muted = theme.fg_muted; + styles.push((false, vec![(0usize, text.len(), muted)])); + store.push_line(text); + } + items.push(Item::lines(start, store.len())); + } + + for id in &order { + let g = &groups[id]; + if *id != 0 { + // Bloque-comando: header (chrome) + cuerpo (si no está colapsado). + let collapsed = state.collapsed.contains(id); + let has_prompt = g + .first() + .map(|l| l.kind == OutputKind::Prompt) + .unwrap_or(false); + let header_text = if has_prompt { + g[0].text.clone() + } else { + state + .block_command + .get(id) + .cloned() + .unwrap_or_else(|| "$ … (salida recortada)".to_string()) + }; + // Estado: última notice de cierre del bloque, o "corriendo". + let mut status = g + .iter() + .filter(|l| l.stage.is_none()) + .filter_map(|l| CmdStatus::from_notice(&l.text)) + .last(); + let still_running = status.is_none() + && ((state.current_block == *id && state.is_running()) + || state.bg_jobs.iter().any(|j| { + j.lock() + .map(|gg| gg.block == *id && !gg.handle.is_finished()) + .unwrap_or(false) + })); + if still_running { + status = Some(CmdStatus::Running); + } + + let lines = body_lines_for_block(state, *id); + let kinds = body_kinds_for_block(state, *id); + let runs = body_color_runs(state, *id, theme); + // Bloques que NO se desplanizan en secciones/tablas: la respuesta de + // IA (prosa/transformación; el desplanizado comería el tinte de + // acento) y el cotejo de `:compara` (ya viene en columnas alineadas; + // el detector de tablas lo rompería). Se pintan planos. + let is_ai_block = kinds.iter().any(|k| *k == OutputKind::Ai); + let is_compare_block = state + .block_command + .get(id) + .map(|c| c.starts_with("≡ :compara")) + .unwrap_or(false); + let skip_sections = is_ai_block || is_compare_block; + let has_stages = g.iter().any(|l| l.stage.is_some()); + let has_stdout = g + .iter() + .any(|l| l.kind == OutputKind::Stdout && l.stage.is_none()); + let expandable = !lines.is_empty() || has_stages; + + // Titular semáforo sólo cuando está colapsado y hay cuerpo: el + // header resume lo que el usuario no está viendo. + let titular = if collapsed && !lines.is_empty() { + let dur = state + .block_ended + .get(id) + .zip(state.block_started.get(id)) + .map(|(end, s)| end.saturating_sub(*s)); + Some(semaforo_titular(&lines, &state.cwd, dur)) + } else { + None + }; + + // Header **sticky**: se fija al tope mientras el cuerpo del bloque + // scrollea por debajo (sticky-scroll estilo VSCode / SliverList). El + // título del comando queda siempre a la vista. + items.push(Item::chrome_sticky( + SURFACE_HEADER_H, + surface_header( + *id, + &header_text, + status, + expandable, + collapsed, + has_stdout, + titular.as_deref(), + state, + theme, + lift, + ), + )); + + // Primera línea del store que pertenece a este bloque (para adjuntar + // el comando al copiar una selección que arranque aquí). + let blk_store_start = store.len(); + + // Chrome de etapas (tee): chips clickeables + capturas desplegadas + // por etapa. Paridad con el `command_card` viejo. Vacío si el + // bloque no tiene etapas o si está colapsado. Reusa el helper del + // path viejo (`stage_capture_rows`) y lo envuelve como un chrome + // de alto medido por el helper, opaco para la virtualización. + if !collapsed && has_stages { + let stage_lines: Vec<&OutputLine> = + g.iter().filter(|l| l.stage.is_some()).copied().collect(); + let (views, h) = + stage_capture_rows(&header_text, &stage_lines, *id, state, theme, lift); + if !views.is_empty() && h > 0.0 { + let chrome_view = View::new(Style { + flex_direction: FlexDirection::Column, + size: Size { width: percent(1.0_f32), height: length(h) }, + ..Default::default() + }) + .children(views); + items.push(Item::chrome(h, chrome_view)); + } + } + + if !collapsed && !lines.is_empty() { + // Detector de sub-secciones por comando: si reconoce el + // patrón (p. ej. `ls -R`), parte el output en grupos con + // su propio header colapsable. + let cmd_for_sections = state + .block_command + .get(id) + .cloned() + .unwrap_or_else(|| header_text.clone()); + if let Some(sections) = (!skip_sections) + .then(|| crate::sections::detect_sections(&cmd_for_sections, &lines)) + .flatten() + .map(|s| &*arena_secciones.alloc(s)) + { + // Render RECURSIVO: paneles dentro de paneles. El `idx` es + // el orden DFS (pre-orden) de la sección en el árbol — así + // el estado de expansión sigue siendo `(block, idx)` sin + // cambiar de tipo; el `nivel` da la indentación. + let mut sidx = 0usize; + for sec in sections { + emit_section( + sec, *id, &mut sidx, 0, &mut items, &mut store, &mut styles, + // Esta surface no viene de un grid vivo: sin celdas + // no hay color que cosechar. + &Default::default(), + state, theme, lift, + ); + } + } else { + let start = store.len(); + for (i, line) in lines.iter().enumerate() { + let is_err = matches!(kinds.get(i), Some(OutputKind::Stderr)); + styles.push((is_err, runs.get(i).cloned().unwrap_or_default())); + store.push_line(line); + } + items.push(Item::lines(start, store.len())); + } + } + + // Imágenes (kitty/sixel) horneadas del PTY (chafa/icat/img2sixel…): + // una por chrome bajo el cuerpo. Persisten en el scrollback tras + // cerrar el comando, como cualquier otra salida. + if !collapsed { + if let Some(imgs) = state.block_images.get(id) { + for img in imgs { + let (w, h) = baked_image_size(img, metrics); + items.push(Item::chrome(h, baked_image_view::(img, w, h))); + } + } + } + + // A4 — notice «¿quisiste decir…?»: si el bloque falló por + // `command not found` y hay una corrección, una fila clickeable que + // lleva la línea corregida al input. Aparece esté o no colapsado. + if let Some(corregida) = state.did_you_mean.get(id) { + items.push(Item::chrome( + DID_YOU_MEAN_H, + did_you_mean_notice(*id, corregida, theme, lift), + )); + } + + // Rango de store de este bloque (si emitió líneas): mapea selección + // → bloque para adjuntar el comando al copiar. + if store.len() > blk_store_start { + block_ranges.push((blk_store_start, store.len(), *id)); + } + } else { + // Líneas sueltas (notices iniciales sin bloque dueño) — cuerpo sin + // header, coloreadas por su decoración semántica. + let start = store.len(); + for &line in g.iter() { + let is_err = line.kind == OutputKind::Stderr; + let line_runs: Vec<_> = if is_err { + vec![(0usize, line.text.len(), theme.fg_destructive)] + } else { + shuma_line::decorate_line(&line.text, &state.cwd) + .into_iter() + .filter(|d| d.start < d.end && d.end <= line.text.len()) + .map(|d| (d.start, d.end, decoration_color(&d.kind, theme))) + .collect() + }; + styles.push((is_err, line_runs)); + store.push_line(&line.text); + } + if store.len() > start { + items.push(Item::lines(start, store.len())); + } + } + } + + // Scroll: convertir el modelo del shell (`scroll_px` desde el fondo) al del + // widget (`scroll_y` desde arriba). El viewport lo midió el painter el frame + // anterior; publicamos el overflow para que `Msg::Scroll` clampe. + let measured = state.out_viewport_h.lock().map(|g| *g).unwrap_or(0.0); + let content_h = blocks_height(&items, row_h); + let viewport_h = if measured >= 1.0 { measured } else { 600.0 }; + // Marca de agua alta (F1): reservar el hueco dejado por efímeros que se van, + // con un espaciador vacío al FONDO — la vista no rebota y lo nuevo cae al + // principio del hueco. `blocks_height` es suma pura → alto efectivo = +gap. + let gap = hwm_gap(state, content_h, row_h); + if gap > 0.5 { + items.push(llimphi_widget_terminal::Item::chrome_perezoso( + gap, + || View::new(Style::default()), + )); + } + let overflow = (content_h + gap - viewport_h).max(0.0); + if let Ok(mut g) = state.out_overflow.lock() { + *g = overflow; + } + // Anclaje estable bajo append (Fase 5 del SDD-TERMINAL): si el usuario + // está scrolled-up (`scroll_px > 0`), su `scroll_y` se interpreta + // contra el `surf_scroll_anchor` (el overflow al momento de su última + // entrada de scroll), NO contra el `overflow` vigente. Append → el + // overflow crece, pero la fila que el usuario tenía a la vista + // permanece en la misma `y` del viewport. + let scroll_y = if state.scroll_px <= 0.5 { + overflow // pinned al fondo + } else { + (state.surf_scroll_anchor - state.scroll_px).clamp(0.0, overflow) + }; + + // Estilo por línea: stderr → tinte rojo tenue; runs ya traen el coloreo + // semántico (paths/urls/stderr-rojo) calculado arriba. + let err_bg = { + let c = theme.fg_destructive.to_rgba8(); + llimphi_ui::llimphi_raster::peniko::Color::from_rgba8(c.r, c.g, c.b, 36) + }; + // Fondos por línea de la consola (H4). La surface es TRANSPARENTE + // (`palette.bg = 0`) sobre un drawer oscuro, así que los tintes van + // ELEVADOS (más claros / saturados) para despegarse — un "hundido" oscuro + // sería invisible. Tres casos (ver `crate::codigo::fondo_de_linea`): + // · Código → slate neutro (`bg_button`), el recuadro de un bloque. + // · Añadida → verde, · Quitada → rojo: claude colorea el marcador `+`/`-` + // del diff (no pinta fondo), y acá lo volvemos fondo — verde-vs-rojo, + // líneas nuevas vs muertas, como en cualquier diff. + let bg_de = |t: &llimphi_theme::Theme| { + use llimphi_ui::llimphi_raster::peniko::Color; + let slate = { let c = t.bg_button.to_rgba8(); Color::from_rgba8(c.r, c.g, c.b, 150) }; + let verde = Color::from_rgba8(0x50, 0xc8, 0x50, 64); + let rojo = Color::from_rgba8(0xdc, 0x5a, 0x5a, 64); + (slate, verde, rojo) + }; + let (codigo_bg, anadida_bg, quitada_bg) = bg_de(theme); + let line_style = move |idx: usize, _text: &str| match styles.get(idx) { + Some((is_err, runs)) => LineStyle { + fg: None, + runs: runs.clone(), + bg: if *is_err { + Some(err_bg) + } else { + match crate::codigo::fondo_de_linea(runs) { + crate::codigo::Fondo::Anadida => Some(anadida_bg), + crate::codigo::Fondo::Quitada => Some(quitada_bg), + crate::codigo::Fondo::Codigo => Some(codigo_bg), + crate::codigo::Fondo::Ninguno => None, + } + }, + }, + None => LineStyle::default(), + }; + + // La rueda y el arrastre de la barra del widget llegan aquí como delta a + // sumar a `scroll_y` (desde arriba); el shell lo guarda como `scroll_px` + // (desde el fondo), así que invertimos el signo. + let lift_scroll = (*lift).clone(); + let on_scroll = move |delta: f32| lift_scroll(Msg::Scroll(-delta)); + + use llimphi_widget_terminal::{ + block_surface_with_scroll, gutter_width, SelectionConfig, + }; + + // Snapshot del layout para que el `update` resuelva clicks contra la + // geometría real del frame anterior, sin re-armar los items. + let items_geo: Vec = + items.iter().map(|it| it.geo()).collect(); + let gw = gutter_width(&store, metrics); + if let Ok(mut g) = state.surf_layout.lock() { + *g = Some(crate::SurfLayout { + items_geo, + scroll_y, + viewport_h, + metrics, + gutter_w: gw, + store: std::sync::Arc::new(store.clone()), + block_ranges, + }); + } + + // Handler de drag de selección: forwardea cada `(phase, lx0, ly0, dx, dy)` + // del viewport al `update` como `Msg::SurfSelectDrag`. El `update` mantiene + // el acumulador y resuelve la posición a `Point` con `point_at_geo`. + let lift_drag = (*lift).clone(); + let on_drag = std::sync::Arc::new( + move |phase, lx0, ly0, dx, dy| -> Option { + Some(lift_drag(Msg::SurfSelectDrag { + phase, + dx, + dy, + ax: lx0, + ay: ly0, + })) + }, + ); + // Doble-click → select-word, paridad con terminales clásicas. + let lift_dbl = (*lift).clone(); + let on_double_click = std::sync::Arc::new( + move |lx, ly, rect_w, rect_h| -> Option { + Some(lift_dbl(Msg::SurfDoubleClick { + lx, + ly, + rect_w, + rect_h, + })) + }, + ); + let sel_cfg = SelectionConfig { + range: state.surf_selection.as_ref(), + on_drag: Some(on_drag), + on_double_click: Some(on_double_click), + // Caret readonly visible sólo en copy-mode (Ctrl+Shift+Espacio). + caret: state + .surf_copy_mode + .then(|| state.surf_selection.map(|s| s.head)) + .flatten(), + }; + + let surface = block_surface_with_scroll::( + &store, + items, + scroll_y, + state.surf_scroll_x.max(0.0), + viewport_h, + metrics, + &palette, + line_style, + on_scroll, + None, + sel_cfg, + ); + + // Nodo flex que toma el espacio sobrante (entre header e input) y mide su + // alto real para el próximo frame (el widget recibe un alto fijo = el + // medido; el painter de medición vive aquí, en el nodo flex-rellenado). + // El mismo painter pinta el **fondo vivo**: dos lóbulos radiales del + // accent a alpha bajísimo que derivan en Lissajous lento (~40 s de + // período). El chasis ya redibuja cada ~100 ms por el caret, así que el + // movimiento sale gratis. Opt-out: `SHUMA_FONDO_QUIETO=1`. + let slot = Arc::clone(&state.out_viewport_h); + let glow_accent = theme.accent; + let painter = move |scene: &mut vello::Scene, + _ts: &mut llimphi_ui::llimphi_text::Typesetter, + rect: llimphi_ui::PaintRect| { + if let Ok(mut g) = slot.lock() { + *g = rect.h; + } + if fondo_vivo_enabled() && rect.w > 1.0 && rect.h > 1.0 { + paint_fondo_vivo(scene, rect, glow_accent); + } + }; + let lift_menu = (*lift).clone(); + View::new(Style { + flex_direction: FlexDirection::Column, + size: Size { + width: percent(1.0_f32), + height: Dimension::auto(), + }, + flex_basis: length(0.0_f32), + flex_grow: 1.0, + min_size: Size { + width: Dimension::auto(), + height: length(0.0_f32), + }, + ..Default::default() + }) + .fill(theme.sunken()) + .radius(3.0) + // Borde de acento cuando copy-mode está activo (transparente si no) — la + // señal visual «estás en modo selección por teclado». + .border( + 2.0, + if state.surf_copy_mode { theme.accent } else { theme.accent.with_alpha(0.0) }, + ) + .clip(true) + .paint_with(painter) + // Botón medio: pega el cuasi-clipboard PRIMARY (lo último seleccionado), + // selección PRIMARY estilo X11, separada del Ctrl+V. + .on_middle_click(lift(Msg::PrimaryPaste)) + // Right-click sobre el contenedor de la surface abre el menú contextual. + // El hit-test innermost-wins le da prioridad a hijos con sus propios + // handlers (p. ej. la barra de find). + .on_right_click_at(move |x, y, _w, _h| Some(lift_menu(Msg::SurfOpenMenu { x, y }))) + .children({ + // Barra de find encima de la superficie, sólo si está abierta. Es + // focus-grabbing (la dispatch ya rutea las teclas a `handle_find_key`). + let mut kids: Vec> = Vec::new(); + if let Some(f) = &state.find { + kids.push(find_bar_view::(f, theme, lift)); + } + // Banner de copy-mode (arriba-centro), sólo si está activo. + if let Some(banner) = copy_mode_banner::(state, theme) { + kids.push(banner); + } + // Status del spill: chip que muestra "N líneas archivadas" cuando + // el history persistente ya recortó al disco. Sólo visible si + // spill está activo y hay contenido archivado. + if let Some(status) = spill_status_view::(state, theme) { + kids.push(status); + } + // Cursor I-beam sobre el cuerpo: señala que el texto es seleccionable + // (drag selecciona, doble-click la palabra, click derecho el menú). Las + // decoraciones clickeables (paths/URLs) que traen su propio cursor ganan + // por hit-test innermost-wins. + kids.push(surface.cursor(llimphi_ui::Cursor::Text)); + // El menú contextual va como overlay arriba de todo. + if let Some(menu) = surf_context_menu(state, theme, lift) { + kids.push(menu); + } + kids + }) +} + +/// Chip de status del spill del scrollback: "≡ N líneas archivadas en +/// ". Sólo aparece si `state.surf_history.spilled_count() > 0` +/// (es decir, el archivo de spill tiene contenido — la sesión llenó el +/// cap en memoria y siguió volcando a disco). `None` mientras esté vacío. +fn spill_status_view( + state: &State, + theme: &Theme, +) -> Option> { + let count = state.surf_history.lock().ok().map(|h| h.spilled_count()).unwrap_or(0); + if count == 0 { + return None; + } + Some( + View::new(Style { + size: Size { width: percent(1.0_f32), height: length(18.0_f32) }, + flex_shrink: 0.0, + align_items: Some(AlignItems::Center), + padding: Rect { + left: length(8.0_f32), + right: length(8.0_f32), + top: length(2.0_f32), + bottom: length(2.0_f32), + }, + ..Default::default() + }) + .fill(theme.bg_panel) + .text_aligned( + format!("≡ {count} líneas archivadas en spill"), + 10.0, + theme.fg_muted, + Alignment::Start, + ) + .mono(), + ) +} + +/// Banner de **copy-mode**: píldora flotante arriba-centro que indica que el +/// panel está en modo selección por teclado y recuerda cómo salir. Es la +/// contraparte visible del borde de acento del contenedor. `None` si el panel no +/// está en copy-mode. Se agrega como overlay a la superficie de output. +fn copy_mode_banner( + state: &State, + theme: &Theme, +) -> Option> { + use llimphi_ui::llimphi_layout::taffy::JustifyContent; + if !state.surf_copy_mode { + return None; + } + let visual = state.surf_copy_visual; + let etiqueta = if visual { + "◆ COPY-MODE · visual · ↵ copia · ESC sale".to_string() + } else { + "◆ COPY-MODE · ⇧/v selecciona · ↵ copia · ESC sale".to_string() + }; + let pill = View::new(Style { + flex_shrink: 0.0, + align_items: Some(AlignItems::Center), + padding: Rect { + left: length(10.0_f32), + right: length(10.0_f32), + top: length(3.0_f32), + bottom: length(3.0_f32), + }, + ..Default::default() + }) + .fill(theme.accent) + .radius(10.0) + .text_aligned(etiqueta, 10.5, theme.bg_panel, Alignment::Center) + .mono(); + // Overlay full-cover absoluto: la píldora se ancla arriba (align Start) y + // centrada (justify Center), sin depender del ancho del panel. Cover completo + // para no pelear con el tipo de `inset` (todo `length(0)`). + Some( + View::new(Style { + position: Position::Absolute, + inset: Rect { + left: length(0.0_f32), + right: length(0.0_f32), + top: length(6.0_f32), + bottom: length(0.0_f32), + }, + justify_content: Some(JustifyContent::Center), + align_items: Some(AlignItems::Start), + ..Default::default() + }) + .children(vec![pill]), + ) +} + +/// Menú contextual del surface (click derecho): Copiar selección · Copiar +/// todo · Seleccionar todo. `None` si no está abierto. +pub(crate) fn surf_context_menu( + state: &State, + theme: &Theme, + lift: &(impl Fn(Msg) -> HostMsg + Clone + Send + Sync + 'static), +) -> Option> { + use llimphi_widget_context_menu::{ + context_menu_view, ContextMenuItem, ContextMenuPalette, ContextMenuSpec, + }; + let (x, y) = state.surf_menu?; + // Fuente única: los mismos items (en el mismo orden) que indexa + // `apply_surf_menu_pick`. Los condicionales (Ejecutar/Ejecutar en nuevo tab) + // sólo aparecen con selección — no hace falta deshabilitarlos. + let hay_sel = state.surf_selection.as_ref().is_some_and(|s| !s.is_empty()); + let items: Vec = crate::update::surf_menu_actions(state) + .into_iter() + .map(|a| { + use crate::update::SurfMenuAction::*; + match a { + Copiar => { + let it = ContextMenuItem::action("Copiar").with_shortcut("Ctrl+Shift+C"); + if hay_sel { it } else { it.disabled() } + } + Ejecutar => ContextMenuItem::action("Ejecutar"), + EjecutarNuevoTab => ContextMenuItem::action("Ejecutar en nuevo tab"), + Pegar => ContextMenuItem::action("Pegar").with_shortcut("botón medio"), + CopiarTodo => ContextMenuItem::action("Copiar todo"), + SeleccionarTodo => ContextMenuItem::action("Seleccionar todo"), + } + }) + .collect(); + let lift_pick = lift.clone(); + let menu = context_menu_view(ContextMenuSpec { + anchor: (x, y), + viewport: (1280.0, 800.0), + header: None, + items, + active: usize::MAX, + on_pick: std::sync::Arc::new(move |i| lift_pick(Msg::SurfMenuPick(i))), + on_dismiss: lift(Msg::SurfMenuDismiss), + palette: ContextMenuPalette::from_theme(theme), + }); + Some( + View::new(Style { + position: Position::Absolute, + inset: Rect { + left: length(0.0_f32), + top: length(0.0_f32), + right: length(0.0_f32), + bottom: length(0.0_f32), + }, + size: Size { + width: percent(1.0_f32), + height: percent(1.0_f32), + }, + ..Default::default() + }) + .children(vec![menu]), + ) +} + +/// Barra de búsqueda Ctrl+F: lupa + query (cursor) + contador `M/N` + chip +/// `Aa` (toggle case) + flechas + ✕. Compacta, encima de la superficie de +/// output. Los clics emiten los `Msg::Find*` ya cableados. +pub(crate) fn find_bar_view( + f: &crate::FindState, + theme: &Theme, + lift: &(impl Fn(Msg) -> HostMsg + Clone + Send + Sync + 'static), +) -> View { + let lup = View::new(Style { + size: Size { width: length(14.0_f32), height: length(14.0_f32) }, + flex_shrink: 0.0, + ..Default::default() + }) + .children(vec![llimphi_icons::icon_view( + llimphi_icons::Icon::Search, + theme.fg_muted, + 1.6, + )]); + + // Query con cursor titilante simulado por sufijo "▏" — paridad simple + // con el cabezal del shell sin meter blink (innecesario en una barra). + let mut shown = f.query.clone(); + shown.push('▏'); + let query_view = View::new(Style { + flex_grow: 1.0, + size: Size { width: Dimension::auto(), height: length(20.0_f32) }, + ..Default::default() + }) + .text_aligned(shown, 13.0, theme.fg_text, Alignment::Start) + .mono(); + + // Contador `M/N`. Sin matches: "0/0" muted, sin destacar. + let total = f.matches.len(); + let cur = f.current.map(|i| i + 1).unwrap_or(0); + let counter_color = if total == 0 && !f.query.is_empty() { + theme.fg_destructive + } else { + theme.fg_muted + }; + let counter = View::new(Style { + size: Size { width: length(54.0_f32), height: length(20.0_f32) }, + flex_shrink: 0.0, + ..Default::default() + }) + .text_aligned(format!("{cur}/{total}"), 11.0, counter_color, Alignment::End) + .mono(); + + let case_chip = { + let (fill, fg) = if f.case_insensitive { + (theme.accent, theme.bg_panel) + } else { + (theme.bg_input, theme.fg_muted) + }; + View::new(Style { + size: Size { width: length(24.0_f32), height: length(20.0_f32) }, + flex_shrink: 0.0, + ..Default::default() + }) + .fill(fill) + .radius(3.0) + .hover_fill(theme.bg_row_hover) + .on_click(lift(Msg::FindToggleCase)) + .text_aligned("Aa".to_string(), 11.0, fg, Alignment::Center) + .mono() + }; + + let arrow = |icon, msg: Msg| { + View::new(Style { + size: Size { width: length(20.0_f32), height: length(20.0_f32) }, + flex_shrink: 0.0, + ..Default::default() + }) + .fill(theme.bg_input) + .radius(3.0) + .hover_fill(theme.bg_row_hover) + .on_click(lift(msg)) + .children(vec![llimphi_icons::icon_view(icon, theme.fg_muted, 1.6)]) + }; + let prev_btn = arrow(llimphi_icons::Icon::ChevronUp, Msg::FindPrev); + let next_btn = arrow(llimphi_icons::Icon::ChevronDown, Msg::FindNext); + let close_btn = View::new(Style { + size: Size { width: length(20.0_f32), height: length(20.0_f32) }, + flex_shrink: 0.0, + ..Default::default() + }) + .fill(theme.bg_input) + .radius(3.0) + .hover_fill(theme.bg_row_hover) + .on_click(lift(Msg::FindClose)) + .children(vec![llimphi_icons::icon_view( + llimphi_icons::Icon::X, + theme.fg_muted, + 1.6, + )]); + + View::new(Style { + flex_direction: FlexDirection::Row, + size: Size { width: percent(1.0_f32), height: length(28.0_f32) }, + flex_shrink: 0.0, + align_items: Some(AlignItems::Center), + gap: Size { width: length(6.0_f32), height: length(0.0_f32) }, + padding: Rect { + left: length(8.0_f32), + right: length(8.0_f32), + top: length(4.0_f32), + bottom: length(4.0_f32), + }, + ..Default::default() + }) + .fill(theme.bg_panel) + .children(vec![lup, query_view, counter, case_chip, prev_btn, next_btn, close_btn]) +} + +#[cfg(test)] +mod baked_image_tests { + use super::*; + + fn metrics() -> llimphi_widget_terminal::TermMetrics { + llimphi_widget_terminal::TermMetrics { + font_size: 12.0, + line_height: 16.0, + char_width: 7.2, + } + } + + fn img(cols: u16, rows: u16, px_w: u32, px_h: u32) -> crate::types::TermImage { + crate::types::TermImage { + image: llimphi_image::from_rgba8(vec![0u8; 4], 1, 1), + col: 0, + row: 0, + cols, + rows, + px_w, + px_h, + } + } + + /// Con celdas pedidas (kitty c=/r=), el tamaño es exactamente celdas × + /// métrica de celda; el alto incluye el padding inferior. + #[test] + fn tamano_por_celdas() { + let m = metrics(); + let (w, h) = baked_image_size(&img(10, 4, 100, 40), m); + assert!((w - 10.0 * m.char_width).abs() < 0.01, "ancho {w}"); + assert!((h - (4.0 * m.line_height + IMAGE_PAD)).abs() < 0.01, "alto {h}"); + } + + /// Sin celdas, se encaja por píxeles a un ancho máximo preservando el + /// aspecto (alto = ancho × aspecto + padding). + #[test] + fn tamano_por_pixeles_preserva_aspecto() { + let m = metrics(); + let (w, h) = baked_image_size(&img(0, 0, 200, 100), m); + let aspect = 100.0 / 200.0_f32; + assert!(((h - IMAGE_PAD) - w * aspect).abs() < 0.5, "w={w} h={h}"); + } + + /// Imágenes muy anchas se capan al ancho máximo (72 celdas), no crecen sin + /// límite. + #[test] + fn ancho_capado() { + let m = metrics(); + let (w, _) = baked_image_size(&img(0, 0, 100_000, 100), m); + assert!(w <= 72.0 * m.char_width + 0.01, "ancho capado: {w}"); + } +} + +#[cfg(test)] +mod tests_color_consola { + use super::{cortes_normalizados, indexar_tramos, reserva_ancho_px, sangria_cols}; + use llimphi_ui::llimphi_raster::peniko::Color; + + const ROJO: Color = Color::from_rgba8(255, 0, 0, 255); + + /// La línea MÁS ancha que la consola puede producir —una línea que el + /// programa llenó hasta la última columna declarada, dentro del panel más + /// anidado— tiene que caber en el panel. Si no cabe, se lee cortada por la + /// derecha: es exactamente lo que pasaba con la reserva puesta a ojo. + /// La línea MÁS ancha que la consola puede producir —una que el programa + /// llenó hasta la última columna declarada, dentro del panel más anidado— + /// tiene que caber. **Con cualquier zoom**: el zoom arranca en 1.15, y dar + /// por sentado 1.0 fue el bug que cortaba las líneas largas por la derecha. + #[test] + fn la_linea_mas_anidada_y_mas_larga_entra_en_el_panel() { + use crate::update::run_exec::{ancho_celda_px, pty_dims}; + for zoom in [0.5_f32, 1.0, 1.15, 1.5, 2.0, 3.0] { + // Ancho de celda REAL con el que la vista pinta. + let char_w = ancho_celda_px(zoom); + // Lo que come el chrome, contado aparte de `reserva_ancho_px` (si + // se derivara de ella el test no probaría nada). Gutter de 3 + // dígitos: el caso corriente, y el más exigente contra una reserva + // que presupone 5. + let gutter = char_w * 3.0 + 10.0 + llimphi_widget_terminal::TEXT_LEFT_PADDING_PX; + let barra = 10.0; + let sangria = char_w * sangria_cols(usize::MAX) as f32; + for ancho_panel in [640.0_f32, 1280.0, 1920.0, 3440.0] { + let declarado = (ancho_panel - reserva_ancho_px(zoom)).max(1.0); + let (_, cols) = pty_dims(declarado, 600.0, zoom).expect("dims"); + // `pty_dims` tiene un PISO de 20 columnas: por debajo de eso + // una terminal no sirve para nada, así que se prefiere el + // recorte a un tamaño inusable. Con zoom grande en un panel + // chico ese piso gana y la línea se corta — es una decisión, + // no un descuido, y no es lo que este test vigila. + if (declarado / char_w).floor() < 20.0 { + continue; + } + let pintado = gutter + barra + sangria + cols as f32 * char_w; + assert!( + pintado <= ancho_panel + 0.01, + "línea cortada: zoom={zoom} panel={ancho_panel} \ + pintado={pintado:.1} cols={cols}" + ); + } + } + } + + /// La reserva no depende de cuántas líneas lleva el scrollback: si variara, + /// el PTY se redimensionaría solo y claude re-renderizaría todo. Sí depende + /// del zoom — eso es correcto, y por eso cambiar el zoom va a reflowear. + #[test] + fn la_reserva_es_estable_salvo_por_el_zoom() { + assert_eq!(reserva_ancho_px(1.15), reserva_ancho_px(1.15)); + assert!( + reserva_ancho_px(1.15) > reserva_ancho_px(1.0), + "con más zoom la celda es más ancha: la reserva tiene que crecer" + ); + assert!( + reserva_ancho_px(1.15) > 100.0, + "la reserva vieja (60) no cubría ni el gutter ni la barra" + ); + } + + #[test] + fn indexa_la_fila_cruda_y_sus_variantes_recortadas() { + // Tal como llega del terminal: sangrada y con viñeta. + let texto = " ● Bash(ls)"; + let cortes = cortes_normalizados(texto); + let mut mapa = std::collections::HashMap::new(); + // Un tramo rojo sobre "Bash(ls)": el '●' ocupa 3 bytes, así que el + // texto arranca en el 6 y termina en el 14. + let tramos = vec![(6usize, 14usize, ROJO)]; + for c in cortes { + indexar_tramos(&mut mapa, texto, c, &tramos); + } + // El detector guarda la línea recortada: se la tiene que encontrar. + let hallado = mapa.get("Bash(ls)").expect("la variante sin viñeta se indexó"); + assert_eq!(hallado, &vec![(0usize, 8usize, ROJO)], "rebasada al nuevo cero"); + // Y la cruda sigue estando, con sus offsets originales. + assert_eq!(mapa.get(texto), Some(&vec![(6usize, 14usize, ROJO)])); + } + + #[test] + fn no_indexa_lo_que_queda_fuera_del_corte() { + let texto = " ● hola"; + // Tramo que vive ENTERO en el sangrado: no debe viajar a la variante. + let tramos = vec![(0usize, 2usize, ROJO)]; + let mut mapa = std::collections::HashMap::new(); + indexar_tramos(&mut mapa, texto, 6, &tramos); // 6 = donde empieza "hola" + assert!(mapa.is_empty(), "un tramo anterior al corte no genera clave"); + } + + #[test] + fn un_corte_que_parte_un_caracter_no_entra() { + let texto = "●x"; // '●' ocupa 3 bytes + let mut mapa = std::collections::HashMap::new(); + indexar_tramos(&mut mapa, texto, 1, &[(0, 4, ROJO)]); + assert!(mapa.is_empty(), "offset a mitad de carácter se descarta"); + } +} diff --git a/02_ruway/shuma/sandbox/shuma-module-shell/src/view/tui.rs b/02_ruway/shuma/sandbox/shuma-module-shell/src/view/tui.rs new file mode 100644 index 0000000..46d7d49 --- /dev/null +++ b/02_ruway/shuma/sandbox/shuma-module-shell/src/view/tui.rs @@ -0,0 +1,1120 @@ +use super::*; + +/// Geometría del card de vim — compartida entre el painter (resaltado) +/// y `copy_vim_selection` (px → celda) para que las celdas coincidan. +/// `VIM_PAD` es fijo (margen del panel); el avance horizontal y el alto +/// de línea son *fallbacks* — los reales los mide el painter sobre el +/// layout de parley y los publica en `State::vim_metrics`. +pub(crate) const VIM_PAD: f64 = 10.0; +pub(crate) const VIM_LINE_H: f64 = 16.0; +pub(crate) const VIM_CHAR_W: f64 = 7.8; +pub(crate) const VIM_FONT_PX: f32 = 13.0; + +/// Coordenadas locales (px, relativas al rect del panel) → celda (fila, +/// col), con las métricas reales del monospace (`char_w`, `line_h`). +pub(crate) fn vim_px_to_cell(x: f64, y: f64, char_w: f64, line_h: f64) -> (usize, usize) { + let col = (((x - VIM_PAD) / char_w).floor()).max(0.0) as usize; + let row = (((y - VIM_PAD) / line_h).floor()).max(0.0) as usize; + (row, col) +} + +/// Snapshot copiable del Screen para enviar a una closure `paint_with`. +pub(crate) struct TuiSnapshot { + pub(crate) cells: Vec>, + pub(crate) rows: u16, + pub(crate) cols: u16, + pub(crate) cursor_r: u16, + pub(crate) cursor_c: u16, + pub(crate) hide_cursor: bool, + /// Imágenes (kitty/sixel) vivas, ancladas a su celda. Las pinta el painter + /// por encima del grid de texto. + pub(crate) images: Vec, +} + +#[derive(Clone)] +pub(crate) struct TuiCell { + pub(crate) ch: String, + pub(crate) fg: vt100::Color, + pub(crate) bg: vt100::Color, +} + +/// Copia el screen actual de un `ActiveRun` PTY a un snapshot +/// `Send`-able. Devuelve `None` si el run no es TUI. +pub(crate) fn capture_tui(active: &std::sync::MutexGuard<'_, ActiveRun>) -> Option { + let tui = active.tui.as_ref()?; + let screen = tui.parser.screen(); + let (rows, cols) = screen.size(); + let mut cells: Vec> = Vec::with_capacity(rows as usize); + for r in 0..rows { + let mut row: Vec = Vec::with_capacity(cols as usize); + for c in 0..cols { + let (ch, fg, bg) = match screen.cell(r, c) { + Some(cell) => ( + if cell.has_contents() { + cell.contents().to_string() + } else { + " ".to_string() + }, + cell.fgcolor(), + cell.bgcolor(), + ), + None => (" ".into(), vt100::Color::Default, vt100::Color::Default), + }; + row.push(TuiCell { ch, fg, bg }); + } + cells.push(row); + } + let (cursor_r, cursor_c) = screen.cursor_position(); + Some(TuiSnapshot { + cells, + rows, + cols, + cursor_r, + cursor_c, + hide_cursor: screen.hide_cursor(), + images: tui.images.clone(), + }) +} + +/// Fila donde empieza el INPUT BOX de claude (reglas ─── + ❯ + «manual mode»), +/// buscando desde el fondo del contenido hacia arriba. `None` si no se detecta. +/// El input vive en la barra de shuma (modo consola), así que el grid lo +/// recorta: un solo streaming sin la caja que «no se llena». +fn input_box_top(snap: &TuiSnapshot) -> Option { + let fila_txt = |r: u16| -> String { + snap.cells + .get(r as usize) + .map(|row| row.iter().map(|c| c.ch.as_str()).collect::()) + .unwrap_or_default() + }; + // Última fila con contenido. + let mut ultima = None; + for r in (0..snap.rows).rev() { + if !fila_txt(r).trim().is_empty() { + ultima = Some(r); + break; + } + } + let ultima = ultima?; + // Desde ahí, sube por las filas del chrome del input (reglas, ❯, manual + // mode, hints, vacías) hasta que aparezca contenido real. Marca la última + // regla ─── como el tope. + let es_regla = |t: &str| { + let t = t.trim(); + t.chars().count() >= 3 && t.chars().all(|c| "─━═╌ ".contains(c)) + }; + let es_chrome_input = |t: &str| { + let t = t.trim(); + es_regla(t) + || t == "❯" + || t == ">" + || t.starts_with('⏸') + || t.contains("manual mode") + || t.contains("? for shortcuts") + || t.contains("esc to interrupt") + || t.is_empty() + }; + let mut top = None; + let mut r = ultima as i32; + // Sólo buscamos en las últimas ~8 filas (el input box no es más alto). + let limite = (ultima as i32 - 8).max(0); + while r >= limite { + let t = fila_txt(r as u16); + if es_chrome_input(&t) { + if es_regla(&t) { + top = Some(r as u16); + } + r -= 1; + } else { + break; + } + } + top +} + +/// Snapshot RECORTADO: el grid vivo menos el input box (y las filas vacías de +/// abajo). Es «cómete todo el TUI menos el input». +pub(crate) fn capture_tui_recortado( + active: &std::sync::MutexGuard<'_, ActiveRun>, +) -> Option { + let mut snap = capture_tui(active)?; + let corte = input_box_top(&snap).unwrap_or(snap.rows); + // Recortar filas vacías por encima del input box también. + let mut fin = corte; + while fin > 0 { + let vacia = snap.cells.get(fin as usize - 1) + .map(|row| row.iter().all(|c| c.ch.trim().is_empty())) + .unwrap_or(true); + if vacia { fin -= 1; } else { break; } + } + snap.cells.truncate(fin as usize); + snap.rows = fin; + // El cursor del input no aplica (input en la barra): ocultarlo. + snap.hide_cursor = true; + Some(snap) +} + +/// Panel de TUI app-aware: según el programa bajo el PTY elige un skin. +/// `is_tui_fullscreen(state)` ya garantiza que hay un PTY en alt-screen. +/// vim se pinta como un card themeable; el resto cae al grid vt100 crudo. +pub(crate) fn tui_panel( + state: &State, + theme: &Theme, + lift: impl Fn(Msg) -> HostMsg + Clone + Send + Sync + 'static, +) -> View { + // Snapshot + skin en un solo lock; la closure de paint debe ser + // `Send + Sync`, así que no captura el Mutex. + // try_lock por la misma razón que `is_tui_active`: si el lector del PTY + // está dentro del mutex en este instante, devolvemos snapshot vacío y el + // panel cae al frame anterior — preferible a pasmar la pantalla. + let (snapshot, skin) = match state.running.as_ref().and_then(|arc| arc.try_lock().ok()) { + Some(g) => { + let skin = g.tui.as_ref().map(|t| t.skin).unwrap_or(AppSkin::Generic); + (capture_tui(&g), skin) + } + None => (None, AppSkin::Generic), + }; + let rect_slot = Arc::clone(&state.last_tui_rect); + if let AppSkin::Vim = skin { + let metrics_slot = Arc::clone(&state.vim_metrics); + return vim_panel::( + snapshot, + theme, + rect_slot, + metrics_slot, + state.vim_sel, + lift, + ); + } + generic_grid_panel::( + snapshot, + theme, + rect_slot, + Arc::clone(&state.gpu_grid), + None, + false, + lift, + ) +} + +/// Panel del PTY INLINE (claude): el grid vivo RECORTADO — todo el TUI menos +/// el input box (que vive en la barra de shuma, modo consola). Un solo +/// streaming; cuadra celda por celda (a diferencia del flujo de texto). Si +/// claude borra una línea, se borra aquí; efímeros y logs conviven. +/// Alto de fila del grid inline (px). ~font 13 mono. +const ROW_H_GRID: f32 = 17.0; + +pub(crate) fn tui_inline_panel( + state: &State, + theme: &Theme, + lift: impl Fn(Msg) -> HostMsg + Clone + Send + Sync + 'static, + // `Some(n)` = vista consola: pintar sólo las últimas `n` filas con + // contenido (la cola). `None` = grid recortado completo (histórico). + max_filas: Option, +) -> View { + let snapshot = state + .running + .as_ref() + .and_then(|arc| arc.try_lock().ok()) + .and_then(|g| capture_tui_recortado(&g)) + .map(|mut snap| { + if let Some(n) = max_filas { + if snap.rows > n { + let sobra = (snap.rows - n) as usize; + snap.cells.drain(..sobra); + snap.rows = n; + } + } + snap + }); + let Some(snap) = snapshot else { + return View::new(Style { + size: Size { width: percent(1.0_f32), height: length(ROW_H_GRID) }, + ..Default::default() + }); + }; + // COLA de la vista consola (`max_filas` puesto): mini-grid PELADO, sin + // segmentación ni headers — el desplanizado vive en la surface de arriba; + // aquí sólo la presentación viva (spinner, menús, input feedback). Un + // header «1. …» aquí partía la vista en un falso split (feedback 17-jul: + // "el vt100 abajo dividido por un panel 1"). + if max_filas.is_some() { + return generic_grid_panel::( + Some(snap), + theme, + Arc::clone(&state.last_tui_rect), + Arc::clone(&state.gpu_grid), + Some(ROW_H_GRID), + // Cola de la vista consola: se funde con la surface de arriba (sin + // caja propia) → una sola terminal, no un doble panel. + true, + lift, + ); + } + paneles_de_snapshot::(&snap, state, theme, lift) +} + +/// Segmenta un snapshot (grid vivo o BUFFER COMPLETO) en PANELES colapsables por +/// los marcadores de claude — cada tramo un mini-grid que conserva el cuadre. El +/// último panel es lo VIVO (expandido por default). Es el render compartido por +/// la vista consola de claude (buffer completo) y el PTY inline genérico. +pub(crate) fn paneles_de_snapshot( + snap: &TuiSnapshot, + state: &State, + theme: &Theme, + lift: impl Fn(Msg) -> HostMsg + Clone + Send + Sync + 'static, +) -> View { + // Segmentar el grid en PANELES por los marcadores de claude (plugin + // por-skin), sin romper el cuadre: cada panel es un mini-grid de sus filas. + let segs = segmentar_grid_claude(snap); + // Block sintético estable para el estado de plegado del PTY vivo (no + // colisiona con blocks reales de comandos). + const BLOCK_TUI: u64 = u64::MAX - 7; + let mut hijos: Vec> = Vec::with_capacity(segs.len() * 2); + let ultimo = segs.len().saturating_sub(1); + for (idx, seg) in segs.iter().enumerate() { + // El ÚLTIMO panel es lo VIVO (donde claude espera input o pinta la + // respuesta en curso): SIEMPRE expandido por default — si no, el + // usuario responde «Yes» a un menú que no ve. Los turnos anteriores se + // pliegan por importancia. El override del usuario (click) manda. + let default_col = if idx == ultimo { + false + } else { + crate::view::section_default_collapsed(&seg.titulo) + }; + let user_toggled = state.section_collapsed.contains(&(BLOCK_TUI, idx)); + let col = default_col ^ user_toggled; + let nfilas = (seg.r1 - seg.r0) as usize; + hijos.push(crate::view::section_header::( + BLOCK_TUI, idx, &seg.titulo, nfilas, col, 0, "filas", theme, &lift, + )); + if !col { + // Mini-grid: un snapshot recortado a [r0, r1). + let mut sub = TuiSnapshot { + cells: snap.cells[seg.r0 as usize..seg.r1 as usize].to_vec(), + rows: seg.r1 - seg.r0, + cols: snap.cols, + cursor_r: 0, + cursor_c: 0, + hide_cursor: true, + images: Vec::new(), + }; + // Recortar columnas vacías a la derecha para que el panel no sea + // más ancho que su contenido. + while sub.cols > 1 + && sub.cells.iter().all(|row| { + row.get(sub.cols as usize - 1).map(|c| c.ch.trim().is_empty()).unwrap_or(true) + }) + { + sub.cols -= 1; + } + hijos.push(generic_grid_panel::( + Some(sub), + theme, + Arc::clone(&state.last_tui_rect), + Arc::clone(&state.gpu_grid), + Some(ROW_H_GRID), + false, + lift.clone(), + )); + } + } + View::new(Style { + flex_direction: FlexDirection::Column, + size: Size { width: percent(1.0_f32), height: Dimension::auto() }, + gap: Size { width: length(0.0_f32), height: length(2.0_f32) }, + ..Default::default() + }) + .children(hijos) +} + +/// Captura el BUFFER COMPLETO del terminal para la vista consola de claude: el +/// **scrollback** (lo permanente que scrolleó fuera de la pantalla — el propio +/// terminal ya resolvió los sobrescritos in-place) MÁS la **pantalla viva** +/// recortada del input box (lo dinámico: respuesta en curso + spinner, que se +/// re-renderizan in-place cada frame). Es la fuente ÚNICA de los paneles — sin +/// cosecha ni heurísticas de estabilidad (que sellaban lo temporal como +/// permanente = la basura repetida). `None` si no hay TUI o está en alt-screen. +/// Requiere `&mut` (mueve la vista del scrollback y la restaura a 0). +pub(crate) fn capture_tui_completo( + active: &mut std::sync::MutexGuard<'_, ActiveRun>, +) -> Option { + // Tope de filas de scrollback leídas por frame. El render virtualiza (sólo + // pinta lo visible), así que el techo lo pone el costo de capturar + + // `detect_claude` por frame, no el pintado. 400 da bastante historia + // navegable; si el CPU pesa, cachear la captura por cambio de buffer. + const MAX_SCROLLBACK: usize = 400; + let tui = active.tui.as_mut()?; + if tui.parser.screen().alternate_screen() { + return None; + } + let (rows_vis, cols) = (tui.rows, tui.cols); + let fila = |screen: &vt100::Screen, r: u16| -> Vec { + (0..cols) + .map(|c| match screen.cell(r, c) { + Some(cell) => TuiCell { + ch: if cell.has_contents() { + cell.contents().to_string() + } else { + " ".to_string() + }, + fg: cell.fgcolor(), + bg: cell.bgcolor(), + }, + None => TuiCell { + ch: " ".to_string(), + fg: vt100::Color::Default, + bg: vt100::Color::Default, + }, + }) + .collect() + }; + let screen = tui.parser.screen_mut(); + // 1. Scrollback (permanente). `set_scrollback(usize::MAX)` clampa al total. + screen.set_scrollback(usize::MAX); + let total = screen.scrollback(); + let leer = total.min(MAX_SCROLLBACK); + let saltar = total - leer; + let _ = saltar; + let mut cells: Vec> = Vec::new(); + let mut pendientes = leer; + while pendientes > 0 { + // offset = pendientes: la fila 0 de la vista es la más vieja de las + // `pendientes` que faltan leer (misma semántica que `cosechar`). Con + // pendientes arrancando en `leer` (no en `total`), leemos las ÚLTIMAS + // `leer` filas del scrollback — las recientes, no las más viejas. (El + // `+saltar` anterior leía las MÁS VIEJAS y se saltaba las recientes.) + screen.set_scrollback(pendientes); + let tanda = pendientes.min(rows_vis as usize); + for r in 0..tanda as u16 { + cells.push(fila(screen, r)); + } + pendientes -= tanda; + } + // 2. Pantalla viva (offset 0), recortada del input box. + screen.set_scrollback(0); + let mut vivas: Vec> = (0..rows_vis).map(|r| fila(screen, r)).collect(); + let snap_vis = TuiSnapshot { + cells: vivas.clone(), + rows: rows_vis, + cols, + cursor_r: 0, + cursor_c: 0, + hide_cursor: true, + images: Vec::new(), + }; + let corte = input_box_top(&snap_vis).unwrap_or(rows_vis); + let mut fin = corte; + while fin > 0 + && vivas + .get(fin as usize - 1) + .map(|row| row.iter().all(|c| c.ch.trim().is_empty())) + .unwrap_or(true) + { + fin -= 1; + } + vivas.truncate(fin as usize); + cells.extend(vivas); + // La línea marcada con `➜` es la respuesta sugerida: se ofrece como ghost en + // la barra, así que fuera del panel — si no, se lee dos veces. + cells.retain(|row| { + let texto: String = row.iter().map(|c| c.ch.as_str()).collect(); + !texto + .trim() + .starts_with(crate::update::pty::MARCA_SUGERENCIA) + }); + // Recortar filas totalmente vacías del final del buffer. + while cells + .last() + .map(|row| row.iter().all(|c| c.ch.trim().is_empty())) + .unwrap_or(false) + { + cells.pop(); + } + let rows = cells.len() as u16; + if rows == 0 { + return None; + } + Some(TuiSnapshot { + cells, + rows, + cols, + cursor_r: 0, + cursor_c: 0, + hide_cursor: true, + images: Vec::new(), + }) +} + +/// Un segmento del grid = un panel: rango de filas [r0, r1) + su título. +struct SegGrid { + titulo: String, + r0: u16, + r1: u16, +} + +/// Segmenta las filas del grid de claude en paneles (plugin por-skin). Detecta +/// los marcadores sobre el TEXTO de cada fila: banner de bienvenida al inicio, +/// cada `●` un mensaje/herramienta. El cuadre se conserva porque cada panel +/// renderiza sus filas del grid tal cual. +fn segmentar_grid_claude(snap: &TuiSnapshot) -> Vec { + let texto = |r: u16| -> String { + snap.cells.get(r as usize) + .map(|row| row.iter().map(|c| c.ch.as_str()).collect::().trim_end().to_string()) + .unwrap_or_default() + }; + // ¿La fila `r` es un TURNO del usuario? claude lo pinta con un fondo + // RESALTADO (bg ≠ Default en las celdas con texto) y/o con prefijo ❯/>. + // El turno es EL divisor de las secciones grandes (un turno = input + + // toda la respuesta hasta el próximo input). + let es_turno = |r: u16| -> bool { + let Some(row) = snap.cells.get(r as usize) else { return false }; + let t = texto(r); + let ts = t.trim_start(); + if ts.starts_with('❯') || ts.starts_with('>') { + return true; + } + // Mayoría de las celdas con texto tienen bg resaltado. + let con_texto: Vec<_> = row.iter().filter(|c| !c.ch.trim().is_empty()).collect(); + if con_texto.len() < 2 { + return false; + } + let resaltadas = con_texto + .iter() + .filter(|c| !matches!(c.bg, vt100::Color::Default)) + .count(); + resaltadas * 2 >= con_texto.len() + }; + let rows = snap.rows; + let mut segs: Vec = Vec::new(); + let mut i = 0u16; + // Banner de bienvenida: hasta la primera INTERACCIÓN — el primer turno del + // usuario (❯/resaltado) o el primer ●, lo que venga antes. Sin el chequeo + // del turno, el ❯ del usuario (que precede al primer ● de la respuesta) + // quedaba DENTRO de la bienvenida. + let mut fin_banner = 0u16; + while fin_banner < rows { + let ts = texto(fin_banner); + let ts = ts.trim_start(); + if ts.starts_with('●') || ts.starts_with('⏺') || es_turno(fin_banner) { + break; + } + fin_banner += 1; + } + if fin_banner > 0 { + // Recortar vacías del final del banner. + let mut f = fin_banner; + while f > 0 && texto(f - 1).trim().is_empty() { f -= 1; } + if f > 0 { + segs.push(SegGrid { titulo: "▚ Bienvenida de Claude".into(), r0: 0, r1: f }); + } + i = fin_banner; + } + // Cada TURNO del usuario abre un panel CONTENEDOR que llega hasta el + // PRÓXIMO turno: el input + toda la respuesta de claude (mensajes ●, + // tools) viven DENTRO, no en paneles hermanos. La cabecera es el input. + while i < rows { + if es_turno(i) && !texto(i).trim().is_empty() { + let r0 = i; + let ts = texto(i); + let msg = ts.trim_start().trim_start_matches(['❯', '>']).trim().to_string(); + let corto: String = msg.chars().take(56).collect(); + let titulo = format!("❯ {corto}{}", if msg.chars().count() > 56 { "…" } else { "" }); + i += 1; + // Extender hasta el próximo turno (o el fin). + while i < rows && !(es_turno(i) && !texto(i).trim().is_empty()) { + i += 1; + } + let mut r1 = i; + while r1 > r0 + 1 && texto(r1 - 1).trim().is_empty() { r1 -= 1; } + segs.push(SegGrid { titulo, r0, r1 }); + } else { + // Contenido sin turno previo (arranque suelto): un panel neutro. + let r0 = i; + i += 1; + while i < rows && !(es_turno(i) && !texto(i).trim().is_empty()) { + i += 1; + } + let mut r1 = i; + while r1 > r0 + 1 && texto(r1 - 1).trim().is_empty() { r1 -= 1; } + if r1 > r0 { + let prim = texto(r0); + let c: String = prim.trim().chars().take(50).collect(); + segs.push(SegGrid { titulo: format!("▸ {c}"), r0, r1 }); + } + } + } + segs +} + +/// Render de grilla vt100 cruda — el camino histórico para htop/less/man. +/// +/// El panel acepta clicks y rueda para programas que habilitaron mouse +/// (htop, btop, less, fzf, …): los handlers emiten `TuiMouseClick` / +/// `TuiMouseWheel` que el `update` convierte en bytes xterm-mouse contra +/// el `mouse_protocol_mode` actual del `vt100::Screen` (no-op si el +/// programa no lo pidió). +pub(crate) fn generic_grid_panel( + snapshot: Option, + theme: &Theme, + rect_slot: Arc>, + gpu_grid: Arc>>, + // `Some(h)` = alto de fila FIJO (modo inline: el panel mide rows·h, no + // estira las filas para llenar). `None` = estira al rect (alt-screen). + alto_fila: Option, + // `true` = el panel se FUNDE con una surface hundida por encima (cola de la + // vista consola de claude): sin caja propia (fondo transparente, sin radio) + // para que el wrapper hundido de `consola_o_inline` se lea a través — una + // sola terminal, no un doble panel. `false` = caja opaca propia (alt-screen + // fullscreen y mini-grids del histórico segmentado). + fondo_hundido: bool, + lift: impl Fn(Msg) -> HostMsg + Clone + Send + Sync + 'static, +) -> View { + let theme_clone = *theme; + // Lectura única del env: si `SHUMA_GPU_GRID=1`, el render del texto va + // por el `CellPipeline` (atlas + quads instanciados) en vez del path + // vello. El vello sigue dibujando el fondo + el cursor para mantener + // los handlers de mouse y la geometría del rect publish. + let use_gpu = std::env::var("SHUMA_GPU_GRID") + .map(|v| v == "1" || v.eq_ignore_ascii_case("true")) + .unwrap_or(false); + let grid_rows = snapshot.as_ref().map(|s| s.rows).unwrap_or(1).max(1); + // El snapshot lo comparten paint_with (rect/cursor/bg) y gpu_paint_with + // (cells). Arc para que cada closure capture su propia handle. + let snapshot = Arc::new(snapshot); + + let snapshot_paint = Arc::clone(&snapshot); + let painter = move |scene: &mut vello::Scene, + ts: &mut llimphi_ui::llimphi_text::Typesetter, + rect: llimphi_ui::PaintRect| { + use llimphi_ui::llimphi_raster::kurbo::Rect as KurboRect; + use llimphi_ui::llimphi_raster::peniko::{Color, Fill}; + use llimphi_ui::llimphi_text::{draw_layout, layout_block, Alignment as TAlign, TextBlock}; + // Publica el rect al state — el próximo Tick disparará resize + // si las dims cambiaron. + if let Ok(mut g) = rect_slot.lock() { + *g = (rect.w, rect.h); + } + let Some(snap) = snapshot_paint.as_ref() else { return }; + // Tamaño de la celda derivado del rect disponible. Monoespacio, + // ancho/alto fijos por celda. Si el panel es chico el grid + // se recorta abajo/derecha (no scrolleamos por ahora). + let pad = 6.0_f64; + let avail_w = (rect.w as f64 - pad * 2.0).max(0.0); + let avail_h = (rect.h as f64 - pad * 2.0).max(0.0); + let cell_h = match alto_fila { + Some(h) => h as f64, + None => (avail_h / snap.rows as f64).max(1.0), + }; + // Ancho de celda: si el alto es fijo (inline), mono ~1:2 (ancho·alto); + // si estira (alt-screen), reparte el ancho entre columnas. + let cell_w = match alto_fila { + Some(_) => (cell_h * 0.5).min(avail_w / snap.cols as f64).max(1.0), + None => (avail_w / snap.cols as f64).max(1.0), + }; + let font_size = (cell_h * 0.72).clamp(8.0, 18.0) as f32; + let origin_x = rect.x as f64 + pad; + let origin_y = rect.y as f64 + pad; + + // Modo GPU: las celdas (bg + glifos) las dibuja el `gpu_paint_with` + // de abajo via `CellPipeline`. El vello sigue aquí sólo por el cursor + // (el shader del cell pipeline no lo pinta) y por publicar el rect. + if use_gpu { + // Skip bg + text — los pinta el pipeline GPU debajo. + // (Sigo al cursor más abajo, después del bloque de text/bg que + // este `if` salta con un `return` del closure ... no, el cursor + // viene en el mismo closure, así que sólo skipeo bg+text.) + } else { + // Backgrounds primero (en bloques rect), texto encima. + for (r, row) in snap.cells.iter().enumerate() { + for (c, cell) in row.iter().enumerate() { + let bg = vt_color(cell.bg, theme_clone, true); + if bg.components[3] > 0.0 { + let x0 = origin_x + c as f64 * cell_w; + let y0 = origin_y + r as f64 * cell_h; + let rect = KurboRect::new(x0, y0, x0 + cell_w, y0 + cell_h); + scene.fill( + Fill::NonZero, + vello::kurbo::Affine::IDENTITY, + bg, + None, + &rect, + ); + } + } + } + } + if !use_gpu { + // Texto por celda. Para reducir shaping, agrupamos runs con + // mismo color contiguo en la misma fila. + for (r, row) in snap.cells.iter().enumerate() { + let mut c = 0usize; + while c < row.len() { + let fg = vt_color(row[c].fg, theme_clone, false); + let mut end = c + 1; + let mut buf = String::new(); + buf.push_str(&row[c].ch); + while end < row.len() && row[end].fg == row[c].fg { + buf.push_str(&row[end].ch); + end += 1; + } + if !buf.trim().is_empty() { + let x0 = origin_x + c as f64 * cell_w; + let y0 = origin_y + r as f64 * cell_h; + let block = TextBlock { + text: &buf, + size_px: font_size, + color: fg, + origin: (x0, y0), + max_width: None, + alignment: TAlign::Start, + line_height: 1.0, + italic: false, + font_family: Some(llimphi_ui::llimphi_text::MONOSPACE.to_string()), + }; + let layout = layout_block(ts, &block); + draw_layout(scene, &layout, fg, (x0, y0)); + } + c = end; + } + } + } + // Imágenes (kitty/sixel) ancladas a su celda, por encima del texto. + // Las pinta el path vello en ambos modos (GPU y no-GPU). + for pi in &snap.images { + let iw = pi.px_w.max(1) as f64; + let ih = pi.px_h.max(1) as f64; + let (tw, th) = if pi.cols > 0 && pi.rows > 0 { + (pi.cols as f64 * cell_w, pi.rows as f64 * cell_h) + } else { + // Sin celdas pedidas: encajamos los píxeles en el área libre a + // la derecha/abajo del ancla, sin agrandar más allá del 1:1. + let maxw = (avail_w - pi.col as f64 * cell_w).max(cell_w); + let maxh = (avail_h - pi.row as f64 * cell_h).max(cell_h); + let scale = (maxw / iw).min(maxh / ih).min(1.0).max(0.000_1); + (iw * scale, ih * scale) + }; + let x0 = origin_x + pi.col as f64 * cell_w; + let y0 = origin_y + pi.row as f64 * cell_h; + let xf = vello::kurbo::Affine::translate((x0, y0)) + * vello::kurbo::Affine::scale_non_uniform(tw / iw, th / ih); + scene.draw_image(&pi.image, xf); + } + // Cursor: barra vertical en (cursor_r, cursor_c). Lo sigue dibujando + // el path vello en ambos modos — el `CellPipeline` no lo emite. + if !snap.hide_cursor { + let x0 = origin_x + snap.cursor_c as f64 * cell_w; + let y0 = origin_y + snap.cursor_r as f64 * cell_h; + let rect = KurboRect::new(x0, y0 + 2.0, x0 + 2.0, y0 + cell_h); + scene.fill( + Fill::NonZero, + vello::kurbo::Affine::IDENTITY, + Color::from_rgba8(214, 222, 232, 220), + None, + &rect, + ); + } + }; + + let lift_click = lift.clone(); + let lift_right = lift.clone(); + let lift_wheel = lift.clone(); + // Closure GPU: si `use_gpu`, dibuja todas las celdas con el + // `CellPipeline`. Lazy-init del pipeline + atlas + textura en el primer + // frame; los resources persisten en `state.gpu_grid`. No-op si el + // modo GPU está apagado o no hay snapshot. + let snapshot_gpu = Arc::clone(&snapshot); + let gpu_grid_for_paint = Arc::clone(&gpu_grid); + let theme_for_gpu = theme_clone; + let gpu_painter = move |device: &llimphi_ui::llimphi_hal::wgpu::Device, + queue: &llimphi_ui::llimphi_hal::wgpu::Queue, + encoder: &mut llimphi_ui::llimphi_hal::wgpu::CommandEncoder, + target_view: &llimphi_ui::llimphi_hal::wgpu::TextureView, + rect: llimphi_ui::PaintRect, + viewport: (u32, u32)| { + if !use_gpu { + return; + } + let Some(snap) = snapshot_gpu.as_ref() else { return }; + let mut guard = match gpu_grid_for_paint.lock() { + Ok(g) => g, + Err(p) => p.into_inner(), + }; + // Lazy-init: la primera vez compilamos el pipeline + armamos atlas + // 32×8 (256 glifos iniciales, alcanza para ASCII + box-drawing). + if guard.is_none() { + let Some(atlas) = llimphi_widget_terminal::GlyphAtlas::new( + llimphi_ui::llimphi_text::MONO_FONT_BYTES, + 14.0, + 32, + 8, + ) else { + return; + }; + // El color_format del target lo sabemos del `Hal` que arma + // la intermediate (Rgba8Unorm por defecto). Asumir Rgba8Unorm; + // si el host cambia, recompilar pipeline una vez al detectar. + let pipeline = llimphi_widget_terminal::CellPipeline::new( + device, + llimphi_ui::llimphi_hal::wgpu::TextureFormat::Rgba8Unorm, + ); + let atlas_size = atlas.size(); + let (atlas_texture, atlas_view) = + llimphi_widget_terminal::CellPipeline::create_atlas_texture( + device, + queue, + atlas.pixels(), + atlas_size, + ); + *guard = Some(crate::GpuGridResources { + pipeline, + atlas, + atlas_texture, + atlas_view, + atlas_size, + }); + } + let res = guard.as_mut().unwrap(); + // Build instances ANTES de chequear dirty (rasteriza glifos nuevos). + let cells = build_cell_instances(snap, &mut res.atlas, theme_for_gpu, rect); + // Si el atlas creció, re-crear textura. + let new_size = res.atlas.size(); + if new_size != res.atlas_size { + let (tex, view) = llimphi_widget_terminal::CellPipeline::create_atlas_texture( + device, + queue, + res.atlas.pixels(), + new_size, + ); + res.atlas_texture = tex; + res.atlas_view = view; + res.atlas_size = new_size; + } else if let Some(dirty) = res.atlas.take_dirty() { + // Subir sólo el rect que cambió. Stride completo del atlas. + let pixels = res.atlas.pixels(); + let row_w = res.atlas_size.0 as usize; + let mut sub = Vec::with_capacity((dirty.w * dirty.h) as usize); + for y in 0..dirty.h { + let src_y = (dirty.y + y) as usize; + let start = src_y * row_w + dirty.x as usize; + let end = start + dirty.w as usize; + sub.extend_from_slice(&pixels[start..end]); + } + queue.write_texture( + llimphi_ui::llimphi_hal::wgpu::TexelCopyTextureInfo { + texture: &res.atlas_texture, + mip_level: 0, + origin: llimphi_ui::llimphi_hal::wgpu::Origin3d { + x: dirty.x, + y: dirty.y, + z: 0, + }, + aspect: llimphi_ui::llimphi_hal::wgpu::TextureAspect::All, + }, + &sub, + llimphi_ui::llimphi_hal::wgpu::TexelCopyBufferLayout { + offset: 0, + bytes_per_row: Some(dirty.w), + rows_per_image: Some(dirty.h), + }, + llimphi_ui::llimphi_hal::wgpu::Extent3d { + width: dirty.w, + height: dirty.h, + depth_or_array_layers: 1, + }, + ); + } + let (acw, ach) = res.atlas.cell_size(); + let snap_cols = snap.cols.max(1) as f32; + let snap_rows = snap.rows.max(1) as f32; + let pad = 6.0_f32; + let render_cell_w = ((rect.w - pad * 2.0).max(1.0) / snap_cols).max(1.0); + let render_cell_h = ((rect.h - pad * 2.0).max(1.0) / snap_rows).max(1.0); + let _ = (acw, ach); // los pasamos como atlas_w/atlas_h + let uniforms = llimphi_widget_terminal::CellUniforms { + viewport_w: viewport.0 as f32, + viewport_h: viewport.1 as f32, + cell_w: render_cell_w, + cell_h: render_cell_h, + atlas_w: res.atlas_size.0 as f32, + atlas_h: res.atlas_size.1 as f32, + _pad0: 0.0, + _pad1: 0.0, + }; + res.pipeline.draw( + device, + queue, + encoder, + target_view, + &res.atlas_view, + &cells, + uniforms, + ); + }; + + let alto_panel = alto_fila.map(|h| h * grid_rows as f32 + 12.0); + let base = View::new(Style { + size: Size { + width: percent(1.0_f32), + height: match alto_panel { + Some(h) => length(h), + None => Dimension::auto(), + }, + }, + flex_grow: if alto_panel.is_some() { 0.0 } else { 1.0 }, + flex_shrink: 0.0, + ..Default::default() + }); + // Fondo OPACO propio SÓLO si el panel es autónomo: una terminal fullscreen + // se lee sobre negro pleno, no a través del glass del drawer (el bg_panel + // translúcido dejaba ver el escritorio detrás del TUI — el "en exceso + // transparente"). En modo `fondo_hundido` la caja se omite: el panel se + // funde con la surface hundida de arriba (una sola terminal, sin doble + // panel). + let base = if fondo_hundido { + base + } else { + base.fill(theme.bg_app.with_alpha(1.0)).radius(3.0) + }; + base + .paint_with(painter) + .gpu_paint_with(gpu_painter) + // Click izquierdo → press+release del botón 0 en la celda (col,row) + // que cubra (lx,ly). El handler de update lo encodea sólo si el + // programa habilitó mouse, sino no-op silencioso. + .on_click_at(move |lx, ly, rect_w, rect_h| { + Some(lift_click(Msg::TuiMouseClick { + button: 0, + lx, + ly, + rect_w, + rect_h, + })) + }) + // Click derecho → botón 2. Algunos TUIs (htop) lo usan para abrir + // menús contextuales propios. + .on_right_click_at(move |lx, ly, rect_w, rect_h| { + Some(lift_right(Msg::TuiMouseClick { + button: 2, + lx, + ly, + rect_w, + rect_h, + })) + }) + // Rueda → botones 4/5 si el programa habilitó mouse. Si no, devolver + // None deja que el chasis siga procesando la rueda como scroll del + // output (los TUIs ocupan toda el área del panel, así que sólo cae + // a global cuando el programa no quiere mouse). + .on_scroll(move |_dx, dy| { + if dy.abs() < f32::EPSILON { + return None; + } + Some(lift_wheel(Msg::TuiMouseWheel { + dy, + lx: 0.0, + ly: 0.0, + // El runtime no nos da las dims del rect en `on_scroll`; el + // update sólo las usa para clampear las coords al grid, y + // como aquí lx/ly son (0,0) — esquina superior-izquierda — + // basta con `1x1` (cae a (1,1) tras local_to_cell). + rect_w: 1.0, + rect_h: 1.0, + })) + }) +} + +/// Skin de vim: reconstruye cada fila del `Screen` como una línea de +/// texto en la paleta del tema — sin la grilla de celdas ni los `~` de +/// relleno —, con la última fila como barra de estado. El contenido se +/// lee como un output normal, dentro del card del panel; las teclas +/// siguen yendo al PTY (vim sigue siendo interactivo). +/// +/// MVP: read-only (la selección/click-derecho-pegar nativos vienen +/// después, sobre el widget de texto). El objetivo de este paso es que +/// vim deje de verse "como por un vidrio". +pub(crate) fn vim_panel( + snapshot: Option, + theme: &Theme, + rect_slot: Arc>, + metrics_slot: Arc>, + sel: Option, + lift: L, +) -> View +where + HostMsg: Clone + 'static, + L: Fn(Msg) -> HostMsg + Clone + Send + Sync + 'static, +{ + let theme_clone = *theme; + let lift_drag = lift.clone(); + let painter = move |scene: &mut vello::Scene, + ts: &mut llimphi_ui::llimphi_text::Typesetter, + rect: llimphi_ui::PaintRect| { + use llimphi_ui::llimphi_raster::kurbo::Rect as KurboRect; + use llimphi_ui::llimphi_raster::peniko::{Color, Fill}; + use llimphi_ui::llimphi_text::{draw_layout, layout_block, Alignment as TAlign, TextBlock}; + // Publica el rect para que el próximo Tick dispare resize si cambió. + if let Ok(mut g) = rect_slot.lock() { + *g = (rect.w, rect.h); + } + let Some(snap) = &snapshot else { return }; + let pad = VIM_PAD; + let font = VIM_FONT_PX; + // Métricas reales del monospace: medimos un bloque-sonda de 40 + // glifos idénticos y dividimos para el avance horizontal; el alto + // del layout (line_height 1.0) da el alto de línea. Adivinar las + // constantes desfasa el resaltado al acumularse por columna. + const PROBE: &str = "0000000000000000000000000000000000000000"; // 40 + let probe = TextBlock { + text: PROBE, + size_px: font, + color: theme_clone.fg_text, + origin: (0.0, 0.0), + max_width: None, + alignment: TAlign::Start, + line_height: 1.0, + italic: false, + font_family: Some(llimphi_ui::llimphi_text::MONOSPACE.to_string()), + }; + let m = llimphi_ui::llimphi_text::measure(ts, &probe); + let char_w = if m.width > 1.0 { + (m.width as f64) / PROBE.len() as f64 + } else { + VIM_CHAR_W + }; + let line_h = if m.height > 1.0 { + m.height as f64 + } else { + VIM_LINE_H + }; + // Publica las métricas para que `copy_vim_selection` use las mismas. + if let Ok(mut g) = metrics_slot.lock() { + *g = (char_w as f32, line_h as f32); + } + let origin_x = rect.x as f64 + pad; + let origin_y = rect.y as f64 + pad; + let n = snap.cells.len(); + // Resaltado de la selección (drag): un rect translúcido por fila. + if let Some(vs) = sel { + let (r0, c0) = vim_px_to_cell(vs.ax as f64, vs.ay as f64, char_w, line_h); + let (r1, c1) = vim_px_to_cell(vs.hx as f64, vs.hy as f64, char_w, line_h); + let (sr, sc, er, ec) = if (r0, c0) <= (r1, c1) { + (r0, c0, r1, c1) + } else { + (r1, c1, r0, c0) + }; + let ncols = snap.cells.first().map(|row| row.len()).unwrap_or(0); + let er = er.min(n.saturating_sub(1)); + let bg = theme_clone.bg_selected; + let sel_color = Color::from_rgba8( + (bg.components[0] * 255.0) as u8, + (bg.components[1] * 255.0) as u8, + (bg.components[2] * 255.0) as u8, + 120, + ); + for r in sr..=er { + let lo = if r == sr { sc } else { 0 }; + let hi = if r == er { (ec + 1).min(ncols) } else { ncols }; + if hi <= lo { + continue; + } + let x0 = origin_x + lo as f64 * char_w; + let x1 = origin_x + hi as f64 * char_w; + let y0 = origin_y + r as f64 * line_h; + let hrect = KurboRect::new(x0, y0, x1, y0 + line_h); + scene.fill( + Fill::NonZero, + vello::kurbo::Affine::IDENTITY, + sel_color, + None, + &hrect, + ); + } + } + for (r, row) in snap.cells.iter().enumerate() { + let raw: String = row.iter().map(|c| c.ch.as_str()).collect(); + let line_str = raw.trim_end(); + // La última fila es la barra de estado / línea de comando de vim. + let is_status = n > 1 && r + 1 == n; + // Relleno de vim: una fila cuyo único contenido es `~`. + if !is_status && line_str.trim_start() == "~" { + continue; + } + let y = origin_y + r as f64 * line_h; + let color = if is_status { + theme_clone.accent + } else { + theme_clone.fg_text + }; + if is_status { + // Fondo sutil para distinguir la barra de estado del buffer. + let bar = + KurboRect::new(rect.x as f64, y - 2.0, (rect.x + rect.w) as f64, y + line_h); + scene.fill( + Fill::NonZero, + vello::kurbo::Affine::IDENTITY, + theme_clone.bg_input, + None, + &bar, + ); + } + if !line_str.is_empty() { + let block = TextBlock { + text: line_str, + size_px: font, + color, + origin: (origin_x, y), + max_width: None, + alignment: TAlign::Start, + line_height: 1.0, + italic: false, + font_family: Some(llimphi_ui::llimphi_text::MONOSPACE.to_string()), + }; + let layout = layout_block(ts, &block); + draw_layout(scene, &layout, color, (origin_x, y)); + } + } + // Cursor: barra vertical en la posición del cursor de vim. + if !snap.hide_cursor { + let x0 = origin_x + snap.cursor_c as f64 * char_w; + let y0 = origin_y + snap.cursor_r as f64 * line_h; + let cur = KurboRect::new(x0, y0 + 2.0, x0 + 2.0, y0 + line_h); + scene.fill( + Fill::NonZero, + vello::kurbo::Affine::IDENTITY, + Color::from_rgba8(214, 222, 232, 220), + None, + &cur, + ); + } + }; + + View::new(Style { + size: Size { + width: percent(1.0_f32), + height: Dimension::auto(), + }, + flex_grow: 1.0, + ..Default::default() + }) + // Fondo OPACO, como el grid genérico: una terminal fullscreen se lee + // sobre color pleno, no a través del glass del drawer. + .fill(theme.bg_app.with_alpha(1.0)) + .radius(3.0) + .paint_with(painter) + // Selección estilo terminal: arrastrar con el botón izquierdo + // selecciona celdas; al soltar se copia al clipboard. + .draggable_at(move |phase, dx, dy, lx0, ly0| { + Some(lift_drag(Msg::VimDrag { + end: matches!(phase, llimphi_ui::DragPhase::End), + dx, + dy, + ax: lx0, + ay: ly0, + })) + }) + // Paste estilo terminal: click derecho y botón del medio pegan el + // clipboard al PTY (vim sigue recibiendo las teclas aparte). + .on_right_click(lift(Msg::VimPaste)) + .on_middle_click(lift(Msg::VimPaste)) +} diff --git a/02_ruway/shuma/sandbox/shuma-module/src/lib.rs b/02_ruway/shuma/sandbox/shuma-module/src/lib.rs index 0ac8ba4..061157f 100644 --- a/02_ruway/shuma/sandbox/shuma-module/src/lib.rs +++ b/02_ruway/shuma/sandbox/shuma-module/src/lib.rs @@ -79,6 +79,41 @@ pub enum Source { /// Etiqueta amigable para mostrar en la UI; default = `user@host`. #[serde(default)] label: Option, + /// Cómo viajan los comandos al host. Default histórico: un + /// `ssh exec` por comando, sin terminal. + #[serde(default)] + transporte: RemoteTransport, + }, + /// Contenedor OCI corriendo en esta máquina. El shell ejecuta cada + /// comando vía `{engine} exec` contra `name`; el contenedor debe estar + /// vivo (lo crea/arranca quien instancia la sesión). El `cwd` que ve la + /// UI sigue siendo el del host — dentro del contenedor el comando corre + /// en su WORKDIR (o el `-w` con el que se creó). + Container { + /// "podman" o "docker" — el binario que vehiculiza el `exec`. + engine: String, + /// Nombre del contenedor (corriendo). + name: String, + /// Etiqueta amigable para la UI; default = `engine:name`. + #[serde(default)] + label: Option, + }, + /// Contenedor corriendo en un **host remoto**: cada comando viaja por SSH y + /// allá se envuelve en `{engine} exec` contra `name`. Combina las coords SSH + /// de [`Source::Remote`] con el contenedor de [`Source::Container`]. El + /// engine y el contenedor viven en el remoto, no en esta máquina. + RemoteContainer { + host: String, + user: String, + #[serde(default = "default_ssh_port")] + port: u16, + /// "podman"/"docker" en el remoto, o "unshare"/"bwrap" sobre un rootfs + /// del remoto (en cuyo caso `name` es el path del rootfs allá). + engine: String, + /// Nombre del contenedor (podman/docker) o path del rootfs (unshare). + name: String, + #[serde(default)] + label: Option, }, } @@ -86,6 +121,36 @@ fn default_ssh_port() -> u16 { 22 } +/// Cómo viajan los comandos a un [`Source::Remote`]. Los dos extremos del +/// trade-off que el usuario elige por host: +/// +/// - [`SshExec`](RemoteTransport::SshExec): no deja **nada** instalado del +/// otro lado, pero cada comando es un shell no interactivo sin terminal — +/// `vim`/`htop`/`claude` quedan mudos. +/// - [`SshPty`](RemoteTransport::SshPty): pide un PTY sobre el mismo canal +/// SSH, así que los programas de pantalla completa andan. Tampoco instala +/// nada. No persiste: cerrar la tab manda SIGHUP al shell remoto. +/// +/// La persistencia tipo tmux (sobrevivir al cierre del cliente) vive en +/// [`Source::DaemonTcp`] — pide desplegar `shuma-daemon` en el host, que es +/// justamente la parte invasiva. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum RemoteTransport { + /// Un `ssh exec` por comando, sin PTY (el modo histórico). + #[default] + SshExec, + /// Canal SSH con PTY: interactivos y pantalla completa funcionan. + SshPty, +} + +impl RemoteTransport { + /// `true` si este transporte puede sostener un programa interactivo. + pub fn soporta_pty(self) -> bool { + matches!(self, RemoteTransport::SshPty) + } +} + impl Source { /// Etiqueta corta para la UI (tab, monitor, etc.). pub fn label(&self) -> String { @@ -98,12 +163,21 @@ impl Source { Source::DaemonTcp { addr, .. } => format!("daemon@{addr}"), Source::Remote { label: Some(l), .. } => l.clone(), Source::Remote { host, user, .. } => format!("{user}@{host}"), + Source::Container { label: Some(l), .. } => l.clone(), + Source::Container { engine, name, .. } => format!("{engine}:{name}"), + Source::RemoteContainer { label: Some(l), .. } => l.clone(), + Source::RemoteContainer { + user, host, engine, name, .. + } => format!("{user}@{host}·{engine}:{name}"), } } /// `true` si el origen es remoto (SSH o DaemonTcp). pub fn is_remote(&self) -> bool { - matches!(self, Source::Remote { .. } | Source::DaemonTcp { .. }) + matches!( + self, + Source::Remote { .. } | Source::DaemonTcp { .. } | Source::RemoteContainer { .. } + ) } } @@ -429,6 +503,120 @@ impl ModuleContributions { } } +/// Urgencia de un aviso de la **marquesina** — el aviso que narra el input en +/// reposo (el placeholder del shell / command-bar), aprovechando ese espacio +/// vacío. Resuelve cómo se muestra (feedback de diseño 2026-07-03): los eventos +/// silenciosos no roban la barra, los leves son sugerencias tenues que no +/// distraen, y los urgentes titilan. La **clasificación** la decide el host (el +/// manejador de eventos), no el módulo que la pinta. Compartido aquí para que el +/// shell y la command-bar usen el mismo contrato. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Serialize, Deserialize)] +pub enum Urgencia { + /// Ruido: no se narra en la barra (queda en el historial / panel). + Silencio, + /// **Calma**: los mensajes idle humanos (ánimo, antiestrés) cuando no hay + /// nada que avisar. Aún más suave que `Leve`: color de placeholder e + /// itálica — se lee como un murmullo, no como información. + Calma, + /// Aviso tenue: se muestra sin robar atención (color muted, sin animación). + #[default] + Leve, + /// Urgente: titila para llamar la atención. + Urgente, +} + +/// Un aviso a narrar en la marquesina (el input en reposo). Lo arma el host a +/// partir de sus fuentes de eventos (el centro willay, `sys_alert` de pata, +/// comunicaciones…); el módulo sólo lo pinta como placeholder. +/// +/// **Grados de atención** (pedido 2026-07-16): además del color por urgencia, +/// la tarjeta puede traer un **glifo coloreado** al frente (`icono` + +/// `icono_rgb`) y un **fundido** gobernado por timestamps unix-ms (`entro_ms` / +/// `sale_ms`): el painter (que redibuja cada ~100 ms) computa el alpha contra su +/// reloj, así el fade es suave aunque el host empuje la tarjeta a 1 Hz. Con +/// `entro_ms == 0` la tarjeta aparece **brusca** (a plena opacidad desde el +/// primer frame) — el modo de lo urgente. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct Marquesina { + /// La línea a narrar (ya compuesta por el host: p. ej. "origen · título"). + pub texto: String, + /// Cuán fuerte se muestra. + pub urgencia: Urgencia, + /// Glifo opcional al frente (♪ ⚑ ⚠ ☀ ☾ …), pintado como bloque propio. + #[serde(default)] + pub icono: Option, + /// Color RGB del glifo. `None` = el mismo color del texto. + #[serde(default)] + pub icono_rgb: Option<[u8; 3]>, + /// Unix-ms en que la tarjeta **entró**: el painter la funde de 0→1 en los + /// primeros [`FUNDIDO_MS`] ms. `0` = sin fade-in (cambio brusco). + #[serde(default)] + pub entro_ms: u64, + /// Unix-ms en que la tarjeta va a **salir** (la próxima rotación): el painter + /// la funde de 1→0 en los últimos [`FUNDIDO_MS`] ms. `0` = sin fade-out. + #[serde(default)] + pub sale_ms: u64, +} + +/// Duración (ms) del fundido de entrada/salida de una tarjeta de marquesina. +pub const FUNDIDO_MS: u64 = 450; + +impl Marquesina { + /// Aviso leve con este texto (el caso más común). + pub fn leve(texto: impl Into) -> Self { + Self { + texto: texto.into(), + urgencia: Urgencia::Leve, + icono: None, + icono_rgb: None, + entro_ms: 0, + sale_ms: 0, + } + } + + /// Aviso urgente (titila; sin fundido — el cambio brusco ES la señal). + pub fn urgente(texto: impl Into) -> Self { + Self { urgencia: Urgencia::Urgente, ..Self::leve(texto) } + } + + /// Mensaje **de calma** (idle humano): el murmullo del input en reposo. + pub fn calma(texto: impl Into) -> Self { + Self { urgencia: Urgencia::Calma, ..Self::leve(texto) } + } + + /// Le pone un glifo al frente, con color propio opcional. + pub fn con_icono(mut self, icono: char, rgb: Option<[u8; 3]>) -> Self { + self.icono = Some(icono); + self.icono_rgb = rgb; + self + } + + /// Le fija el fundido: cuándo entró y (si se sabe) cuándo va a salir, en + /// unix-ms. El painter anima solo a partir de esto. + pub fn con_fundido(mut self, entro_ms: u64, sale_ms: u64) -> Self { + self.entro_ms = entro_ms; + self.sale_ms = sale_ms; + self + } + + /// Opacidad `0..1` de la tarjeta a `ahora_ms`, según su fundido. Pura (el + /// painter la llama cada frame con su reloj). + pub fn alpha(&self, ahora_ms: u64) -> f32 { + let f = FUNDIDO_MS as f32; + let entrada = if self.entro_ms == 0 { + 1.0 + } else { + (ahora_ms.saturating_sub(self.entro_ms) as f32 / f).clamp(0.0, 1.0) + }; + let salida = if self.sale_ms == 0 { + 1.0 + } else { + (self.sale_ms.saturating_sub(ahora_ms) as f32 / f).clamp(0.0, 1.0) + }; + entrada.min(salida) + } +} + #[cfg(test)] mod tests { use super::*; @@ -446,6 +634,7 @@ mod tests { user: "ops".into(), port: 22, label: None, + transporte: RemoteTransport::default(), }; assert_eq!(s.label(), "ops@srv"); assert!(s.is_remote()); @@ -458,6 +647,7 @@ mod tests { user: "ops".into(), port: 22, label: Some("edge".into()), + transporte: RemoteTransport::default(), }; assert_eq!(s.label(), "edge"); } @@ -560,6 +750,7 @@ mod tests { user: "ops".into(), port: 2222, label: None, + transporte: RemoteTransport::default(), }, label: Some("Edge 1".into()), options: Some("inventory = \"/etc/matilda/inv.json\"".into()), diff --git a/02_ruway/shuma/sandbox/shuma-protocol/Cargo.toml b/02_ruway/shuma/sandbox/shuma-protocol/Cargo.toml index 3661203..5a7df29 100644 --- a/02_ruway/shuma/sandbox/shuma-protocol/Cargo.toml +++ b/02_ruway/shuma/sandbox/shuma-protocol/Cargo.toml @@ -10,7 +10,7 @@ description = "Wire protocol entre shipote-daemon y clientes (cli/gui). Postcard [dependencies] shuma-card = { path = "../shuma-card" } -card-core = { workspace = true } +shuma-consola-core = { path = "../shuma-consola-core" } serde = { workspace = true } postcard = { workspace = true } thiserror = { workspace = true } diff --git a/02_ruway/shuma/sandbox/shuma-protocol/src/lib.rs b/02_ruway/shuma/sandbox/shuma-protocol/src/lib.rs index ef26ac9..60a0341 100644 --- a/02_ruway/shuma/sandbox/shuma-protocol/src/lib.rs +++ b/02_ruway/shuma/sandbox/shuma-protocol/src/lib.rs @@ -202,6 +202,86 @@ pub enum Request { /// (Sólo dentro de un `ExecPty`) la ventana del cliente cambió de /// tamaño; el daemon reescala el PTY (`TIOCSWINSZ`). PtyResize { rows: u16, cols: u16 }, + + // === Sesiones PTY persistentes (tmux-like) === + // A diferencia de `ExecPty` (efímero: muere al cerrar la conexión), + // una sesión persiste a la desconexión del cliente. El proceso vive + // en el daemon, desacoplado de cualquier conexión, hasta que termina + // solo o se le manda `PtyKill`. No persiste a reinicio del daemon. + + /// Crear una sesión PTY persistente. El daemon spawnea `program args` + /// bajo un pseudo-terminal y la registra; el proceso sigue vivo aunque + /// nadie esté adjunto. Request/response 1:1: devuelve [`Response::PtySpawned`] + /// con el id; luego el cliente se adjunta con [`Request::PtyAttach`] + /// (en esta misma conexión o en otra). + PtySpawn { + cwd: String, + program: String, + args: Vec, + rows: u16, + cols: u16, + /// Etiqueta legible para listar (p. ej. "claude · repo X"). Si + /// está vacía, el daemon usa el nombre del programa. + #[serde(default)] + label: String, + }, + + /// Adjuntarse a una sesión existente. La conexión pasa a modo + /// **full-duplex** igual que `ExecPty`: + /// - server→cliente: primero el **scrollback** (bytes recientes para + /// repintar la pantalla) como uno o más [`Response::ExecBytes`], + /// luego la salida en vivo, terminada por `ExecExited` cuando el + /// proceso de la sesión muere. Si la sesión no existe, `ExecFailed`. + /// - cliente→server: [`Request::PtyInput`]/[`Request::PtyResize`]. + /// + /// **Cerrar la conexión = DETACH, no mata la sesión** (la diferencia + /// clave con `ExecPty`). `rows`/`cols` reescalan el PTY al tamaño del + /// cliente que se adjunta. + PtyAttach { session: Ulid, rows: u16, cols: u16 }, + + /// Listar las sesiones PTY (vivas y terminadas-no-reapeadas). + /// Request/response 1:1 → [`Response::PtyList`]. + PtyList, + + /// Matar (o reapear, si ya murió) una sesión y quitarla del registro. + /// Request/response 1:1 → [`Response::PtyKilled`]. + PtyKill { session: Ulid }, + + /// Inyectar input (teclas) a una sesión persistente **por id**, sin + /// adjuntarse. Request/response 1:1 → [`Response::PtyInputSent`]. Pensado + /// para acciones rápidas (responder/continuar desde una notificación). + PtySendInput { session: Ulid, bytes: Vec }, + + /// **Mirar sin adjuntarse**: el daemon corre el scrollback de la sesión por + /// un vt100 de `rows`×`cols` y devuelve la pantalla resultante en texto más + /// el título OSC que el programa haya puesto. Request/response 1:1 → + /// [`Response::PtySnapshot`]. Es lo que alimenta la **miniatura** y el + /// **título real** de cada sesión en el gestor: sin esto, una sesión que + /// nadie tiene abierta sólo se puede rotular con el nombre del binario. + /// + /// Barato del lado del cliente, no gratis del lado del daemon (parsear el + /// anillo cuesta): es a demanda (al abrir/refrescar el gestor), nunca por + /// frame. + PtySnapshot { session: Ulid, rows: u16, cols: u16 }, + + // === Consola de claudes agénticos (poll-based) === + // Cada sesión es una sesión de Claude Code viva y reanudable en el daemon + // (ver `shuma-consola-host`). El cliente (móvil) consulta por **polling** — + // el mismo patrón con el que el chasis de escritorio maneja el registro + // in-process, ahora sobre HTTP `/rpc` del gateway (passthrough genérico). + /// Lista de sesiones de consola. Request/response 1:1 → [`Response::ConsolaList`]. + ConsolaList, + /// Crea una sesión y arranca su primer turno. → [`Response::ConsolaCreada`]. + ConsolaCrear { cwd: String, prompt: String, model: Option }, + /// Manda el próximo mensaje (reanuda con `--resume`). → [`Response::ConsolaOk`]. + ConsolaEnviar { id: String, prompt: String }, + /// Snapshot del historial reducido de una sesión, para pintar el transcript. + /// → [`Response::ConsolaSnapshot`]. + ConsolaSnapshot { id: String }, + /// Marca una sesión como vista (apaga sus badges). → [`Response::ConsolaOk`]. + ConsolaLeida { id: String }, + /// Mata y quita una sesión del registro. → [`Response::ConsolaOk`]. + ConsolaKill { id: String }, } /// Cómo ejecutar — variante serializable paralela a `shuma_exec::Exec`. @@ -377,6 +457,72 @@ pub enum Response { /// Terminal. El proceso no se pudo ni lanzar (binario inexistente, /// permisos, etc.). ExecFailed(String), + + // === Sesiones PTY persistentes === + /// Respuesta a [`Request::PtySpawn`]: id de la sesión recién creada. + PtySpawned { session: Ulid }, + /// Respuesta a [`Request::PtyList`]: sesiones registradas. + PtyList { sessions: Vec }, + /// Respuesta a [`Request::PtyKill`]: `existed=false` si no había tal + /// sesión en el registro. + PtyKilled { session: Ulid, existed: bool }, + /// Respuesta a [`Request::PtySendInput`]: `existed=false` si no había tal + /// sesión (o ya estaba muerta). + PtyInputSent { session: Ulid, existed: bool }, + /// Respuesta a [`Request::PtySnapshot`]: la pantalla de la sesión en texto + /// (una `String` por fila, ya recortada a `cols`) y el título OSC vigente. + /// `existed=false` (con `lines` vacío) si no había tal sesión. + PtySnapshot { + session: Ulid, + existed: bool, + /// Título puesto por el programa con OSC 0/2 (`None` si nunca puso uno). + title: Option, + /// Filas de la pantalla, de arriba hacia abajo, sin las vacías del final. + lines: Vec, + }, + + // === Consola de claudes === + /// Respuesta a [`Request::ConsolaList`]. + ConsolaList { sesiones: Vec }, + /// Respuesta a [`Request::ConsolaCrear`]: id de la sesión nueva. + ConsolaCreada { id: String }, + /// Respuesta a [`Request::ConsolaSnapshot`]: el estado reducido (`None` si + /// no existe la sesión). + ConsolaSnapshot { sesion: Option }, + /// Ack de consola (`existed=false` si no había tal sesión). + ConsolaOk { existed: bool }, +} + +/// Metadatos de una sesión PTY persistente, para `PtyList`. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct PtySessionInfo { + pub session: Ulid, + /// Etiqueta legible (cae al nombre del programa si se creó vacía). + pub label: String, + pub program: String, + pub args: Vec, + pub cwd: String, + pub rows: u16, + pub cols: u16, + /// `true` mientras el proceso vive; `false` si ya terminó (la sesión + /// sigue en el registro hasta que se reapea con `PtyKill`). + pub alive: bool, + /// Código de salida una vez muerta (`None` si sigue viva). + pub exit_code: Option, + /// Instante de creación (epoch ms) — para ordenar/mostrar antigüedad. + pub created_unix_ms: u64, + /// Conexiones actualmente adjuntas a la sesión. + pub attached: u32, +} + +/// Resumen de una sesión de consola para la tira de tabs (`ConsolaList`). +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct ConsolaResumen { + pub id: String, + pub titulo: String, + pub estado: shuma_consola_core::EstadoSesion, + pub atencion: shuma_consola_core::Atencion, + pub actualizada: u64, } impl Response { @@ -550,6 +696,33 @@ mod tests { assert!(matches!(back, Request::Ping)); } + #[test] + fn consola_snapshot_roundtrips() { + // La Sesión de la consola cruza el framing postcard (por eso su + // Herramienta usa input_json:String, no serde_json::Value). + let mut s = shuma_consola_core::Sesion::nueva("consola-1", "/tmp", 0); + s.enviar("hola", 0); + let resp = Response::ConsolaSnapshot { sesion: Some(s.clone()) }; + let bytes = postcard::to_allocvec(&resp).unwrap(); + let back: Response = postcard::from_bytes(&bytes).unwrap(); + match back { + Response::ConsolaSnapshot { sesion: Some(b) } => assert_eq!(b, s), + _ => panic!("wrong variant"), + } + } + + #[test] + fn consola_list_request_roundtrips() { + let req = Request::ConsolaCrear { + cwd: "/tmp".into(), + prompt: "arregla el bug".into(), + model: None, + }; + let bytes = postcard::to_allocvec(&req).unwrap(); + let back: Request = postcard::from_bytes(&bytes).unwrap(); + assert!(matches!(back, Request::ConsolaCrear { .. })); + } + #[test] fn workspace_create_roundtrip() { let req = Request::WorkspaceCreate { diff --git a/02_ruway/shuma/sandbox/shuma-remote-exec/Cargo.toml b/02_ruway/shuma/sandbox/shuma-remote-exec/Cargo.toml index 2e6cb3c..5e77765 100644 --- a/02_ruway/shuma/sandbox/shuma-remote-exec/Cargo.toml +++ b/02_ruway/shuma/sandbox/shuma-remote-exec/Cargo.toml @@ -11,6 +11,8 @@ description = "shuma — cliente sync del subprotocolo ExecStream del daemon. Re [dependencies] shuma-exec = { path = "../shuma-exec" } shuma-protocol = { path = "../shuma-protocol" } +ulid = { workspace = true } shuma-link = { path = "../shuma-link" } +ssh = { workspace = true } tokio = { workspace = true } thiserror = { workspace = true } diff --git a/02_ruway/shuma/sandbox/shuma-remote-exec/examples/listar_sesiones.rs b/02_ruway/shuma/sandbox/shuma-remote-exec/examples/listar_sesiones.rs new file mode 100644 index 0000000..c8fd319 --- /dev/null +++ b/02_ruway/shuma/sandbox/shuma-remote-exec/examples/listar_sesiones.rs @@ -0,0 +1,23 @@ +//! Lista las sesiones PTY persistentes del daemon (id, label, programa, estado). +//! Uso: cargo run -p shuma-remote-exec --example listar_sesiones +fn main() { + let sock = shuma_protocol::default_socket_path(); + match shuma_remote_exec::list_sessions(&sock) { + Ok(sesiones) => { + println!("socket: {}", sock.display()); + println!("{} sesión(es):\n", sesiones.len()); + for s in &sesiones { + let estado = if s.alive { + format!("VIVA · {} adjunta(s)", s.attached) + } else { + format!("muerta · exit {:?}", s.exit_code) + }; + println!( + ":attach {}\n label={:?} prog={} {:?}\n cwd={} [{estado}]\n", + s.session, s.label, s.program, s.args, s.cwd + ); + } + } + Err(e) => eprintln!("✘ no pude listar: {e}"), + } +} diff --git a/02_ruway/shuma/sandbox/shuma-remote-exec/examples/session_smoke.rs b/02_ruway/shuma/sandbox/shuma-remote-exec/examples/session_smoke.rs new file mode 100644 index 0000000..aedc4be --- /dev/null +++ b/02_ruway/shuma/sandbox/shuma-remote-exec/examples/session_smoke.rs @@ -0,0 +1,27 @@ +//! Smoke e2e de las sesiones persistentes (E4) contra un daemon vivo: +//! spawn → list → attach (lee scrollback) → kill. Requiere shuma-daemon +//! corriendo. `cargo run -p shuma-remote-exec --example session_smoke` +use shuma_exec::{CommandSpec, Exec}; +fn main() { + let sock = shuma_protocol::default_socket_path(); + let spec = CommandSpec { + exec: Exec::Pty { program: "bash".into(), args: vec!["-lc".into(), "echo hola-e4; sleep 30".into()], cols: 80, rows: 24 }, + cwd: "/".into(), capture_limit: 0, spill_path: None, stdin_data: None, env: Vec::new(), capture_stages: false, + }; + let id = shuma_remote_exec::spawn_session_id(&spec, &sock, "smoke-e4").expect("spawn"); + println!("spawned: {id}"); + let sessions = shuma_remote_exec::list_sessions(&sock).expect("list"); + println!("list: {} sesiones; la nuestra viva={}", sessions.len(), + sessions.iter().find(|s| s.session == id).map(|s| s.alive).unwrap_or(false)); + let mut h = shuma_remote_exec::attach_session(&sock, id, 24, 80).expect("attach"); + std::thread::sleep(std::time::Duration::from_millis(400)); + let evs = h.try_events(); + let bytes: Vec = evs.iter().flat_map(|e| if let shuma_exec::RunEvent::Bytes(b) = e { b.clone() } else { vec![] }).collect(); + println!("attach scrollback contiene 'hola-e4': {}", String::from_utf8_lossy(&bytes).contains("hola-e4")); + drop(h); // detach (no mata) + std::thread::sleep(std::time::Duration::from_millis(200)); + let still = shuma_remote_exec::list_sessions(&sock).expect("list2"); + println!("tras detach sigue viva: {}", still.iter().any(|s| s.session == id && s.alive)); + let killed = shuma_remote_exec::kill_session(&sock, id).expect("kill"); + println!("kill existed: {killed}"); +} diff --git a/02_ruway/shuma/sandbox/shuma-remote-exec/src/lib.rs b/02_ruway/shuma/sandbox/shuma-remote-exec/src/lib.rs index f80ca81..8a1df76 100644 --- a/02_ruway/shuma/sandbox/shuma-remote-exec/src/lib.rs +++ b/02_ruway/shuma/sandbox/shuma-remote-exec/src/lib.rs @@ -29,6 +29,8 @@ #![forbid(unsafe_code)] pub use shuma_exec::RunEvent; +/// Re-export para que los consumidores construyan auth sin depender de `ssh`. +pub use ssh::SshAuth; use shuma_exec::{CommandSpec, Exec}; use shuma_protocol::{ read_frame, write_frame, ExecKind, ExecStage, Request, Response, @@ -132,8 +134,12 @@ pub enum RemoteExecError { Connect(PathBuf, std::io::Error), #[error("conexión TCP a {0}: {1}")] ConnectTcp(String, std::io::Error), - #[error("PTY remoto aún no soportado — usá el modo local para comandos TUI (vim, htop, etc.)")] + #[error("PTY remoto aún no soportado — usa el modo local para comandos TUI (vim, htop, etc.)")] PtyNotSupported, + /// Error de protocolo con el daemon (respuesta inesperada, framing, etc.) + /// — usado por las sesiones PTY persistentes (E4). + #[error("protocolo: {0}")] + Protocol(String), } /// Lanza `spec` contra el daemon en `socket` y devuelve un asa cuyos @@ -270,6 +276,228 @@ pub fn run_default(spec: &CommandSpec) -> Result RemoteRunHandle { + let (tx, rx) = std::sync::mpsc::channel::(); + let cancel = Arc::new(Notify::new()); + // Persistencia de cwd v1: sólo paths absolutos (cada `exec` SSH es un shell + // nuevo que arranca en $HOME; un `cd` relativo no tiene contra qué resolver + // sin un round-trip extra). Relativo → corre en $HOME. + let remote_cmd = if cwd.starts_with('/') { + format!("cd {} && {}", ssh_quote(cwd), line) + } else { + line.to_string() + }; + let cfg = ssh::SshConfig { + host: host.to_string(), + port, + user: user.to_string(), + auth, + keepalive_secs: 15, + }; + let etiqueta = format!("{user}@{host}"); + std::thread::spawn(move || { + let rt = match tokio::runtime::Builder::new_current_thread() + .enable_all() + .build() + { + Ok(r) => r, + Err(e) => { + let _ = tx.send(RunEvent::Failed(format!("runtime: {e}"))); + return; + } + }; + rt.block_on(async move { + let sess = match ssh::SshSession::connect(&cfg).await { + Ok(s) => s, + Err(e) => { + let _ = tx.send(RunEvent::Failed(format!("ssh {etiqueta}: {e}"))); + return; + } + }; + match sess.exec(&remote_cmd).await { + Ok(out) => { + for l in String::from_utf8_lossy(&out.stdout).lines() { + if tx.send(RunEvent::Stdout(l.to_string())).is_err() { + return; + } + } + for l in String::from_utf8_lossy(&out.stderr).lines() { + if tx.send(RunEvent::Stderr(l.to_string())).is_err() { + return; + } + } + let _ = tx.send(RunEvent::Exited(out.exit_code)); + } + Err(e) => { + let _ = tx.send(RunEvent::Failed(format!("ssh exec: {e}"))); + } + } + }); + }); + RemoteRunHandle { rx, finished: false, cancel, pty_out: None } +} + +/// Abre un **PTY remoto por SSH** — el equivalente de `ssh host` a secas, +/// sin instalar nada del otro lado. El asa devuelta es idéntica a la de +/// [`run_pty`] (la del daemon): acepta `write_input`/`resize` y emite +/// `RunEvent::Bytes` con la salida cruda del terminal, así que el frontend +/// la pinta con la misma `TuiSession` que usa para un PTY local. +/// +/// `command = None` arranca el shell de login del usuario remoto (una tab +/// remota); `Some(cmd)` corre ese comando **con** terminal (un `vim` o un +/// `htop` remoto puntual). `cwd` sólo se antepone como `cd` cuando hay +/// comando y es un path absoluto — un shell de login arranca en el `$HOME` +/// del remoto, donde el cwd local casi nunca existe. +/// +/// A diferencia del transporte por daemon, esto **no persiste**: cerrar el +/// asa cierra el canal y el shell remoto recibe su SIGHUP. A cambio no pide +/// desplegar nada en el host. +pub fn run_pty_ssh( + command: Option, + cwd: &str, + host: &str, + user: &str, + port: u16, + auth: ssh::SshAuth, + rows: u16, + cols: u16, +) -> RemoteRunHandle { + let (tx, rx) = std::sync::mpsc::channel::(); + let (out_tx, mut out_rx) = tokio::sync::mpsc::unbounded_channel::(); + let cancel = Arc::new(Notify::new()); + let cancel_thread = cancel.clone(); + let remote_cmd = command.map(|c| { + if cwd.starts_with('/') { + format!("cd {} && {}", ssh_quote(cwd), c) + } else { + c + } + }); + let cfg = ssh::SshConfig { + host: host.to_string(), + port, + user: user.to_string(), + auth, + keepalive_secs: 15, + }; + let etiqueta = format!("{user}@{host}"); + + std::thread::spawn(move || { + let rt = match tokio::runtime::Builder::new_current_thread() + .enable_all() + .build() + { + Ok(r) => r, + Err(e) => { + let _ = tx.send(RunEvent::Failed(format!("runtime: {e}"))); + return; + } + }; + rt.block_on(async move { + let sess = match ssh::SshSession::connect(&cfg).await { + Ok(s) => s, + Err(e) => { + let _ = tx.send(RunEvent::Failed(format!("ssh {etiqueta}: {e}"))); + return; + } + }; + let pty = match &remote_cmd { + None => sess.open_pty("xterm-256color", cols, rows).await, + Some(cmd) => { + sess.open_pty_exec("xterm-256color", cols, rows, cmd).await + } + }; + let ssh::SshPty { mut read, write } = match pty { + Ok(p) => p, + Err(e) => { + let _ = tx.send(RunEvent::Failed(format!("ssh pty {etiqueta}: {e}"))); + return; + } + }; + // Tarea escritora: teclas y resizes del shell → canal SSH. Reusa + // los mismos `Request` del daemon para que `RemoteRunHandle` no + // tenga que distinguir el transporte. + let writer = tokio::spawn(async move { + while let Some(msg) = out_rx.recv().await { + let ok = match msg { + Request::PtyInput { bytes } => write.input(&bytes).await.is_ok(), + Request::PtyResize { rows, cols } => { + write.resize(cols, rows).await.is_ok() + } + // Ningún otro request tiene sentido sobre un canal SSH. + _ => true, + }; + if !ok { + break; + } + } + // Al salir se dropea `write` → el canal manda EOF/close. + }); + // Bucle lector: salida del terminal remoto → RunEvent::Bytes. + let mut code = 0i32; + loop { + tokio::select! { + biased; + _ = cancel_thread.notified() => break, + ev = read.next() => { + match ev { + Some(ssh::PtyEvent::Data(bytes)) => { + if tx.send(RunEvent::Bytes(bytes)).is_err() { + break; + } + } + Some(ssh::PtyEvent::Exit(c)) => code = c, + None => break, + } + } + } + } + writer.abort(); + // El evento terminal es obligatorio: sin él el frontend deja el + // run "vivo" y el gate del teclado se sigue comiendo las teclas + // contra un PTY muerto (misma trampa que en `run_pty`). + let _ = tx.send(RunEvent::Exited(code)); + }); + }); + + RemoteRunHandle { + rx, + finished: false, + cancel, + pty_out: Some(out_tx), + } +} + +/// Quote Bourne mínimo para inyectar un path en `cd ''`. +fn ssh_quote(s: &str) -> String { + let mut out = String::with_capacity(s.len() + 2); + out.push('\''); + for ch in s.chars() { + if ch == '\'' { + out.push_str("'\\''"); + } else { + out.push(ch); + } + } + out.push('\''); + out +} + /// Extrae `(program, args, rows, cols)` de un spec PTY; `None` si el spec /// no es `Exec::Pty`. fn pty_fields(spec: &CommandSpec) -> Option<(String, Vec, u16, u16)> { @@ -344,7 +572,17 @@ pub fn run_pty( res = read_frame::(&mut rd) => { let resp = match res { Ok(r) => r, - Err(_) => break, // EOF/error: el daemon cerró + Err(_) => { + // EOF/error: el daemon murió o cerró. SIN un + // evento terminal el run quedaba "vivo" para + // siempre y el gate del teclado se comía todas + // las teclas contra un PTY muerto (ni ls ni + // Ctrl+C) — el frontend NECESITA el cierre. + let _ = tx.send(RunEvent::Failed( + "conexión al daemon perdida (EOF)".into(), + )); + break; + } }; let terminal = resp.is_exec_terminal(); if let Some(ev) = response_to_event(resp) { @@ -366,6 +604,274 @@ pub fn run_pty( Ok(RemoteRunHandle { rx, finished: false, cancel, pty_out: Some(out_tx) }) } +// =================================================================== +// Sesiones PTY persistentes (E4): el proceso vive en el daemon y +// sobrevive a la desconexión del cliente. `spawn_session` lo crea y se +// adjunta; `attach_session` se re-adjunta a uno existente; `list`/`kill` +// son request/response 1:1. El `RemoteRunHandle` que devuelven es idéntico +// al de `run_pty` — el shell los drena igual. Cerrar la conexión = DETACH +// (la sesión sigue viva), no kill. +// =================================================================== + +/// Un round-trip 1:1 con el daemon por el socket Unix (para PtyList/PtyKill/ +/// PtySpawn): conecta, escribe `req`, lee una `Response`. Bloqueante. +fn daemon_roundtrip( + socket: &std::path::Path, + req: Request, +) -> Result { + let std_stream = std::os::unix::net::UnixStream::connect(socket) + .map_err(|e| RemoteExecError::Connect(socket.to_path_buf(), e))?; + std_stream + .set_nonblocking(true) + .map_err(|e| RemoteExecError::Connect(socket.to_path_buf(), e))?; + let rt = tokio::runtime::Builder::new_current_thread() + .enable_all() + .build() + .map_err(|e| RemoteExecError::Protocol(format!("runtime: {e}")))?; + rt.block_on(async move { + let mut stream = tokio::net::UnixStream::from_std(std_stream) + .map_err(|e| RemoteExecError::Protocol(format!("from_std: {e}")))?; + write_frame(&mut stream, &req) + .await + .map_err(|e| RemoteExecError::Protocol(format!("write: {e}")))?; + read_frame::(&mut stream) + .await + .map_err(|e| RemoteExecError::Protocol(format!("read: {e}"))) + }) +} + +/// Garantiza que haya un daemon atendiendo `socket` — **modelo tmux**: el +/// server nace en el primer uso, desacoplado del frontend, y las sesiones +/// sobreviven a reinicios del compositor. Si el socket no conecta, lanza +/// `shuma-daemon` desacoplado (grupo propio + stdio nulo) y espera a que +/// atienda. Las carreras entre frontends son inofensivas: el daemon es +/// singleton por lockfile, el perdedor aborta solo. Devuelve `true` si lo +/// tuvo que arrancar (para que el caller lo pueda contar al usuario). +pub fn ensure_daemon(socket: &std::path::Path) -> Result { + if std::os::unix::net::UnixStream::connect(socket).is_ok() { + return Ok(false); + } + // Sólo auto-arrancamos para el socket DEFAULT: un daemon nuevo bindea + // `default_socket_path()` (deriva de XDG_RUNTIME_DIR), así que arrancarlo + // para un socket ajeno (daemon remoto/custom caído) no ayudaría. + if socket != shuma_protocol::default_socket_path() { + return Err(RemoteExecError::Connect( + socket.to_path_buf(), + std::io::Error::new(std::io::ErrorKind::ConnectionRefused, "daemon no responde"), + )); + } + let bin = daemon_binary(); + let mut cmd = std::process::Command::new(&bin); + cmd.stdin(std::process::Stdio::null()) + .stdout(std::process::Stdio::null()) + .stderr(std::process::Stdio::null()); + // Grupo de proceso propio (API safe; el crate prohíbe `unsafe`, así que + // nada de setsid/pre_exec): cuando el frontend muera —o el compositor lo + // barra con un kill-de-grupo—, el daemon no cae con él; queda huérfano y + // lo adopta el init. + { + use std::os::unix::process::CommandExt; + cmd.process_group(0); + } + cmd.spawn() + .map_err(|e| RemoteExecError::Protocol(format!("no pude lanzar {bin}: {e}")))?; + // Espera acotada a que el socket atienda (el daemon bindea temprano). + for _ in 0..40 { + std::thread::sleep(std::time::Duration::from_millis(50)); + if std::os::unix::net::UnixStream::connect(socket).is_ok() { + return Ok(true); + } + } + Err(RemoteExecError::Protocol(format!( + "lancé {bin} pero {} no atendió en 2s", + socket.display() + ))) +} + +/// Binario del daemon a lanzar: override explícito (`SHUMA_DAEMON_BIN`) > +/// hermano del ejecutable actual (el deploy instala frontend y daemon juntos +/// en /usr/local/bin) > `shuma-daemon` del PATH. +fn daemon_binary() -> String { + if let Ok(p) = std::env::var("SHUMA_DAEMON_BIN") { + return p; + } + if let Ok(exe) = std::env::current_exe() { + if let Some(dir) = exe.parent() { + let hermano = dir.join("shuma-daemon"); + if hermano.exists() { + return hermano.display().to_string(); + } + } + } + "shuma-daemon".to_string() +} + +/// Lista las sesiones PTY persistentes del daemon. +pub fn list_sessions( + socket: &std::path::Path, +) -> Result, RemoteExecError> { + match daemon_roundtrip(socket, Request::PtyList)? { + Response::PtyList { sessions } => Ok(sessions), + Response::Error { message } => Err(RemoteExecError::Protocol(message)), + other => Err(RemoteExecError::Protocol(format!("respuesta inesperada: {other:?}"))), + } +} + +/// Mira una sesión persistente **sin adjuntarse**: devuelve el título OSC que +/// puso el programa y la pantalla de `rows`×`cols` en texto (una `String` por +/// fila, sin las vacías del final). Es la fuente del rótulo y de la miniatura +/// del gestor de sesiones. `Ok(None)` si la sesión ya no existe. +pub fn snapshot_session( + socket: &std::path::Path, + session: ulid::Ulid, + rows: u16, + cols: u16, +) -> Result, Vec)>, RemoteExecError> { + match daemon_roundtrip(socket, Request::PtySnapshot { session, rows, cols })? { + Response::PtySnapshot { existed: false, .. } => Ok(None), + Response::PtySnapshot { title, lines, .. } => Ok(Some((title, lines))), + Response::Error { message } => Err(RemoteExecError::Protocol(message)), + other => Err(RemoteExecError::Protocol(format!("respuesta inesperada: {other:?}"))), + } +} + +/// Mata (o reapea) una sesión persistente. `Ok(false)` si no existía. +pub fn kill_session( + socket: &std::path::Path, + session: ulid::Ulid, +) -> Result { + match daemon_roundtrip(socket, Request::PtyKill { session })? { + Response::PtyKilled { existed, .. } => Ok(existed), + Response::Error { message } => Err(RemoteExecError::Protocol(message)), + other => Err(RemoteExecError::Protocol(format!("respuesta inesperada: {other:?}"))), + } +} + +/// Crea una sesión persistente (PtySpawn, request/response 1:1) y devuelve +/// su id. El proceso queda vivo en el daemon aunque nadie esté adjunto. +pub fn spawn_session_id( + spec: &CommandSpec, + socket: &std::path::Path, + label: &str, +) -> Result { + let Some((program, args, rows, cols)) = pty_fields(spec) else { + return Err(RemoteExecError::PtyNotSupported); + }; + let req = Request::PtySpawn { + cwd: spec.cwd.clone(), + program, + args, + rows, + cols, + label: label.to_string(), + }; + match daemon_roundtrip(socket, req)? { + Response::PtySpawned { session } => Ok(session), + Response::Error { message } => Err(RemoteExecError::Protocol(message)), + other => Err(RemoteExecError::Protocol(format!("respuesta inesperada: {other:?}"))), + } +} + +/// Se re-adjunta a una sesión existente: full-duplex como [`run_pty`], pero +/// cerrar la conexión es DETACH (no mata la sesión). El daemon repinta el +/// scrollback al adjuntar, así la pantalla se reconstruye. +pub fn attach_session( + socket: &std::path::Path, + session: ulid::Ulid, + rows: u16, + cols: u16, +) -> Result { + let (tx, rx) = std::sync::mpsc::channel::(); + let (out_tx, mut out_rx) = tokio::sync::mpsc::unbounded_channel::(); + let cancel = Arc::new(Notify::new()); + let cancel_thread = cancel.clone(); + let socket_owned = socket.to_path_buf(); + let req = Request::PtyAttach { session, rows, cols }; + + let std_stream = std::os::unix::net::UnixStream::connect(&socket_owned) + .map_err(|e| RemoteExecError::Connect(socket_owned.clone(), e))?; + std_stream + .set_nonblocking(true) + .map_err(|e| RemoteExecError::Connect(socket_owned.clone(), e))?; + + std::thread::spawn(move || { + let rt = match tokio::runtime::Builder::new_current_thread().enable_all().build() { + Ok(r) => r, + Err(e) => { + let _ = tx.send(RunEvent::Failed(format!("runtime: {e}"))); + return; + } + }; + rt.block_on(async move { + let stream = match tokio::net::UnixStream::from_std(std_stream) { + Ok(s) => s, + Err(e) => { + let _ = tx.send(RunEvent::Failed(format!("from_std: {e}"))); + return; + } + }; + let (mut rd, mut wr) = tokio::io::split(stream); + if let Err(e) = write_frame(&mut wr, &req).await { + let _ = tx.send(RunEvent::Failed(format!("write attach: {e}"))); + return; + } + let writer = tokio::spawn(async move { + while let Some(msg) = out_rx.recv().await { + if write_frame(&mut wr, &msg).await.is_err() { + break; + } + } + }); + loop { + tokio::select! { + biased; + _ = cancel_thread.notified() => break, + res = read_frame::(&mut rd) => { + let resp = match res { + Ok(r) => r, + Err(_) => { + // EOF sin terminal (daemon muerto): sintetizar + // el cierre — ver el comentario en `run_pty`. + let _ = tx.send(RunEvent::Failed( + "conexión al daemon perdida (EOF)".into(), + )); + break; + } + }; + let terminal = resp.is_exec_terminal(); + if let Some(ev) = response_to_event(resp) { + if tx.send(ev).is_err() { + break; + } + } + if terminal { + break; + } + } + } + } + writer.abort(); + // Drop de rd/wr = cerrar la conexión = DETACH (la sesión vive). + }); + }); + + Ok(RemoteRunHandle { rx, finished: false, cancel, pty_out: Some(out_tx) }) +} + +/// Crea una sesión persistente y se adjunta de una: devuelve `(id, asa)`. +/// El asa rinde la salida del PTY como en `run_pty`; la sesión sobrevive si +/// el asa se dropea (detach). +pub fn spawn_session( + spec: &CommandSpec, + socket: &std::path::Path, + label: &str, +) -> Result<(ulid::Ulid, RemoteRunHandle), RemoteExecError> { + let session = spawn_session_id(spec, socket, label)?; + let (_program, _args, rows, cols) = pty_fields(spec).unwrap_or((String::new(), vec![], 24, 80)); + let handle = attach_session(socket, session, rows, cols)?; + Ok((session, handle)) +} + /// Variante autenticada y cifrada vía Noise XK sobre TCP — espejo de /// [`run`] para hablar con un daemon **remoto**. El cliente conoce de /// antemano la pubkey del servidor (`server_pub`, igual que @@ -539,7 +1045,14 @@ pub fn run_pty_tcp( res = rd.recv_postcard::() => { let resp = match res { Ok(r) => r, - Err(_) => break, + Err(_) => { + // EOF sin terminal (daemon muerto): sintetizar + // el cierre — ver el comentario en `run_pty`. + let _ = tx.send(RunEvent::Failed( + "conexión al daemon perdida (EOF)".into(), + )); + break; + } }; let terminal = resp.is_exec_terminal(); if let Some(ev) = response_to_event(resp) { @@ -594,6 +1107,7 @@ mod tests { capture_limit: capture_limit_bytes, spill_path: None, stdin_data, + env: Vec::new(), capture_stages, }; let mut h = shuma_exec::run(&spec); @@ -824,6 +1338,7 @@ mod tests { capture_limit: capture_limit_bytes, spill_path: None, stdin_data, + env: Vec::new(), capture_stages, }; let mut h = shuma_exec::run(&spec); @@ -956,6 +1471,7 @@ mod tests { capture_limit: 0, spill_path: None, stdin_data: None, + env: Vec::new(), capture_stages: false, } } @@ -973,6 +1489,7 @@ mod tests { capture_limit: 0, spill_path: None, stdin_data: None, + env: Vec::new(), capture_stages: false, }; let mut h = shuma_exec::run(&spec); @@ -1114,6 +1631,7 @@ mod tests { capture_limit: 0, spill_path: None, stdin_data: None, + env: Vec::new(), capture_stages: false, }; let mut h = shuma_exec::run(&spec); diff --git a/02_ruway/shuma/sandbox/shuma-voz-ui/Cargo.toml b/02_ruway/shuma/sandbox/shuma-voz-ui/Cargo.toml new file mode 100644 index 0000000..f395dda --- /dev/null +++ b/02_ruway/shuma/sandbox/shuma-voz-ui/Cargo.toml @@ -0,0 +1,12 @@ +[package] +name = "shuma-voz-ui" +version.workspace = true +edition.workspace = true +license.workspace = true +authors.workspace = true +publish.workspace = true +description = "shuma-voz-ui — widget del indicador de escucha por voz compartido por las superficies de shuma (panel de chat, command-bar, input del shell). Es la costura visual del «llamado shuma estilo alexa»: el `EstadoEscucha` (que el chasis fija desde los EventoEscucha de rimay-voz-host) + el botón de micrófono con halo animado. Un solo widget → indicador de escucha consistente en todo el chasis (VOZ.md §Gaps)." + +[dependencies] +llimphi-ui = { workspace = true } +llimphi-theme = { workspace = true } diff --git a/02_ruway/shuma/sandbox/shuma-voz-ui/LEEME.md b/02_ruway/shuma/sandbox/shuma-voz-ui/LEEME.md new file mode 100644 index 0000000..9d9161a --- /dev/null +++ b/02_ruway/shuma/sandbox/shuma-voz-ui/LEEME.md @@ -0,0 +1,20 @@ +# shuma-voz-ui + +*Read this in English: [README.md](README.md).* + +El indicador de escucha por voz, compartido. + +El «llamado shuma estilo alexa» aparece en varias superficies: el panel de +chat (`shuma-module-agente`), la command-bar y el input del shell. Todas +pintan **el mismo** botón de micrófono con halo animado y comparten el +`EstadoEscucha` que el chasis fija a partir de los `EventoEscucha` de +`rimay-voz-host`. Este crate es ese widget único — un solo lugar donde vive +el efecto «cava», para que el indicador de escucha sea consistente en todo +el chasis (VOZ.md §Gaps: «el indicador de escucha debe ser visible siempre»). + +El widget es agnóstico del `Msg` del host: `boton_mic` recibe el mensaje a +dispatchar en el click, así cada módulo lo cablea a su propio `ToggleMic`. + +--- + +Parte de **shuma** — ver [shuma](../../LEEME.md). diff --git a/02_ruway/shuma/sandbox/shuma-voz-ui/README.md b/02_ruway/shuma/sandbox/shuma-voz-ui/README.md new file mode 100644 index 0000000..df2de76 --- /dev/null +++ b/02_ruway/shuma/sandbox/shuma-voz-ui/README.md @@ -0,0 +1,12 @@ +# shuma-voz-ui + +The shared voice-listening indicator. + +The "alexa-style shuma call" appears on several surfaces: the chat panel +(`shuma-module-agente`), the command bar and the shell's input. They all paint +**the same** microphone button with an animated halo and share the `EstadoEscucha` +the chassis sets from the listening events. + +--- + +Part of **shuma** — see [shuma](../../README.md). diff --git a/02_ruway/shuma/sandbox/shuma-voz-ui/src/lib.rs b/02_ruway/shuma/sandbox/shuma-voz-ui/src/lib.rs new file mode 100644 index 0000000..4fa2ec7 --- /dev/null +++ b/02_ruway/shuma/sandbox/shuma-voz-ui/src/lib.rs @@ -0,0 +1,194 @@ +//! `shuma-voz-ui` — el indicador de escucha por voz, compartido. +//! +//! El «llamado shuma estilo alexa» aparece en varias superficies: el panel de +//! chat ([`shuma-module-agente`]), la command-bar y el input del shell. Todas +//! pintan **el mismo** botón de micrófono con halo animado y comparten el +//! [`EstadoEscucha`] que el chasis fija a partir de los `EventoEscucha` de +//! `rimay-voz-host`. Este crate es ese widget único — un solo lugar donde vive +//! el efecto «cava», para que el indicador de escucha sea consistente en todo +//! el chasis (VOZ.md §Gaps: «el indicador de escucha debe ser visible siempre»). +//! +//! El widget es agnóstico del `Msg` del host: [`boton_mic`] recibe el mensaje a +//! dispatchar en el click, así cada módulo lo cablea a su propio `ToggleMic`. + +#![forbid(unsafe_code)] + +use llimphi_ui::llimphi_layout::taffy::prelude::{ + length, AlignItems, JustifyContent, Size, Style, +}; +use llimphi_ui::View; +use llimphi_theme::Theme; + +/// Estado de la **escucha por voz**, para el indicador del micrófono. Lo fija el +/// chasis a partir de los `EventoEscucha` de `rimay-voz-host`; la superficie sólo +/// lo pinta (halo del botón + glow del input). +#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)] +pub enum EstadoEscucha { + /// Micrófono apagado. + #[default] + Apagado, + /// Encendido y armado, esperando la palabra de llamada. + Esperando, + /// El VAD detectó voz (alguien habla) — transitorio. + Oyendo, + /// Despertó con el llamado; listo para dictar. + Despierto, + /// Dictando: el texto fluye al input. + Dictando, +} + +impl EstadoEscucha { + /// `true` si el micrófono está encendido (cualquier estado salvo apagado). + pub fn activo(self) -> bool { + !matches!(self, EstadoEscucha::Apagado) + } +} + +/// Parámetros de la animación del halo según el estado: `(anillos, periodo_ms, +/// intensidad)`. Más activa la escucha → más anillos, más rápidos, más intensos. +pub fn params_escucha(e: EstadoEscucha) -> (u32, f64, f32) { + match e { + EstadoEscucha::Apagado => (0, 1600.0, 0.0), + EstadoEscucha::Esperando => (2, 1600.0, 0.45), + EstadoEscucha::Oyendo => (3, 1000.0, 0.70), + EstadoEscucha::Despierto => (3, 800.0, 0.90), + EstadoEscucha::Dictando => (3, 600.0, 1.0), + } +} + +/// Botón de micrófono con **halo animado** según el estado de escucha — el +/// efecto «cava»: anillos que emanan como ondas de sonido, más rápidos e +/// intensos cuanto más activa la escucha — más el glifo del micrófono teñido por +/// estado. El click dispatcha `on_click` (cada módulo lo cablea a su `ToggleMic`). +/// La animación avanza con `reloj_ms` (el chasis lo refresca mientras escucha). +/// `enrolando` manda sobre el estado: halo rojo cálido de «grabando». `lado` es +/// el tamaño del botón en px (el chat usa ~34; la command-bar y el shell, barras +/// más finas, usan menos — el glifo escala con la caja). +pub fn boton_mic( + escucha: EstadoEscucha, + enrolando: bool, + reloj_ms: u64, + lado_px: f32, + theme: &Theme, + on_click: HostMsg, +) -> View { + let accent = theme.accent; + let apagado = theme.fg_muted; + // Enrolando: halo «grabando» en rojo cálido, anillos rápidos e intensos. + let grabando = llimphi_ui::llimphi_raster::peniko::Color::from_rgb8(0xE0, 0x5A, 0x5A); + View::new(Style { + size: Size { width: length(lado_px), height: length(lado_px) }, + flex_shrink: 0.0, + align_items: Some(AlignItems::Center), + justify_content: Some(JustifyContent::Center), + ..Default::default() + }) + .hover_fill(theme.bg_row_hover) + .on_click(on_click) + .paint_with(move |scene, _ts, rect| { + use llimphi_ui::llimphi_raster::kurbo::{ + Affine, BezPath, Circle, Line, Point, RoundedRect, Stroke, + }; + use llimphi_ui::llimphi_raster::peniko::Fill; + if rect.w <= 0.0 || rect.h <= 0.0 { + return; + } + let cx = (rect.x + rect.w * 0.5) as f64; + let cy = (rect.y + rect.h * 0.5) as f64; + let lado = rect.w.min(rect.h) as f64; + // Enrolando manda sobre el estado de escucha (color rojo, máxima onda). + let (color, anillos, periodo, intensidad) = if enrolando { + (grabando, 3u32, 600.0_f64, 1.0_f32) + } else { + let (a, p, i) = params_escucha(escucha); + (accent, a, p, i) + }; + + // Halo: anillos que emanan (las «ondas» del efecto cava). + let r0 = lado * 0.20; + let spread = lado * 0.42; + for k in 0..anillos { + let fase = (((reloj_ms as f64) / periodo) + (k as f64) / (anillos as f64)).fract(); + let r = r0 + fase * spread; + let a = ((1.0 - fase as f32) * intensidad).clamp(0.0, 1.0); + scene.stroke( + &Stroke::new(1.6), + Affine::IDENTITY, + color.with_alpha(a), + None, + &Circle::new((cx, cy), r), + ); + } + + // Glifo del micrófono, teñido por estado. + let gc = if enrolando || escucha.activo() { color } else { apagado }; + let bw = lado * 0.11; + let bh = lado * 0.20; + let cap = RoundedRect::new(cx - bw, cy - bh - 2.0, cx + bw, cy + bh - 2.0, bw); + scene.fill(Fill::NonZero, Affine::IDENTITY, gc, None, &cap); + let aw = bw + 2.5; + let ay = cy + bh - 2.0; + let mut u = BezPath::new(); + u.move_to(Point::new(cx - aw, cy - 2.0)); + u.quad_to(Point::new(cx - aw, ay + 1.5), Point::new(cx, ay + 1.5)); + u.quad_to(Point::new(cx + aw, ay + 1.5), Point::new(cx + aw, cy - 2.0)); + scene.stroke(&Stroke::new(1.4), Affine::IDENTITY, gc, None, &u); + scene.stroke( + &Stroke::new(1.4), + Affine::IDENTITY, + gc, + None, + &Line::new(Point::new(cx, ay + 1.5), Point::new(cx, ay + 4.5)), + ); + scene.stroke( + &Stroke::new(1.4), + Affine::IDENTITY, + gc, + None, + &Line::new(Point::new(cx - 3.0, ay + 4.5), Point::new(cx + 3.0, ay + 4.5)), + ); + }) +} + +/// Botón de **enviar** — el gemelo del [`boton_mic`] para cuando el input tiene +/// texto: mismo marco y hover, pero el glifo es un avioncito de papel (enviar). +/// El click dispatcha `on_click` (el host lo cablea a su submit/ejecutar). Se +/// pinta en el acento para leerse como acción afirmativa, no como el micrófono +/// tenue en reposo. `lado_px` es el tamaño del botón (mismo que el mic para que +/// el swap no salte el layout). +pub fn boton_enviar( + lado_px: f32, + theme: &Theme, + on_click: HostMsg, +) -> View { + let accent = theme.accent; + View::new(Style { + size: Size { width: length(lado_px), height: length(lado_px) }, + flex_shrink: 0.0, + align_items: Some(AlignItems::Center), + justify_content: Some(JustifyContent::Center), + ..Default::default() + }) + .hover_fill(theme.bg_row_hover) + .on_click(on_click) + .paint_with(move |scene, _ts, rect| { + use llimphi_ui::llimphi_raster::kurbo::{Affine, BezPath, Point}; + use llimphi_ui::llimphi_raster::peniko::Fill; + if rect.w <= 0.0 || rect.h <= 0.0 { + return; + } + let cx = (rect.x + rect.w * 0.5) as f64; + let cy = (rect.y + rect.h * 0.5) as f64; + let lado = rect.w.min(rect.h) as f64; + let r = lado * 0.30; + // Avioncito de papel apuntando a la derecha: cuerpo triangular con la + // muesca de la cola (la "V" del pliegue), en un solo trazo relleno. + let mut plane = BezPath::new(); + plane.move_to(Point::new(cx - r, cy - r * 0.78)); // esquina superior-izq (morro atrás) + plane.line_to(Point::new(cx + r * 1.05, cy)); // punta derecha (adelante) + plane.line_to(Point::new(cx - r, cy + r * 0.78)); // esquina inferior-izq + plane.line_to(Point::new(cx - r * 0.42, cy)); // muesca central (pliegue de la cola) + plane.close_path(); + scene.fill(Fill::NonZero, Affine::IDENTITY, accent, None, &plane); + }) +} diff --git a/02_ruway/shuma/shuma-askpass/Cargo.toml b/02_ruway/shuma/shuma-askpass/Cargo.toml new file mode 100644 index 0000000..b2edf77 --- /dev/null +++ b/02_ruway/shuma/shuma-askpass/Cargo.toml @@ -0,0 +1,20 @@ +[package] +name = "shuma-askpass" +version.workspace = true +edition.workspace = true +rust-version.workspace = true +license.workspace = true +authors.workspace = true +publish.workspace = true +description = "Mini-ventana Llimphi compatible con SUDO_ASKPASS: lee una pass en un input masked y la escribe a stdout al confirmar (exit 0). Esc / cierre = exit 1." + +[[bin]] +name = "shuma-askpass" +path = "src/main.rs" + +[dependencies] +bitacora = { workspace = true } +llimphi-ui = { workspace = true } +llimphi-theme = { workspace = true } +llimphi-widget-text-input = { workspace = true } +llimphi-clipboard = { workspace = true } diff --git a/02_ruway/shuma/shuma-askpass/LEEME.md b/02_ruway/shuma/shuma-askpass/LEEME.md new file mode 100644 index 0000000..30e1142 --- /dev/null +++ b/02_ruway/shuma/shuma-askpass/LEEME.md @@ -0,0 +1,26 @@ +# shuma-askpass + +*Read this in English: [README.md](README.md).* + +Popup de contraseña compatible con `SUDO_ASKPASS`. + +`sudo -A` invoca el binario referenciado por `$SUDO_ASKPASS`; éste debe: +- aceptar el prompt como `argv[1]` (opcional), +- escribir la pass a stdout y terminar con exit 0 al confirmar, +- terminar con exit !=0 sin stdout si el usuario cancela. + +El bin abre una ventana Llimphi modal pequeña con un text-input `masked`, +botones Cancelar/OK y atajos Enter/Esc. Al cerrarse, lee el resultado del +singleton compartido y lo emite a stdout / pone el exit code. + +Para que `sudo -A` lo encuentre, shuma-exec exporta `SUDO_ASKPASS` al PTY. + +## Uso + +```sh +cargo run --release -p shuma-askpass +``` + +--- + +Parte de **shuma** — ver [shuma](../LEEME.md). diff --git a/02_ruway/shuma/shuma-askpass/README.md b/02_ruway/shuma/shuma-askpass/README.md new file mode 100644 index 0000000..ec1b258 --- /dev/null +++ b/02_ruway/shuma/shuma-askpass/README.md @@ -0,0 +1,15 @@ +# shuma-askpass + +A password popup compatible with `SUDO_ASKPASS`. + +`sudo -A` invokes the binary referenced by `$SUDO_ASKPASS`, which must: + +- accept the prompt as `argv[1]` (optional), +- write the password to stdout and exit 0 on confirmation, +- exit non-zero with no stdout if the user cancels. + +That is why plain `sudo` no longer hangs inside the shell. + +--- + +Part of **shuma** — see [shuma](../README.md). diff --git a/02_ruway/shuma/shuma-askpass/src/main.rs b/02_ruway/shuma/shuma-askpass/src/main.rs new file mode 100644 index 0000000..21c9685 --- /dev/null +++ b/02_ruway/shuma/shuma-askpass/src/main.rs @@ -0,0 +1,261 @@ +//! `shuma-askpass` — popup de contraseña compatible con `SUDO_ASKPASS`. +//! +//! `sudo -A` invoca el binario referenciado por `$SUDO_ASKPASS`; éste debe: +//! - aceptar el prompt como `argv[1]` (opcional), +//! - escribir la pass a stdout y terminar con exit 0 al confirmar, +//! - terminar con exit !=0 sin stdout si el usuario cancela. +//! +//! El bin abre una ventana Llimphi modal pequeña con un text-input `masked`, +//! botones Cancelar/OK y atajos Enter/Esc. Al cerrarse, lee el resultado del +//! singleton compartido y lo emite a stdout / pone el exit code. +//! +//! Para que `sudo -A` lo encuentre, shuma-exec exporta `SUDO_ASKPASS` al PTY. + +use std::sync::Mutex; + +use llimphi_theme::Theme; +use llimphi_ui::llimphi_layout::taffy::{ + prelude::{length, percent, Dimension, FlexDirection, Size, Style}, + AlignItems, JustifyContent, Rect, +}; +use llimphi_ui::llimphi_text::Alignment; +use llimphi_ui::{App, Handle, Key, KeyEvent, NamedKey, View}; +use llimphi_widget_text_input::{ + text_input_view_full, TextInputEvent, TextInputPalette, TextInputState, +}; + +/// Singleton del resultado: `Some(pass)` si el usuario confirmó; `None` si +/// canceló o cerró la ventana sin confirmar. El `main` lo lee tras `run`. +static RESULT: Mutex> = Mutex::new(None); + +#[derive(Clone)] +enum Msg { + Key(KeyEvent), + /// Evento de mouse del campo (click/arrastre). Ver [`text_input_view_full`]. + Campo(TextInputEvent), + Confirm, + Cancel, +} + +struct Model { + prompt: String, + input: TextInputState, + theme: Theme, + /// Portapapeles del sistema para copiar/cortar/pegar en el campo (permite + /// pegar la contraseña desde un gestor). Degrada a no-op sin display. + clipboard: llimphi_clipboard::SystemClipboard, +} + +struct Askpass; + +impl App for Askpass { + type Model = Model; + type Msg = Msg; + + fn title() -> &'static str { + "shuma · autenticación" + } + + fn app_id() -> Option<&'static str> { + Some("shuma.askpass") + } + + fn initial_size() -> (u32, u32) { + (400, 190) + } + + fn init(_h: &Handle) -> Self::Model { + // `argv[1]` lo trae sudo con el prompt resuelto ("[sudo] password + // for user:"); si no, default explícito. + let prompt = std::env::args() + .nth(1) + .filter(|s| !s.trim().is_empty()) + .unwrap_or_else(|| "Contraseña:".to_string()); + Model { + prompt, + input: TextInputState::masked(), + theme: Theme::dark(), + clipboard: llimphi_clipboard::SystemClipboard::new(), + } + } + + fn on_key(_model: &Self::Model, e: &KeyEvent) -> Option { + Some(Msg::Key(e.clone())) + } + + fn update(model: Self::Model, msg: Self::Msg, handle: &Handle) -> Self::Model { + let mut m = model; + match msg { + Msg::Key(e) => match &e.key { + Key::Named(NamedKey::Escape) => { + handle.quit(); + } + Key::Named(NamedKey::Enter) => { + if let Ok(mut g) = RESULT.lock() { + *g = Some(m.input.text()); + } + handle.quit(); + } + _ => { + // `handle` cubre escribir, mover, seleccionar y copiar/ + // cortar/pegar (Ctrl+C/X/V) contra el portapapeles. + m.input.handle(TextInputEvent::Key(e.clone()), &mut m.clipboard); + } + }, + Msg::Campo(ev) => { + // Único campo, siempre focado: el click/arrastre sólo posiciona + // el caret y extiende la selección (no confirma). + m.input.handle(ev, &mut m.clipboard); + } + Msg::Confirm => { + if let Ok(mut g) = RESULT.lock() { + *g = Some(m.input.text()); + } + handle.quit(); + } + Msg::Cancel => { + handle.quit(); + } + } + m + } + + fn view(model: &Self::Model) -> View { + let theme = &model.theme; + let prompt = View::new(Style { + size: Size { + width: percent(1.0_f32), + height: length(24.0_f32), + }, + ..Default::default() + }) + .text_aligned( + model.prompt.clone(), + 14.0, + theme.fg_text, + Alignment::Start, + ); + + let tpal = TextInputPalette::from_theme(theme); + let input = View::new(Style { + size: Size { + width: percent(1.0_f32), + height: length(36.0_f32), + }, + ..Default::default() + }) + .children(vec![text_input_view_full( + &model.input, + "•••••••", + true, + &tpal, + Msg::Campo, // click/arrastre → caret/selección (siempre focado) + )]); + + let cancelar = View::new(Style { + size: Size { + width: length(120.0_f32), + height: length(34.0_f32), + }, + align_items: Some(AlignItems::Center), + justify_content: Some(JustifyContent::Center), + ..Default::default() + }) + .fill(theme.bg_button) + .hover_fill(theme.bg_button_hover) + .radius(5.0) + .text_aligned("Cancelar".to_string(), 12.0, theme.fg_text, Alignment::Center) + .on_click(Msg::Cancel); + let ok = View::new(Style { + size: Size { + width: length(120.0_f32), + height: length(34.0_f32), + }, + align_items: Some(AlignItems::Center), + justify_content: Some(JustifyContent::Center), + ..Default::default() + }) + .fill(theme.accent) + .radius(5.0) + .text_aligned("Aceptar".to_string(), 12.0, theme.bg_app, Alignment::Center) + .on_click(Msg::Confirm); + let botones = View::new(Style { + flex_direction: FlexDirection::Row, + size: Size { + width: percent(1.0_f32), + height: length(40.0_f32), + }, + gap: Size { + width: length(10.0_f32), + height: length(0.0_f32), + }, + justify_content: Some(JustifyContent::End), + align_items: Some(AlignItems::Center), + margin: Rect { + left: length(0.0_f32), + right: length(0.0_f32), + top: length(12.0_f32), + bottom: length(0.0_f32), + }, + ..Default::default() + }) + .children(vec![cancelar, ok]); + + let card = View::new(Style { + flex_direction: FlexDirection::Column, + size: Size { + width: percent(1.0_f32), + height: Dimension::auto(), + }, + padding: Rect { + left: length(20.0_f32), + right: length(20.0_f32), + top: length(18.0_f32), + bottom: length(18.0_f32), + }, + gap: Size { + width: length(0.0_f32), + height: length(10.0_f32), + }, + ..Default::default() + }) + .fill(theme.bg_panel) + .radius(8.0) + .children(vec![prompt, input, botones]); + + View::new(Style { + size: Size { + width: percent(1.0_f32), + height: percent(1.0_f32), + }, + padding: Rect { + left: length(14.0_f32), + right: length(14.0_f32), + top: length(14.0_f32), + bottom: length(14.0_f32), + }, + align_items: Some(AlignItems::Stretch), + ..Default::default() + }) + .fill(theme.bg_app) + .children(vec![card]) + } +} + +fn main() { + bitacora::abrir("shuma"); + llimphi_ui::run::(); + // Tras `run`, el bucle terminó. Si el usuario confirmó, escupimos la + // pass a stdout (sin newline trailing — algunos askpass strict cortan + // en `\n` y otros no, la pass del usuario *podría* tener LF; nos + // alineamos al formato que usa `ssh-askpass`: sólo lo que tipeó). + let pass = RESULT.lock().ok().and_then(|mut g| g.take()); + if let Some(p) = pass { + // `print!` sin trailing newline — algunos consumidores la dejan + // entera; `sudo` y `ssh` toleran un único `\n` final, así que lo + // agregamos como hace ssh-askpass clásico. + println!("{p}"); + std::process::exit(0); + } + std::process::exit(1); +} diff --git a/02_ruway/shuma/shuma-cli/Cargo.toml b/02_ruway/shuma/shuma-cli/Cargo.toml index fdc859e..138b9a9 100644 --- a/02_ruway/shuma/shuma-cli/Cargo.toml +++ b/02_ruway/shuma/shuma-cli/Cargo.toml @@ -13,11 +13,12 @@ name = "shuma" path = "src/main.rs" [dependencies] +bitacora = { workspace = true } shuma-card = { path = "../sandbox/shuma-card" } shuma-protocol = { path = "../sandbox/shuma-protocol" } -card-core = { workspace = true } anyhow = { workspace = true } clap = { workspace = true } tokio = { workspace = true } -serde_json = { workspace = true } ulid = { workspace = true } +# Modo raw del terminal + winsize para `shuma pty attach` (cliente full-duplex). +libc = { workspace = true } diff --git a/02_ruway/shuma/shuma-cli/LEEME.md b/02_ruway/shuma/shuma-cli/LEEME.md index 664ed3f..29690bc 100644 --- a/02_ruway/shuma/shuma-cli/LEEME.md +++ b/02_ruway/shuma/shuma-cli/LEEME.md @@ -12,4 +12,4 @@ cargo run --release -p shuma-cli ## Deps -- [`shuma-core`](../shuma-core/README.md), [`shuma-line`](../shuma-line/README.md), [`shuma-exec`](../shuma-exec/README.md) +- [`shuma-core`](../sandbox/shuma-core/README.md), [`shuma-line`](../sandbox/shuma-line/README.md), [`shuma-exec`](../sandbox/shuma-exec/README.md) diff --git a/02_ruway/shuma/shuma-cli/src/main.rs b/02_ruway/shuma/shuma-cli/src/main.rs index d644ecb..f69b4af 100644 --- a/02_ruway/shuma/shuma-cli/src/main.rs +++ b/02_ruway/shuma/shuma-cli/src/main.rs @@ -84,6 +84,41 @@ enum Cmd { /// Flow data plane (subscribirse a streams enriquecidos). #[command(subcommand)] Flow(FlowCmd), + + /// Sesiones PTY persistentes (tmux-like): viven en el daemon, sobreviven + /// a la desconexión del cliente. Spawn / ls / attach / kill. + #[command(subcommand)] + Pty(PtyCmd), +} + +#[derive(Subcommand, Debug)] +enum PtyCmd { + /// Spawnear una sesión persistente y devolver su id (no se adjunta). + Spawn { + /// Etiqueta legible para listar (p. ej. "claude · repo X"). + #[arg(long, default_value = "")] + label: String, + /// Directorio de trabajo (default: el cwd actual). + #[arg(long)] + cwd: Option, + /// Programa a correr bajo el PTY. + program: String, + /// Argumentos del programa. + args: Vec, + }, + /// Listar las sesiones (vivas y terminadas-no-reapeadas). + Ls, + /// Adjuntarse a una sesión: terminal full-duplex. Ctrl-] desadjunta + /// (la sesión sigue viva); el proceso al terminar cierra la vista. + Attach { + /// ULID de la sesión. + session: String, + }, + /// Matar (o reapear, si ya murió) una sesión y quitarla del registro. + Kill { + /// ULID de la sesión. + session: String, + }, } #[derive(Subcommand, Debug)] @@ -183,6 +218,7 @@ enum WsCmd { #[tokio::main] async fn main() -> Result<()> { + bitacora::abrir("shuma"); let cli = Cli::parse(); let socket = cli.socket.unwrap_or_else(default_socket_path); let mut stream = UnixStream::connect(&socket) @@ -639,6 +675,75 @@ async fn main() -> Result<()> { } } + Cmd::Pty(PtyCmd::Spawn { label, cwd, program, args }) => { + let cwd = cwd + .or_else(|| std::env::current_dir().ok()) + .map(|p| p.display().to_string()) + .unwrap_or_else(|| "/".into()); + let (rows, cols) = term_size(); + let resp = round_trip( + &mut stream, + Request::PtySpawn { cwd, program, args, rows, cols, label }, + ) + .await?; + match resp { + Response::PtySpawned { session } => { + println!("{session}"); + eprintln!("adjuntate con: shuma pty attach {session}"); + } + Response::Error { message } => return Err(anyhow!(message)), + other => print_unexpected(&other), + } + } + + Cmd::Pty(PtyCmd::Ls) => { + let resp = round_trip(&mut stream, Request::PtyList).await?; + match resp { + Response::PtyList { sessions } => { + if sessions.is_empty() { + println!("(sin sesiones)"); + } + for s in sessions { + let estado = if s.alive { + format!("viva ({} adj)", s.attached) + } else { + format!("muerta (exit {})", s.exit_code.unwrap_or(-1)) + }; + let cmd = if s.args.is_empty() { + s.program.clone() + } else { + format!("{} {}", s.program, s.args.join(" ")) + }; + println!("{} {:<22} {:<18} {}", s.session, s.label, estado, cmd); + } + } + Response::Error { message } => return Err(anyhow!(message)), + other => print_unexpected(&other), + } + } + + Cmd::Pty(PtyCmd::Kill { session }) => { + let id = Ulid::from_string(&session).map_err(|e| anyhow!("id inválido: {e}"))?; + let resp = round_trip(&mut stream, Request::PtyKill { session: id }).await?; + match resp { + Response::PtyKilled { session, existed } => { + if existed { + println!("matada {session}"); + } else { + eprintln!("no existía: {session}"); + } + } + Response::Error { message } => return Err(anyhow!(message)), + other => print_unexpected(&other), + } + } + + Cmd::Pty(PtyCmd::Attach { session }) => { + let id = Ulid::from_string(&session).map_err(|e| anyhow!("id inválido: {e}"))?; + attach_pty(stream, id).await?; + return Ok(()); + } + Cmd::Discern { path } => { let bytes = std::fs::read(&path).with_context(|| format!("read {}", path.display()))?; // Sample: hasta 4 KiB. @@ -738,3 +843,151 @@ async fn tail_socket(socket: &std::path::Path) -> Result<()> { } Ok(()) } + +// =================================================================== +// `shuma pty attach` — cliente full-duplex de una sesión persistente. +// =================================================================== + +/// Tamaño actual del terminal `(filas, columnas)`; `(24, 80)` si no es un tty. +fn term_size() -> (u16, u16) { + unsafe { + let mut ws: libc::winsize = std::mem::zeroed(); + if libc::ioctl(libc::STDIN_FILENO, libc::TIOCGWINSZ, &mut ws) == 0 && ws.ws_row > 0 { + (ws.ws_row, ws.ws_col) + } else { + (24, 80) + } + } +} + +/// Pone el terminal en modo raw mientras está vivo y lo restaura al dropear +/// (incluso si `attach_pty` retorna por error o panic). +struct RawGuard { + fd: i32, + orig: libc::termios, +} + +impl RawGuard { + fn enter() -> Option { + let fd = libc::STDIN_FILENO; + unsafe { + let mut orig: libc::termios = std::mem::zeroed(); + if libc::tcgetattr(fd, &mut orig) != 0 { + return None; + } + let mut raw = orig; + libc::cfmakeraw(&mut raw); + if libc::tcsetattr(fd, libc::TCSANOW, &raw) != 0 { + return None; + } + Some(RawGuard { fd, orig }) + } + } +} + +impl Drop for RawGuard { + fn drop(&mut self) { + unsafe { + libc::tcsetattr(self.fd, libc::TCSANOW, &self.orig); + } + } +} + +/// Cómo terminó el attach. +enum AttachEnd { + /// La sesión (su proceso) terminó con este código (`None` si fallo/EOF). + Exited(Option), + /// El usuario se desadjuntó (Ctrl-] o stdin EOF) — la sesión sigue viva. + Detached, +} + +/// Cliente full-duplex de `PtyAttach`: terminal en raw, teclas → `PtyInput`, +/// resizes (SIGWINCH) → `PtyResize`, y los `ExecBytes` del daemon → stdout. +/// **Ctrl-]** (0x1d) desadjunta sin matar la sesión. +async fn attach_pty(mut stream: UnixStream, session: Ulid) -> Result<()> { + use std::io::Write as _; + use tokio::io::AsyncReadExt as _; + + let (rows, cols) = term_size(); + write_frame(&mut stream, &Request::PtyAttach { session, rows, cols }).await?; + + let raw = RawGuard::enter(); + if raw.is_none() { + eprintln!("aviso: no se pudo poner el terminal en raw (¿no es un tty?)"); + } + + let (mut rd, mut wr) = tokio::io::split(stream); + + // Lectora: ExecBytes → stdout; terminal → devuelve el exit code. + let read_task = tokio::spawn(async move { + loop { + match read_frame::(&mut rd).await { + Ok(Response::ExecBytes(b)) => { + let mut out = std::io::stdout(); + let _ = out.write_all(&b); + let _ = out.flush(); + } + Ok(Response::ExecExited(c)) => return Some(c), + Ok(Response::ExecFailed(m)) => { + let _ = writeln!(std::io::stderr(), "\r\n✘ {m}"); + return None; + } + Ok(_) => {} + Err(_) => return None, + } + } + }); + + let mut sigwinch = + tokio::signal::unix::signal(tokio::signal::unix::SignalKind::window_change())?; + let mut stdin = tokio::io::stdin(); + let mut buf = [0u8; 4096]; + + let mut read_task = read_task; // mut para `&mut read_task` en select! + let end: AttachEnd; + loop { + tokio::select! { + res = &mut read_task => { + end = AttachEnd::Exited(res.unwrap_or(None)); + break; + } + n = stdin.read(&mut buf) => { + match n { + Ok(0) => { end = AttachEnd::Detached; break; } // stdin EOF + Ok(n) => { + // Ctrl-] (0x1d) en cualquier parte del buffer = detach. + if buf[..n].contains(&0x1d) { + end = AttachEnd::Detached; + break; + } + if write_frame(&mut wr, &Request::PtyInput { bytes: buf[..n].to_vec() }) + .await + .is_err() + { + end = AttachEnd::Detached; + break; + } + } + Err(_) => { end = AttachEnd::Detached; break; } + } + } + _ = sigwinch.recv() => { + let (rows, cols) = term_size(); + let _ = write_frame(&mut wr, &Request::PtyResize { rows, cols }).await; + } + } + } + + // Restaurar el terminal antes del mensaje final. + drop(raw); + match end { + AttachEnd::Exited(Some(c)) => eprintln!("\r\n— sesión terminó (exit {c}) —"), + AttachEnd::Exited(None) => eprintln!("\r\n— sesión cerrada —"), + AttachEnd::Detached => { + // Cerrar la conexión = detach del lado del daemon (no la mata). + read_task.abort(); + eprintln!("\r\n— desadjuntado (la sesión sigue viva: `shuma pty ls`) —"); + } + } + Ok(()) +} diff --git a/02_ruway/shuma/shuma-daemon/Cargo.toml b/02_ruway/shuma/shuma-daemon/Cargo.toml index 8c6246d..3f76b76 100644 --- a/02_ruway/shuma/shuma-daemon/Cargo.toml +++ b/02_ruway/shuma/shuma-daemon/Cargo.toml @@ -13,12 +13,15 @@ name = "shuma-daemon" path = "src/main.rs" [dependencies] +bitacora = { workspace = true } shuma-card = { path = "../sandbox/shuma-card" } shuma-protocol = { path = "../sandbox/shuma-protocol" } -shuma-discern = { workspace = true } +shuma-discern = { path = "../sandbox/shuma-discern" } shuma-core = { path = "../sandbox/shuma-core" } shuma-exec = { path = "../sandbox/shuma-exec" } +shuma-config = { path = "../sandbox/shuma-config" } shuma-link = { path = "../sandbox/shuma-link" } +shuma-consola-host = { path = "../sandbox/shuma-consola-host" } arje-incarnate = { workspace = true } card-core = { workspace = true } card-sidecar = { workspace = true } @@ -27,8 +30,12 @@ tokio = { workspace = true } tracing = { workspace = true } tracing-subscriber = { workspace = true } ulid = { workspace = true } +vt100 = { workspace = true } nix = { workspace = true } libc = { workspace = true } [dev-dependencies] tempfile = { workspace = true } +shuma-remote-exec = { path = "../sandbox/shuma-remote-exec" } +shuma-module-shell = { path = "../sandbox/shuma-module-shell" } +shuma-module = { path = "../sandbox/shuma-module" } diff --git a/02_ruway/shuma/shuma-daemon/LEEME.md b/02_ruway/shuma/shuma-daemon/LEEME.md index 835523d..194f5da 100644 --- a/02_ruway/shuma/shuma-daemon/LEEME.md +++ b/02_ruway/shuma/shuma-daemon/LEEME.md @@ -12,4 +12,4 @@ cargo run --release -p shuma-daemon -- --listen unix:/tmp/shuma.sock ## Deps -- [`shuma-core`](../shuma-core/README.md), [`shuma-session`](../shuma-session/README.md), [`shuma-protocol`](../shuma-protocol/README.md) +- [`shuma-core`](../sandbox/shuma-core/README.md), [`shuma-session`](../sandbox/shuma-session/README.md), [`shuma-protocol`](../sandbox/shuma-protocol/README.md) diff --git a/02_ruway/shuma/shuma-daemon/src/main.rs b/02_ruway/shuma/shuma-daemon/src/main.rs index ff4f66d..28f31f4 100644 --- a/02_ruway/shuma/shuma-daemon/src/main.rs +++ b/02_ruway/shuma/shuma-daemon/src/main.rs @@ -25,8 +25,12 @@ use std::sync::Arc; use tokio::net::{UnixListener, UnixStream}; use tracing::{error, info, warn}; +mod pty_sessions; +use pty_sessions::{PtyRegistry, SessionEvent}; + #[tokio::main] async fn main() -> anyhow::Result<()> { + bitacora::abrir("shuma"); init_tracing(); let sock = default_socket_path(); let pid_path = pid_path_for(&sock); @@ -102,7 +106,7 @@ async fn main() -> anyhow::Result<()> { } }; // Relauncher de live_pipelines: como necesita inc+disc del daemon, - // lo hacemos acá tras el restore. Cada uno mismo flujo que un run + // lo hacemos aquí tras el restore. Cada uno mismo flujo que un run // normal — register_pipeline_commands + register_pipeline_supervisor. for entry in restore.live_pipelines { let inc = mgr.incarnator_handle(); @@ -168,6 +172,16 @@ async fn main() -> anyhow::Result<()> { let discerner = Arc::new(DiscernPipeline::default_pipeline()); + // Registro de sesiones PTY persistentes (tmux-like). Compartido por + // los dos listeners; vive lo que viva el daemon. + let pty_registry = Arc::new(PtyRegistry::default()); + + // Registro de la consola de claudes agénticos (sesiones de Claude Code + // vivas y reanudables). Mismo desacople que el PTY: sobrevive a la + // desconexión del cliente. El móvil lo consulta por polling vía el + // gateway. Ver `shuma-consola-host`. + let consola_registry = Arc::new(shuma_consola_host::ConsolaRegistro::con_claude()); + // Reaper periódico cada 500 ms. Además drena pipelines pendientes // de restart (supervisión a nivel pipeline). { @@ -267,6 +281,8 @@ async fn main() -> anyhow::Result<()> { let mgr_tcp = mgr.clone(); let disc_tcp = discerner.clone(); let pool_tcp = sidecar_pool.clone(); + let pty_tcp = pty_registry.clone(); + let consola_tcp = consola_registry.clone(); let daemon_started_tcp = daemon_started; tokio::spawn(async move { loop { @@ -279,6 +295,8 @@ async fn main() -> anyhow::Result<()> { let mgr = mgr_tcp.clone(); let disc = disc_tcp.clone(); let pool = pool_tcp.clone(); + let pty = pty_tcp.clone(); + let consola = consola_tcp.clone(); tokio::spawn(async move { if let Err(e) = handle_enc_client( tcp, @@ -287,6 +305,8 @@ async fn main() -> anyhow::Result<()> { mgr, disc, pool, + pty, + consola, daemon_started_tcp, ) .await @@ -328,8 +348,10 @@ async fn main() -> anyhow::Result<()> { let mgr = mgr.clone(); let disc = discerner.clone(); let pool = sidecar_pool.clone(); + let pty = pty_registry.clone(); + let consola = consola_registry.clone(); tokio::spawn(async move { - if let Err(e) = handle_client(stream, mgr, disc, pool, daemon_started).await { + if let Err(e) = handle_client(stream, mgr, disc, pool, pty, consola, daemon_started).await { warn!(?e, "client handler error"); } }); @@ -368,6 +390,8 @@ async fn handle_client( mgr: Arc, disc: Arc, pool: Option>, + pty: Arc, + consola: Arc, daemon_started: std::time::Instant, ) -> anyhow::Result<()> { // Audit: peer uid lo leemos una vez aquí (no cambia durante la conexión). @@ -395,8 +419,13 @@ async fn handle_client( if let Request::ExecPty { cwd, program, args, rows, cols } = req { return handle_pty_stream(stream, cwd, program, args, rows, cols).await; } + // Adjuntarse a una sesión persistente: full-duplex hasta que el + // cliente cierra (DETACH) o el proceso de la sesión muere. + if let Request::PtyAttach { session, rows, cols } = req { + return handle_pty_attach(stream, &pty, session, rows, cols).await; + } - let resp = dispatch(&mgr, &disc, &pool, daemon_started, req).await; + let resp = dispatch(&mgr, &disc, &pool, &pty, &consola, daemon_started, req).await; write_frame(&mut stream, &resp).await?; } } @@ -428,6 +457,7 @@ async fn handle_exec_stream( capture_limit: capture_limit_bytes, spill_path: None, // el cliente no expone path local del daemon stdin_data, + env: Vec::new(), capture_stages, }; let mut handle = shuma_exec::run(&spec); @@ -680,10 +710,184 @@ fn pty_spec( capture_limit: 0, spill_path: None, stdin_data: None, + env: Vec::new(), capture_stages: false, } } +/// Adjunta una conexión Unix a una sesión PTY persistente: manda el +/// scrollback, luego la salida en vivo, y reenvía teclas/resizes al PTY. +/// Cerrar la conexión = **DETACH** (no mata la sesión); la sesión sólo +/// muere al terminar su proceso o por `PtyKill`. +async fn handle_pty_attach( + stream: UnixStream, + pty: &Arc, + session: ulid::Ulid, + rows: u16, + cols: u16, +) -> anyhow::Result<()> { + let (mut rd, mut wr) = tokio::io::split(stream); + let Some(sess) = pty.get(session) else { + let _ = write_frame( + &mut wr, + &Response::ExecFailed(format!("sesión {session} no existe")), + ) + .await; + return Ok(()); + }; + sess.resize(rows, cols); + let att = sess.attach(); + + // Tarea lectora: teclas/resizes del cliente → PTY. EOF/error = el + // cliente cerró → DETACH (no matamos la sesión, sólo salimos). + let sess_in = Arc::clone(&sess); + let mut reader = tokio::spawn(async move { + loop { + match read_frame::(&mut rd).await { + Ok(Request::PtyInput { bytes }) => sess_in.write_input(bytes), + Ok(Request::PtyResize { rows, cols }) => sess_in.resize(rows, cols), + Ok(_) => {} + Err(_) => break, + } + } + }); + + // Scrollback inicial para repintar la pantalla. + if !att.scrollback.is_empty() + && write_frame(&mut wr, &Response::ExecBytes(att.scrollback)) + .await + .is_err() + { + reader.abort(); + return Ok(()); + } + // Sesión ya muerta al adjuntarse: el `Exited` ya se broadcasteó antes + // de nuestra suscripción, así que lo sintetizamos y cerramos. + if let Some(code) = att.exited { + let _ = write_frame(&mut wr, &Response::ExecExited(code)).await; + reader.abort(); + return Ok(()); + } + let mut rx = att.rx; + loop { + tokio::select! { + // El cliente cerró su lado = DETACH, aun sin tráfico de salida + // (idle): cortamos ya para que el `attached` baje al instante. + _ = &mut reader => break, + ev = rx.recv() => match ev { + Ok(SessionEvent::Bytes(b)) => { + if write_frame(&mut wr, &Response::ExecBytes(b.as_ref().clone())) + .await + .is_err() + { + break; + } + } + Ok(SessionEvent::Exited(c)) => { + let _ = write_frame(&mut wr, &Response::ExecExited(c)).await; + break; + } + // El cliente quedó atrás: repintamos el ring entero en vez de + // arrastrar bytes perdidos (corromperían el vt100). + Err(tokio::sync::broadcast::error::RecvError::Lagged(_)) => { + if write_frame(&mut wr, &Response::ExecBytes(sess.scrollback())) + .await + .is_err() + { + break; + } + } + Err(tokio::sync::broadcast::error::RecvError::Closed) => break, + } + } + } + reader.abort(); + Ok(()) +} + +/// Versión cifrada de [`handle_pty_attach`] sobre un `FramedChannel`. +async fn handle_pty_attach_enc( + ch: FramedChannel, + pty: &Arc, + session: ulid::Ulid, + rows: u16, + cols: u16, +) -> anyhow::Result<()> +where + S: tokio::io::AsyncRead + tokio::io::AsyncWrite + Unpin + Send + 'static, +{ + let (mut rd, mut wr) = ch.split(); + let Some(sess) = pty.get(session) else { + let _ = wr + .send_postcard(&Response::ExecFailed(format!("sesión {session} no existe"))) + .await; + return Ok(()); + }; + sess.resize(rows, cols); + let att = sess.attach(); + + let sess_in = Arc::clone(&sess); + let mut reader = tokio::spawn(async move { + loop { + match rd.recv_postcard::().await { + Ok(Request::PtyInput { bytes }) => sess_in.write_input(bytes), + Ok(Request::PtyResize { rows, cols }) => sess_in.resize(rows, cols), + Ok(_) => {} + Err(_) => break, + } + } + }); + + if !att.scrollback.is_empty() + && wr + .send_postcard(&Response::ExecBytes(att.scrollback)) + .await + .is_err() + { + reader.abort(); + return Ok(()); + } + if let Some(code) = att.exited { + let _ = wr.send_postcard(&Response::ExecExited(code)).await; + reader.abort(); + return Ok(()); + } + let mut rx = att.rx; + loop { + tokio::select! { + // Detach idle: el cliente cerró su lado sin que hubiera salida. + _ = &mut reader => break, + ev = rx.recv() => match ev { + Ok(SessionEvent::Bytes(b)) => { + if wr + .send_postcard(&Response::ExecBytes(b.as_ref().clone())) + .await + .is_err() + { + break; + } + } + Ok(SessionEvent::Exited(c)) => { + let _ = wr.send_postcard(&Response::ExecExited(c)).await; + break; + } + Err(tokio::sync::broadcast::error::RecvError::Lagged(_)) => { + if wr + .send_postcard(&Response::ExecBytes(sess.scrollback())) + .await + .is_err() + { + break; + } + } + Err(tokio::sync::broadcast::error::RecvError::Closed) => break, + } + } + } + reader.abort(); + Ok(()) +} + /// Atiende una conexión TCP autenticada por Noise XK. /// /// Flujo: @@ -699,6 +903,7 @@ fn pty_spec( /// salida), un cliente que cierra TCP no dispara el kill hasta que el /// proceso emita algo. Se mejora cuando se añada un frame Cancel del /// cliente o se splittea el FramedChannel en mitades sender/receiver. +#[allow(clippy::too_many_arguments)] async fn handle_enc_client( tcp: tokio::net::TcpStream, our_keypair: shuma_link::Keypair, @@ -706,6 +911,8 @@ async fn handle_enc_client( mgr: Arc, disc: Arc, pool: Option>, + pty: Arc, + consola: Arc, daemon_started: std::time::Instant, ) -> anyhow::Result<()> { let (mut ch, peer) = shuma_link::server_handshake(tcp, &our_keypair) @@ -742,8 +949,12 @@ async fn handle_enc_client( if let Request::ExecPty { cwd, program, args, rows, cols } = req { return handle_pty_stream_enc(ch, cwd, program, args, rows, cols).await; } + // Adjuntarse a una sesión persistente sobre el canal cifrado. + if let Request::PtyAttach { session, rows, cols } = req { + return handle_pty_attach_enc(ch, &pty, session, rows, cols).await; + } - let resp = dispatch(&mgr, &disc, &pool, daemon_started, req).await; + let resp = dispatch(&mgr, &disc, &pool, &pty, &consola, daemon_started, req).await; if let Err(e) = ch.send_postcard(&resp).await { return Err(anyhow::anyhow!("send: {e}")); } @@ -781,6 +992,7 @@ where capture_limit: capture_limit_bytes, spill_path: None, stdin_data, + env: Vec::new(), capture_stages, }; let mut handle = shuma_exec::run(&spec); @@ -884,11 +1096,31 @@ fn audit_request(peer: &str, req: &Request) { "exec.pty", format!("cwd={cwd} {program} {}", args.join(" ")), ), + Request::PtySpawn { cwd, program, args, label, .. } => ( + "pty.spawn", + format!("cwd={cwd} label={label:?} {program} {}", args.join(" ")), + ), + Request::PtyAttach { session, .. } => ("pty.attach", format!("session={session}")), + Request::PtyKill { session } => ("pty.kill", format!("session={session}")), + Request::PtySendInput { session, bytes } => { + ("pty.input", format!("session={session} bytes={}", bytes.len())) + } + // Consola de claudes: se auditan las que mutan (crear/enviar/kill); + // la lista y el snapshot son reads de polling (no audit, ver abajo). + Request::ConsolaCrear { cwd, .. } => ("consola.crear", format!("cwd={cwd}")), + Request::ConsolaEnviar { id, .. } => ("consola.enviar", format!("id={id}")), + Request::ConsolaKill { id } => ("consola.kill", format!("id={id}")), + // Reads / alta frecuencia (no audit). Las teclas y resizes de un - // PTY no se auditan línea a línea — la apertura (`exec.pty`) ya - // quedó registrada. - Request::PtyInput { .. } + // PTY no se auditan línea a línea — la apertura (`exec.pty` / + // `pty.spawn`) ya quedó registrada. + Request::ConsolaList + | Request::ConsolaSnapshot { .. } + | Request::ConsolaLeida { .. } + | Request::PtyInput { .. } | Request::PtyResize { .. } + | Request::PtyList + | Request::PtySnapshot { .. } | Request::Ping | Request::Health | Request::WorkspaceList @@ -922,6 +1154,8 @@ async fn dispatch( mgr: &Arc, disc: &DiscernPipeline, pool: &Option>, + pty: &Arc, + consola: &Arc, daemon_started: std::time::Instant, req: Request, ) -> Response { @@ -1283,12 +1517,88 @@ async fn dispatch( } } - // `ExecStream`/`ExecPty` se atienden inline en `handle_client` con - // sus subprotocolos; los frames `PtyInput`/`PtyResize` sólo viven - // dentro de un `ExecPty` ya en curso. Nunca deberían llegar a - // `dispatch` (request/response 1:1). Si lo hacen, error explícito. + // Sesiones PTY persistentes. `PtySpawn`/`PtyList`/`PtyKill` son + // request/response 1:1 y se atienden aquí; `PtyAttach` es + // full-duplex y se intercepta inline antes de `dispatch`. + Request::PtySpawn { cwd, program, args, rows, cols, label } => { + let session = pty.spawn(cwd, program, args, rows, cols, label); + Response::PtySpawned { session } + } + Request::PtyList => Response::PtyList { sessions: pty.list() }, + Request::PtyKill { session } => Response::PtyKilled { + session, + existed: pty.kill(session), + }, + Request::PtySendInput { session, bytes } => { + let existed = match pty.get(session) { + Some(s) => { + s.write_input(bytes); + true + } + None => false, + }; + Response::PtyInputSent { session, existed } + } + Request::PtySnapshot { session, rows, cols } => match pty.get(session) { + Some(s) => { + // Parsear el anillo es CPU (hasta 256 KB por sesión): va en el + // pool de bloqueantes, no en el hilo del reactor. + let (title, lines) = + tokio::task::spawn_blocking(move || s.render(rows.max(1), cols.max(1))) + .await + .unwrap_or((None, Vec::new())); + Response::PtySnapshot { session, existed: true, title, lines } + } + None => Response::PtySnapshot { + session, + existed: false, + title: None, + lines: Vec::new(), + }, + }, + + // Consola de claudes: todo request/response 1:1 (polling). El registro + // corre cada sesión en su propio hilo; aquí sólo consultamos/comandamos. + Request::ConsolaList => { + let sesiones = consola + .list() + .into_iter() + .map(|r| shuma_protocol::ConsolaResumen { + id: r.id, + titulo: r.titulo, + estado: r.estado, + atencion: r.atencion, + actualizada: r.actualizada, + }) + .collect(); + Response::ConsolaList { sesiones } + } + Request::ConsolaCrear { cwd, prompt, model } => match consola.crear(cwd, prompt, model) { + Ok(id) => Response::ConsolaCreada { id }, + Err(e) => Response::Error { message: format!("consola crear: {e}") }, + }, + Request::ConsolaEnviar { id, prompt } => match consola.enviar(&id, prompt) { + Ok(existed) => Response::ConsolaOk { existed }, + Err(e) => Response::Error { message: format!("consola enviar: {e}") }, + }, + Request::ConsolaSnapshot { id } => Response::ConsolaSnapshot { + sesion: consola.snapshot(&id), + }, + Request::ConsolaLeida { id } => { + consola.marcar_leido(&id); + Response::ConsolaOk { existed: true } + } + Request::ConsolaKill { id } => Response::ConsolaOk { + existed: consola.kill(&id), + }, + + // `ExecStream`/`ExecPty`/`PtyAttach` se atienden inline en los + // handlers de conexión con sus subprotocolos full-duplex; los + // frames `PtyInput`/`PtyResize` sólo viven dentro de uno ya en + // curso. Nunca deberían llegar a `dispatch` (request/response 1:1). Request::ExecStream { .. } | Request::ExecPty { .. } + | Request::PtyAttach { .. } | Request::PtyInput { .. } | Request::PtyResize { .. } => Response::Error { message: "frame de streaming/PTY fuera de su subprotocolo; no por dispatch".into(), @@ -1431,7 +1741,8 @@ fn build_daemon_card(service_socket: &std::path::Path) -> Card { fn init_tracing() { use tracing_subscriber::{fmt, EnvFilter}; let filter = EnvFilter::try_from_env("SHIPOTE_LOG").unwrap_or_else(|_| EnvFilter::new("info")); - fmt().with_env_filter(filter).init(); + // try_init: bitacora::abrir ya puede haber instalado el subscriber global. + let _ = fmt().with_env_filter(filter).try_init(); } /// Path del lockfile asociado al socket admin: mismo dir, extensión `.pid`. diff --git a/02_ruway/shuma/shuma-daemon/src/pty_sessions.rs b/02_ruway/shuma/shuma-daemon/src/pty_sessions.rs new file mode 100644 index 0000000..5ad2665 --- /dev/null +++ b/02_ruway/shuma/shuma-daemon/src/pty_sessions.rs @@ -0,0 +1,409 @@ +//! Registro de sesiones PTY persistentes (tmux-like) del daemon. +//! +//! Una sesión es un proceso bajo pseudo-terminal cuyo ciclo de vida está +//! **desacoplado de cualquier conexión**: el cliente se adjunta y se +//! desadjunta libremente; cerrar la conexión NO mata el proceso. El +//! proceso sólo muere si termina solo o se le manda `PtyKill`. (No +//! persiste a reinicio del daemon — igual que tmux pierde sus sesiones si +//! matas el servidor.) +//! +//! Diseño: +//! - Un hilo de fondo por sesión drena `RunHandle::next_event()` (API +//! bloqueante de `shuma-exec`) hacia dos sumideros: el **ring** (los +//! últimos `RING_CAP` bytes, para repintar a quien (re)adjunta) y un +//! canal **broadcast** (la salida en vivo a los clientes adjuntos). +//! - `Shared` (buffer + alive + exit) está bajo un único `Mutex`, y el +//! drain hace *push/marcar-muerto + broadcast* mientras lo tiene tomado. +//! Un cliente que se adjunta toma ese mismo lock para suscribirse y +//! sacar el snapshot a la vez → ni pierde ni duplica bytes en la +//! frontera scrollback↔vivo. + +use std::collections::{HashMap, VecDeque}; +use std::sync::{Arc, Mutex}; +use std::time::SystemTime; + +use shuma_exec::RunEvent; +use shuma_protocol::PtySessionInfo; +use tokio::sync::broadcast; +use ulid::Ulid; + +/// Bytes de scrollback retenidos por sesión. Un terminal típico cabe de +/// sobra; al exceder, se descartan los más viejos (anillo). +const RING_CAP: usize = 256 * 1024; + +/// Capacidad del broadcast de salida por sesión. Si un cliente adjunto se +/// atrasa más de esto, recibe `Lagged` y le repintamos el scrollback +/// completo en vez de arrastrar bytes perdidos (que corromperían la +/// pantalla vt100). +const BROADCAST_CAP: usize = 1024; + +/// Un frame de salida de la sesión hacia los clientes adjuntos. +#[derive(Clone)] +pub enum SessionEvent { + /// Bytes crudos del terminal. `Arc` para no clonar el buffer una vez + /// por subscriber del broadcast. + Bytes(Arc>), + /// La sesión terminó con este código. + Exited(i32), +} + +/// Estado mutable protegido por un único lock: el ring de scrollback y el +/// estado de vida. Tenerlos juntos hace que *acumular salida*, *marcar +/// muerto* y *suscribirse* sean operaciones bien ordenadas entre sí. +struct Shared { + buf: VecDeque, + alive: bool, + exit: Option, +} + +impl Shared { + fn push(&mut self, bytes: &[u8]) { + self.buf.extend(bytes.iter().copied()); + while self.buf.len() > RING_CAP { + self.buf.pop_front(); + } + } + fn snapshot(&self) -> Vec { + self.buf.iter().copied().collect() + } +} + +/// Metadatos inmutables de una sesión (lo que se reporta en `PtyList`). +struct Meta { + label: String, + program: String, + args: Vec, + cwd: String, + rows: u16, + cols: u16, + created_unix_ms: u64, +} + +/// Una sesión PTY persistente. El registro guarda los handles de control +/// (clonables y desacoplados del lock de eventos); el hilo de drenado +/// mantiene `shared` y `tx` al día. +pub struct PtySession { + meta: Meta, + control: shuma_exec::PtyControl, + killer: shuma_exec::Killer, + shared: Arc>, + tx: broadcast::Sender, +} + +impl PtySession { + /// Reescala el PTY al tamaño del cliente que se adjunta. + pub fn resize(&self, rows: u16, cols: u16) { + self.control.resize(rows, cols); + } + + /// Reenvía teclas al PTY. + pub fn write_input(&self, bytes: Vec) { + self.control.write_input(bytes); + } + + /// Se suscribe al stream en vivo y saca el scrollback **de forma + /// atómica** respecto al drenado: ambos bajo el mismo lock, así no hay + /// hueco ni solape en la frontera. Devuelve el receiver, el snapshot + /// del scrollback, y —si la sesión ya murió— su código de salida (en + /// cuyo caso el `Exited` ya se emitió antes de esta suscripción y hay + /// que sintetizarlo, porque el receiver no lo verá). + pub fn attach(&self) -> Attachment { + let s = self.shared.lock().expect("pty shared lock"); + let rx = self.tx.subscribe(); + let scrollback = s.snapshot(); + let exited = if s.alive { None } else { Some(s.exit.unwrap_or(-1)) }; + Attachment { rx, scrollback, exited } + } + + /// Snapshot del scrollback (para repintar tras un `Lagged`). + pub fn scrollback(&self) -> Vec { + self.shared.lock().expect("pty shared lock").snapshot() + } + + /// **Mira la sesión sin adjuntarse**: corre el anillo por un vt100 de + /// `rows`×`cols` y devuelve (título OSC, filas de la pantalla en texto). + /// + /// El daemon guarda bytes crudos, no pantallas: el título que el programa + /// puso con OSC 0/2 y lo que se ve ahora sólo existen *después* de parsear. + /// Lo hace acá —y no el cliente— para no mandarle 256 KB de anillo por + /// sesión cada vez que quiere una miniatura. + /// + /// Las filas vacías del final se descartan (una pantalla de 24 filas con 6 + /// de texto devuelve 6): quien pinta la miniatura quiere lo último escrito, + /// no el relleno. + pub fn render(&self, rows: u16, cols: u16) -> (Option, Vec) { + /// Sólo nos interesa una cosa del vt100: el título. `vt100::Screen` no + /// lo expone, llega por callback. + #[derive(Default)] + struct Titulero { + titulo: Option, + } + impl vt100::Callbacks for Titulero { + fn set_window_title(&mut self, _: &mut vt100::Screen, titulo: &[u8]) { + let t = String::from_utf8_lossy(titulo) + .chars() + .filter(|c| !c.is_control()) + .collect::() + .trim() + .to_string(); + self.titulo = if t.is_empty() { None } else { Some(t) }; + } + } + + let bytes = self.scrollback(); + // `scrollback_len = 0`: sólo queremos la pantalla visible. + let mut parser = vt100::Parser::new_with_callbacks(rows, cols, 0, Titulero::default()); + parser.process(&bytes); + let mut lineas: Vec = parser + .screen() + .rows(0, cols) + .map(|l| l.trim_end().to_string()) + .collect(); + while lineas.last().is_some_and(|l| l.is_empty()) { + lineas.pop(); + } + (parser.callbacks().titulo.clone(), lineas) + } + + fn info(&self, session: Ulid) -> PtySessionInfo { + let s = self.shared.lock().expect("pty shared lock"); + PtySessionInfo { + session, + label: self.meta.label.clone(), + program: self.meta.program.clone(), + args: self.meta.args.clone(), + cwd: self.meta.cwd.clone(), + rows: self.meta.rows, + cols: self.meta.cols, + alive: s.alive, + exit_code: s.exit, + created_unix_ms: self.meta.created_unix_ms, + // `-1`: el sender propio del registro no cuenta como adjunto. + attached: self.tx.receiver_count() as u32, + } + } +} + +/// Resultado de adjuntarse a una sesión. +pub struct Attachment { + pub rx: broadcast::Receiver, + pub scrollback: Vec, + /// `Some(code)` si la sesión ya estaba muerta al adjuntarse — el + /// llamador debe emitir el `ExecExited(code)` él mismo. + pub exited: Option, +} + +/// Registro global de sesiones PTY del daemon. +#[derive(Default)] +pub struct PtyRegistry { + sessions: Mutex>>, +} + +impl PtyRegistry { + /// Crea y registra una sesión: spawnea el proceso bajo PTY y arranca + /// el hilo de drenado. Devuelve el id. + pub fn spawn( + &self, + cwd: String, + program: String, + args: Vec, + rows: u16, + cols: u16, + label: String, + ) -> Ulid { + // El id de la sesión se siembra como `SHUMA_SESSION` en el entorno del + // PTY (lo hereda el shell, claude y sus hooks), para que un aviso de + // hook pueda enlazar a ESTA sesión exacta. Envolvemos con `/usr/bin/ + // env` en vez de tocar `CommandSpec`: el `Meta` guarda el comando + // original (la lista queda limpia), pero el proceso recibe la env. + let id = Ulid::new(); + let mut wrapped = Vec::with_capacity(args.len() + 3); + wrapped.push(format!("SHUMA_SESSION={id}")); + // Grupos de env ACTIVOS (`~/.config/shuma/env.json`, el store del + // builtin `:env` que "sobrevive reinicios"): el daemon suele nacer + // huérfano de sesión (auto-arranque lazy desde pata) y su entorno no + // trae credenciales como `http_proxy` — sin esto, un claude spawneado + // aquí da 403 contra Anthropic y pide /login aunque el proxy esté en + // settings.json (el env de settings no cubre la fase de auth; medido + // 2026-07-17). Se leen por spawn: baratos y siempre frescos. Van + // ANTES de las vars propias de shuma para que éstas ganen ante un + // choque de nombres (`/usr/bin/env` resuelve última-gana). + for g in shuma_config::load_env_groups() { + if !g.active { + continue; + } + for (n, v) in &g.vars { + if !n.is_empty() && !n.contains('=') { + wrapped.push(format!("{n}={v}")); + } + } + } + // claude elige su renderer según lo que el terminal contesta: con las + // queries respondidas (QueryScanner del frontend) activa el fullscreen + // por alt-screen (ESC[?1049h) y la vista consola de shuma nunca + // engancha — el grid crudo es lo único que queda. El kill switch + // oficial lo fuerza al renderer inline, que es el que la consola + // (historia cosechada + cola viva) sabe desplanizar. + if std::path::Path::new(&program) + .file_name() + .is_some_and(|n| n == "claude") + { + wrapped.push("CLAUDE_CODE_DISABLE_ALTERNATE_SCREEN=1".to_string()); + } + wrapped.push(program.clone()); + wrapped.extend(args.iter().cloned()); + let spec = shuma_exec::CommandSpec { + exec: shuma_exec::Exec::Pty { + program: "/usr/bin/env".to_string(), + args: wrapped, + cols, + rows, + }, + cwd: cwd.clone(), + capture_limit: 0, + spill_path: None, + stdin_data: None, + env: Vec::new(), + capture_stages: false, + }; + let mut handle = shuma_exec::run(&spec); + let killer = handle.killer(); + let control = handle.pty_control(); + + let shared = Arc::new(Mutex::new(Shared { + buf: VecDeque::new(), + alive: true, + exit: None, + })); + let (tx, _rx) = broadcast::channel(BROADCAST_CAP); + + // Hilo de drenado: bloquea en `next_event()` y reparte cada evento + // al ring + broadcast, siempre bajo el lock de `shared` para + // ordenar correctamente respecto a quien se suscribe. + { + let shared = Arc::clone(&shared); + let tx = tx.clone(); + std::thread::spawn(move || { + while let Some(ev) = handle.next_event() { + match ev { + RunEvent::Bytes(b) => { + let mut s = shared.lock().expect("pty shared lock"); + s.push(&b); + let _ = tx.send(SessionEvent::Bytes(Arc::new(b))); + } + // Un PTY captura a su pantalla, no por líneas; si + // aún así llegan, las tratamos como bytes crudos. + RunEvent::Stdout(l) | RunEvent::Stderr(l) => { + let bytes = l.into_bytes(); + let mut s = shared.lock().expect("pty shared lock"); + s.push(&bytes); + let _ = tx.send(SessionEvent::Bytes(Arc::new(bytes))); + } + RunEvent::StageStdout { line, .. } => { + let bytes = line.into_bytes(); + let mut s = shared.lock().expect("pty shared lock"); + s.push(&bytes); + let _ = tx.send(SessionEvent::Bytes(Arc::new(bytes))); + } + RunEvent::Exited(c) => { + let mut s = shared.lock().expect("pty shared lock"); + s.alive = false; + s.exit = Some(c); + let _ = tx.send(SessionEvent::Exited(c)); + break; + } + RunEvent::Failed(m) => { + let bytes = m.into_bytes(); + let mut s = shared.lock().expect("pty shared lock"); + s.push(&bytes); + let _ = tx.send(SessionEvent::Bytes(Arc::new(bytes))); + s.alive = false; + s.exit = Some(-1); + let _ = tx.send(SessionEvent::Exited(-1)); + break; + } + RunEvent::Truncated | RunEvent::Spilled(_) => {} + } + } + // Salvaguarda: si `next_event` devuelve `None` sin terminal + // explícito (canal cerrado), marcamos muerta la sesión para + // que los que se adjunten después no queden colgados. + let mut s = shared.lock().expect("pty shared lock"); + if s.alive { + s.alive = false; + s.exit = s.exit.or(Some(-1)); + let _ = tx.send(SessionEvent::Exited(s.exit.unwrap_or(-1))); + } + }); + } + + let label = if label.trim().is_empty() { + program.clone() + } else { + label + }; + let created_unix_ms = SystemTime::now() + .duration_since(SystemTime::UNIX_EPOCH) + .map(|d| d.as_millis() as u64) + .unwrap_or(0); + + let session = Arc::new(PtySession { + meta: Meta { + label, + program, + args, + cwd, + rows, + cols, + created_unix_ms, + }, + control, + killer, + shared, + tx, + }); + + self.sessions + .lock() + .expect("pty registry lock") + .insert(id, session); + id + } + + /// Handle de una sesión por id (para adjuntarse), si existe. + pub fn get(&self, id: Ulid) -> Option> { + self.sessions + .lock() + .expect("pty registry lock") + .get(&id) + .cloned() + } + + /// Todas las sesiones registradas, ordenadas por antigüedad (más + /// nuevas al final). + pub fn list(&self) -> Vec { + let map = self.sessions.lock().expect("pty registry lock"); + let mut out: Vec = map.iter().map(|(id, s)| s.info(*id)).collect(); + out.sort_by_key(|i| i.created_unix_ms); + out + } + + /// Mata (o reapea, si ya murió) y quita del registro. `false` si no + /// existía. Tras quitarla, el `Arc` muere cuando los clientes adjuntos + /// se desadjunten; el SIGKILL hace que el hilo de drenado vea `Exited` + /// y termine solo. + pub fn kill(&self, id: Ulid) -> bool { + let removed = self + .sessions + .lock() + .expect("pty registry lock") + .remove(&id); + match removed { + Some(session) => { + session.killer.kill(); + true + } + None => false, + } + } +} diff --git a/02_ruway/shuma/shuma-daemon/tests/ensure_daemon_e2e.rs b/02_ruway/shuma/shuma-daemon/tests/ensure_daemon_e2e.rs new file mode 100644 index 0000000..e04c590 --- /dev/null +++ b/02_ruway/shuma/shuma-daemon/tests/ensure_daemon_e2e.rs @@ -0,0 +1,72 @@ +//! End-to-end del AUTO-ARRANQUE perezoso (modelo tmux): sin daemon corriendo, +//! `shuma_remote_exec::ensure_daemon` debe lanzarlo desacoplado, esperar el +//! socket y dejarlo usable; una segunda llamada debe encontrarlo vivo. +//! +//! Corre en proceso propio (archivo de test aparte) porque manipula el env +//! (`XDG_RUNTIME_DIR`, `SHUMA_DAEMON_BIN`) del proceso entero. + +use std::path::PathBuf; +use std::time::Duration; + +/// Mata el daemon auto-arrancado leyendo su PID del lockfile. +fn matar_daemon(dir: &std::path::Path) { + if let Ok(pid) = std::fs::read_to_string(dir.join("shuma.pid")) { + let pid = pid.trim(); + if !pid.is_empty() { + let _ = std::process::Command::new("kill").arg("-9").arg(pid).status(); + } + } +} + +#[test] +fn ensure_daemon_lo_arranca_y_lo_reusa() { + // Aislamiento: runtime dir propio → default_socket_path() cae aquí tanto + // para el cliente como para el daemon que se auto-arranque. + let dir: PathBuf = std::env::temp_dir().join(format!("shuma-ensure-{}", std::process::id())); + let _ = std::fs::remove_dir_all(&dir); + std::fs::create_dir_all(&dir).unwrap(); + std::env::set_var("XDG_RUNTIME_DIR", &dir); + std::env::set_var("XDG_STATE_HOME", dir.join("state")); + std::env::set_var("XDG_DATA_HOME", dir.join("data")); + std::env::set_var("XDG_CONFIG_HOME", dir.join("config")); + std::env::set_var("SHUMA_DAEMON_BIN", env!("CARGO_BIN_EXE_shuma-daemon")); + + let sock = shuma_protocol::default_socket_path(); + assert_eq!(sock, dir.join("shuma.sock"), "el socket default debe caer en el dir del test"); + + // 1) Sin daemon: lo arranca (true) y el socket queda usable. + let arrancado = shuma_remote_exec::ensure_daemon(&sock).expect("ensure #1"); + assert!(arrancado, "no había daemon — debía arrancarlo"); + + // 2) Usable de verdad: spawn de una sesión persistente + list la muestra. + let spec = shuma_exec::CommandSpec { + exec: shuma_exec::Exec::Pty { + program: "/bin/cat".into(), + args: vec![], + cols: 80, + rows: 24, + }, + cwd: ".".into(), + capture_limit: 0, + spill_path: None, + stdin_data: None, + env: Vec::new(), + capture_stages: false, + }; + let id = shuma_remote_exec::spawn_session_id(&spec, &sock, "ensure-e2e").expect("spawn"); + let sesiones = shuma_remote_exec::list_sessions(&sock).expect("list"); + assert!(sesiones.iter().any(|s| s.session == id && s.alive)); + + // 3) Segunda llamada: lo encuentra vivo (false), sin arrancar otro. + let arrancado2 = shuma_remote_exec::ensure_daemon(&sock).expect("ensure #2"); + assert!(!arrancado2, "ya había daemon — no debía arrancar otro"); + + // 4) Un socket NO-default no dispara auto-arranque: error limpio. + let ajeno = dir.join("otro.sock"); + assert!(shuma_remote_exec::ensure_daemon(&ajeno).is_err()); + + // Limpieza: matar el daemon (quedó desacoplado a propósito). + matar_daemon(&dir); + std::thread::sleep(Duration::from_millis(100)); + let _ = std::fs::remove_dir_all(&dir); +} diff --git a/02_ruway/shuma/shuma-daemon/tests/pty_sessions_e2e.rs b/02_ruway/shuma/shuma-daemon/tests/pty_sessions_e2e.rs new file mode 100644 index 0000000..ee44c8c --- /dev/null +++ b/02_ruway/shuma/shuma-daemon/tests/pty_sessions_e2e.rs @@ -0,0 +1,249 @@ +//! End-to-end de las sesiones PTY persistentes. +//! +//! Arranca el binario real del daemon en un `XDG_RUNTIME_DIR` temporal +//! (socket aislado) y, por el socket Unix, ejercita el ciclo completo: +//! spawn → attach → escribir → DETACH → re-attach (el scrollback debe +//! sobrevivir) → list → kill. Es la prueba de que la sesión vive +//! desacoplada de la conexión. + +use std::path::{Path, PathBuf}; +use std::process::{Child, Command}; +use std::time::Duration; + +use shuma_protocol::{read_frame, write_frame, Request, Response}; +use tokio::net::UnixStream; +use ulid::Ulid; + +/// Mata el daemon al terminar el test, pase lo que pase. +struct DaemonGuard { + child: Child, +} +impl Drop for DaemonGuard { + fn drop(&mut self) { + let _ = self.child.kill(); + let _ = self.child.wait(); + } +} + +async fn connect(sock: &Path) -> UnixStream { + for _ in 0..200 { + if let Ok(s) = UnixStream::connect(sock).await { + return s; + } + tokio::time::sleep(Duration::from_millis(25)).await; + } + panic!("el socket del daemon nunca apareció en {sock:?}"); +} + +/// Drena frames hasta encontrar `needle` en la salida acumulada, o hasta +/// agotar el timeout / ver el exit. +async fn read_until_contains(s: &mut UnixStream, needle: &[u8], timeout: Duration) -> bool { + let mut acc: Vec = Vec::new(); + let deadline = tokio::time::Instant::now() + timeout; + loop { + let remaining = deadline.saturating_duration_since(tokio::time::Instant::now()); + if remaining.is_zero() { + return false; + } + match tokio::time::timeout(remaining, read_frame::(s)).await { + Ok(Ok(Response::ExecBytes(b))) => { + acc.extend_from_slice(&b); + if acc.windows(needle.len()).any(|w| w == needle) { + return true; + } + } + Ok(Ok(Response::ExecExited(_))) => { + return acc.windows(needle.len()).any(|w| w == needle); + } + Ok(Ok(_)) => {} // otros frames: ignorar + Ok(Err(_)) => return false, // conexión cerrada + Err(_) => return false, // timeout + } + } +} + +#[tokio::test] +async fn pty_session_persists_across_detach() { + let cat = ["/bin/cat", "/usr/bin/cat"] + .into_iter() + .find(|p| Path::new(p).exists()) + .expect("se necesita `cat` para el test"); + + // Aislamiento: directorio runtime/estado propio del test. + let dir: PathBuf = std::env::temp_dir().join(format!("shuma-e2e-{}", std::process::id())); + let _ = std::fs::remove_dir_all(&dir); + std::fs::create_dir_all(&dir).unwrap(); + let sock = dir.join("shuma.sock"); + + let bin = env!("CARGO_BIN_EXE_shuma-daemon"); + let child = Command::new(bin) + .env("XDG_RUNTIME_DIR", &dir) + .env("XDG_STATE_HOME", dir.join("state")) + .env("XDG_DATA_HOME", dir.join("data")) + .env("XDG_CONFIG_HOME", dir.join("config")) + .spawn() + .expect("arrancar shuma-daemon"); + let _guard = DaemonGuard { child }; + + // 1) Crear una sesión `cat` (vive y hace eco por el PTY). + let mut c = connect(&sock).await; + write_frame( + &mut c, + &Request::PtySpawn { + cwd: ".".into(), + program: cat.into(), + args: vec![], + rows: 24, + cols: 80, + label: "e2e".into(), + }, + ) + .await + .unwrap(); + let session: Ulid = match read_frame::(&mut c).await.unwrap() { + Response::PtySpawned { session } => session, + other => panic!("esperaba PtySpawned, vino {other:?}"), + }; + drop(c); + + // 2) Attach #1: escribe una marca y confirma el eco en vivo. + let mut a1 = connect(&sock).await; + write_frame( + &mut a1, + &Request::PtyAttach { session, rows: 24, cols: 80 }, + ) + .await + .unwrap(); + write_frame( + &mut a1, + &Request::PtyInput { bytes: b"marca-uno\n".to_vec() }, + ) + .await + .unwrap(); + assert!( + read_until_contains(&mut a1, b"marca-uno", Duration::from_secs(5)).await, + "attach #1 no recibió el eco de marca-uno" + ); + drop(a1); // DETACH — NO debe matar la sesión. + + // 3) Attach #2 (conexión nueva): el scrollback debe traer la marca + // previa → la sesión sobrevivió al detach. + tokio::time::sleep(Duration::from_millis(200)).await; + let mut a2 = connect(&sock).await; + write_frame( + &mut a2, + &Request::PtyAttach { session, rows: 24, cols: 80 }, + ) + .await + .unwrap(); + assert!( + read_until_contains(&mut a2, b"marca-uno", Duration::from_secs(5)).await, + "attach #2 no vio el scrollback persistido tras el detach" + ); + drop(a2); + + // 4) List: la sesión sigue viva y con su etiqueta. + let mut l = connect(&sock).await; + write_frame(&mut l, &Request::PtyList).await.unwrap(); + match read_frame::(&mut l).await.unwrap() { + Response::PtyList { sessions } => { + let s = sessions + .iter() + .find(|s| s.session == session) + .expect("la sesión debe estar en la lista"); + assert!(s.alive, "la sesión debería estar viva"); + assert_eq!(s.label, "e2e"); + assert_eq!(s.program, cat); + } + other => panic!("esperaba PtyList, vino {other:?}"), + } + drop(l); + + // 4.bis) Snapshot: mirar la sesión SIN adjuntarse. El daemon corre el + // anillo por un vt100 y devuelve la pantalla en texto más el título + // OSC — es lo que rotula y muestra en miniatura cada sesión del + // gestor (una sesión del fondo no tiene ventana que capturar). + let mut a3 = connect(&sock).await; + write_frame(&mut a3, &Request::PtyAttach { session, rows: 24, cols: 80 }) + .await + .unwrap(); + // OSC 2 = título de ventana; después una línea de contenido. + write_frame( + &mut a3, + &Request::PtyInput { bytes: b"\x1b]2;mi-titulo-osc\x07visible-en-miniatura\n".to_vec() }, + ) + .await + .unwrap(); + assert!( + read_until_contains(&mut a3, b"visible-en-miniatura", Duration::from_secs(5)).await, + "el eco de la marca de miniatura no llegó" + ); + drop(a3); // detach: el snapshot NO necesita estar adjunto + + let mut sn = connect(&sock).await; + write_frame(&mut sn, &Request::PtySnapshot { session, rows: 24, cols: 80 }) + .await + .unwrap(); + match read_frame::(&mut sn).await.unwrap() { + Response::PtySnapshot { existed, title, lines, session: s } => { + assert_eq!(s, session); + assert!(existed, "la sesión existe"); + assert_eq!( + title.as_deref(), + Some("mi-titulo-osc"), + "el título OSC tiene que llegar parseado, no crudo" + ); + assert!( + lines.iter().any(|l| l.contains("visible-en-miniatura")), + "la pantalla renderizada debe traer la marca; vino {lines:?}" + ); + assert!( + lines.last().is_some_and(|l| !l.is_empty()), + "las filas vacías del final se descartan; vino {lines:?}" + ); + assert!( + !lines.iter().any(|l| l.contains('\x1b')), + "las líneas salen en texto plano, sin escapes; vino {lines:?}" + ); + } + other => panic!("esperaba PtySnapshot, vino {other:?}"), + } + // Sesión inexistente: existed=false y nada más (no es un error). + write_frame( + &mut sn, + &Request::PtySnapshot { session: Ulid::new(), rows: 24, cols: 80 }, + ) + .await + .unwrap(); + match read_frame::(&mut sn).await.unwrap() { + Response::PtySnapshot { existed, lines, .. } => { + assert!(!existed); + assert!(lines.is_empty()); + } + other => panic!("esperaba PtySnapshot, vino {other:?}"), + } + drop(sn); + + // 5) Kill: existed=true y deja de aparecer en la lista. + let mut k = connect(&sock).await; + write_frame(&mut k, &Request::PtyKill { session }).await.unwrap(); + match read_frame::(&mut k).await.unwrap() { + Response::PtyKilled { existed, session: s } => { + assert!(existed, "la sesión debía existir"); + assert_eq!(s, session); + } + other => panic!("esperaba PtyKilled, vino {other:?}"), + } + write_frame(&mut k, &Request::PtyList).await.unwrap(); + match read_frame::(&mut k).await.unwrap() { + Response::PtyList { sessions } => { + assert!( + !sessions.iter().any(|s| s.session == session), + "la sesión no debe seguir listada tras el kill" + ); + } + other => panic!("esperaba PtyList, vino {other:?}"), + } + + let _ = std::fs::remove_dir_all(&dir); +} diff --git a/02_ruway/shuma/shuma-daemon/tests/reattach_e2e.rs b/02_ruway/shuma/shuma-daemon/tests/reattach_e2e.rs new file mode 100644 index 0000000..8a8f9de --- /dev/null +++ b/02_ruway/shuma/shuma-daemon/tests/reattach_e2e.rs @@ -0,0 +1,91 @@ +//! End-to-end de la PERSISTENCIA tipo tmux del módulo shell: +//! +//! 1. `claude` tipeado en el shell → sesión persistente en el daemon +//! (auto-arrancado) en vez de PTY in-process, con el id registrado. +//! 2. "Muere el frontend" (se dropea el State = detach) → un State nuevo +//! con `auto_reattach` re-monta la sesión viva donde estaba. +//! 3. Una sesión muerta no se re-monta: se cosecha del registro. +//! +//! Corre en proceso propio (archivo aparte) porque fija el env global. + +use std::path::PathBuf; +use std::time::Duration; + +use shuma_module::Source; +use shuma_module_shell::{update, Msg, State}; + +fn submit(mut s: State, linea: &str) -> State { + s.input.set_text(linea); + update(s, Msg::Submit) +} + +fn matar_daemon(dir: &std::path::Path) { + if let Ok(pid) = std::fs::read_to_string(dir.join("shuma.pid")) { + let pid = pid.trim(); + if !pid.is_empty() { + let _ = std::process::Command::new("kill").arg("-9").arg(pid).status(); + } + } +} + +#[test] +fn claude_persiste_y_el_frontend_nuevo_se_readjunta() { + let dir: PathBuf = std::env::temp_dir().join(format!("shuma-reattach-{}", std::process::id())); + let _ = std::fs::remove_dir_all(&dir); + std::fs::create_dir_all(&dir).unwrap(); + std::env::set_var("XDG_RUNTIME_DIR", &dir); + std::env::set_var("XDG_STATE_HOME", dir.join("state")); + std::env::set_var("XDG_DATA_HOME", dir.join("data")); + std::env::set_var("XDG_CONFIG_HOME", dir.join("config")); + std::env::set_var("HOME", &dir); // que no lea el config real del usuario + std::env::set_var("SHUMA_DAEMON_BIN", env!("CARGO_BIN_EXE_shuma-daemon")); + let montada = dir.join("shuma-montada"); + let sock = dir.join("shuma.sock"); + + // ── 1) `claude` → sesión persistente (aunque el binario no exista aquí: + // la sesión NACE en el daemon; el exec fallido es asunto suyo). + let s = State::new(Source::Local); + let s = submit(s, "claude"); + assert!(s.running.is_some(), "claude debe montar un run"); + { + let guard = s.running.as_ref().unwrap().lock().unwrap(); + assert!( + guard.session.is_some(), + "el run de claude debe ser sesión persistente del daemon" + ); + } + assert!(montada.exists(), "el id montado debe registrarse para el reattach"); + assert!(sock.exists(), "el daemon debió auto-arrancarse"); + drop(s); // «muere el frontend» — detach, la sesión queda en el daemon + + // ── 2) Sesión VIVA re-adjuntable: montamos una que no muera (`:spawn` + // corre bash -lc, que sí existe) y volvemos a matar el frontend. + let s = State::new(Source::Local); + let s = submit(s, ":spawn sleep 300"); + let id_viva = { + let guard = s.running.as_ref().unwrap().lock().unwrap(); + guard.session.expect(":spawn debe montar sesión persistente") + }; + drop(s); + assert!(montada.exists()); + + // Frontend NUEVO (reinicio del compositor): re-adjunta solo. + let s2 = shuma_module_shell::auto_reattach(State::new(Source::Local)); + assert!(s2.running.is_some(), "auto_reattach debe re-montar la sesión viva"); + { + let guard = s2.running.as_ref().unwrap().lock().unwrap(); + assert_eq!(guard.session, Some(id_viva), "debe volver a LA sesión montada"); + } + drop(s2); + + // ── 3) Sesión MUERTA no se re-monta: matala en el daemon y reintenta. + let _ = shuma_remote_exec::kill_session(&sock, id_viva); + std::thread::sleep(Duration::from_millis(200)); + let s3 = shuma_module_shell::auto_reattach(State::new(Source::Local)); + assert!(s3.running.is_none(), "una sesión matada no debe re-montarse"); + assert!(!montada.exists(), "el registro debe limpiarse al no encontrarla"); + + matar_daemon(&dir); + std::thread::sleep(Duration::from_millis(100)); + let _ = std::fs::remove_dir_all(&dir); +} diff --git a/02_ruway/shuma/shuma-gateway/Cargo.toml b/02_ruway/shuma/shuma-gateway/Cargo.toml index 6cfd01c..5211733 100644 --- a/02_ruway/shuma/shuma-gateway/Cargo.toml +++ b/02_ruway/shuma/shuma-gateway/Cargo.toml @@ -6,16 +6,21 @@ rust-version.workspace = true license.workspace = true authors.workspace = true publish.workspace = true -description = "HTTP/JSON gateway para shipote — traduce JSON ↔ postcard contra el admin socket." +description = "HTTP/JSON + WebSocket gateway para shuma — JSON ↔ postcard y puente WS↔ExecPty." [[bin]] name = "shuma-gateway" path = "src/main.rs" [dependencies] +bitacora = { workspace = true } shuma-protocol = { path = "../sandbox/shuma-protocol" } anyhow = { workspace = true } +serde = { workspace = true } serde_json = { workspace = true } +axum = { workspace = true, features = ["ws"] } +reqwest = { workspace = true } tokio = { workspace = true } tracing = { workspace = true } tracing-subscriber = { workspace = true } +ulid = { workspace = true } diff --git a/02_ruway/shuma/shuma-gateway/LEEME.md b/02_ruway/shuma/shuma-gateway/LEEME.md index 46d71d6..40e2d95 100644 --- a/02_ruway/shuma/shuma-gateway/LEEME.md +++ b/02_ruway/shuma/shuma-gateway/LEEME.md @@ -6,4 +6,4 @@ Proxy entre cliente local y daemons remotos. Útil en redes corporativas donde s ## Deps -- [`shuma-protocol`](../shuma-protocol/README.md) +- [`shuma-protocol`](../sandbox/shuma-protocol/README.md) diff --git a/02_ruway/shuma/shuma-gateway/README.md b/02_ruway/shuma/shuma-gateway/README.md index 9812b60..4dbad9e 100644 --- a/02_ruway/shuma/shuma-gateway/README.md +++ b/02_ruway/shuma/shuma-gateway/README.md @@ -2,8 +2,111 @@ > Remote-session gateway of [shuma](../README.md). -Proxy between local client and remote daemons. Useful on corporate networks where only one host has direct access to the destination. +Adaptador **HTTP/JSON + WebSocket** sobre el admin socket del `shuma-daemon`. +Pensado para clientes no-Rust (app Android, web, curl) que no hablan postcard. + +``` +cliente JSON/WS ──► shuma-gateway ──postcard──► shuma-daemon (unix socket) +``` ## Deps - [`shuma-protocol`](../sandbox/shuma-protocol/README.md) +- `axum` (incluye WebSocket) + +## Ejecutar + +```sh +cargo run -p shuma-gateway +``` + +| Var de entorno | Default | Para qué | +|-----|---------|----------| +| `SHIPOTE_GATEWAY_LISTEN` | `127.0.0.1:7378` | dirección TCP de escucha | +| `SHIPOTE_GATEWAY_TOKEN` | *(vacío)* | si se define, exige auth (ver abajo) | +| `SHIPOTE_GATEWAY_LOG` | `info` | filtro de `tracing` | + +El daemon debe estar corriendo; el gateway se conecta a su socket +(`$XDG_RUNTIME_DIR/shuma.sock`). + +## Auth (opcional) + +Sin `SHIPOTE_GATEWAY_TOKEN` el gateway queda **abierto** (úsalo en loopback o +detrás de un túnel TLS/SSH/Noise). Con token definido, toda request exige: + +- header `Authorization: Bearer `, o +- query `?token=` (para clientes WS que no fijan headers). + +Comparación en tiempo constante. Sin auth → `401`. + +## `POST /rpc` — request/response 1:1 + +Body = un `shuma_protocol::Request` como JSON; respuesta = el `Response` como +JSON. Los enums van **externally-tagged** (convención serde): + +- variante unitaria → string: `"Ping"`, `"Health"`, `"WorkspaceList"`, `"Capabilities"`. +- variante con campos → objeto de una clave: `{"WorkspaceStop":{"id":…,"grace_ms":1000}}`. + +Ejemplos verificados: + +```sh +curl -s --noproxy '*' -XPOST localhost:7378/rpc -d '"Ping"' +# "Pong" + +curl -s --noproxy '*' -XPOST localhost:7378/rpc -d '"Health"' +# {"Health":{"version":"0.1.0","uptime_ms":667,"alive_workspaces":0, +# "alive_commands":0,"alive_pipelines":0,"active_flows":0,"dirty":false}} + +curl -s --noproxy '*' -XPOST localhost:7378/rpc -d '"WorkspaceList"' +# {"WorkspaceList":{"items":[{"id":…,"label":"…","commands":0,"uptime_ms":…}]}} +``` + +Requests útiles para un panel de control (ver `shuma-protocol::Request` para el +conjunto completo y los campos exactos): + +| Request (JSON) | Para qué | +|----------------|----------| +| `"Health"` | versión + uptime + conteos vivos | +| `"WorkspaceList"` | listar workspaces (= "claudes") | +| `{"WorkspaceCreate":{"spec":{…WorkspaceSpec…}}}` | crear workspace | +| `{"WorkspaceStop":{"id":…,"grace_ms":1000}}` | detener (SIGTERM→SIGKILL) | +| `{"WorkspaceStats":{"workspace":…}}` | CPU/RSS/comandos vivos | +| `{"WorkspaceFullSummary":{"workspace":…}}` | stats+quota+commands en 1 roundtrip | + +Errores: `400` (JSON inválido), `502` (`{"error":"daemon: …"}`), `401` (auth). + +## `GET /ws/pty` — terminal remoto (WebSocket ↔ subprotocolo `ExecPty`) + +Canal **full-duplex** hacia un PTY remoto. Ideal para "un terminal por cada +Claude" (`program:"claude"`), un `ssh host`, o cualquier TUI. + +1. **Abrir** — primer mensaje del cliente = **texto JSON** con el spec: + ```json + {"cwd":"/ruta","program":"claude","args":["code"],"rows":40,"cols":120} + ``` + (`cwd` default `"."`, `rows` 24, `cols` 80, `args` []). +2. **Salida** — el server manda **frames binarios** = bytes crudos del PTY + (con escapes ANSI). Aliméntalos a un emulador vt100. +3. **Teclas** — el cliente manda **frames binarios** = stdin crudo. +4. **Resize** — el cliente manda **texto JSON** `{"t":"resize","rows":50,"cols":100}`. +5. **Fin** — al salir el proceso, el server manda **texto JSON** + `{"t":"exit","code":0}` (o `{"t":"error","msg":"…"}`) y cierra el WS. +6. **Abortar** — el cliente **cierra el WS** → el daemon mata el PTY (SSH/PTY). + +Regla: **binario = bytes del terminal** (ambos sentidos); **texto = control JSON**. + +### ⚠️ Persistencia (clave para "administrar un grupo de claudes") + +Hoy un `ExecPty` es **efímero**: el proceso vive sólo mientras el WebSocket está +abierto; al cerrar (cerrar la app, caída de red) **el proceso muere**. Sirve +para asomarse a una sesión, no para dejar claudes corriendo y re-adjuntarse +luego. Para sesiones **persistentes** tipo tmux (dejar N claudes trabajando y +attach/detach desde el móvil) hace falta un modo de PTY persistente en el daemon +— pendiente de decidir. + +## Cliente Android (rizoma `:consola`, planeado) + +- Lista de claudes: `POST /rpc "WorkspaceList"` (+ `WorkspaceStats` por item). +- Terminal: WebSocket a `/ws/pty` con `program:"claude"`, emulador vt100, Trazo + como teclado. +- Auth: token en Android Keystore → header `Authorization: Bearer`. diff --git a/02_ruway/shuma/shuma-gateway/src/main.rs b/02_ruway/shuma/shuma-gateway/src/main.rs index 9f77a43..af5d670 100644 --- a/02_ruway/shuma/shuma-gateway/src/main.rs +++ b/02_ruway/shuma/shuma-gateway/src/main.rs @@ -1,168 +1,750 @@ -//! `shuma-gateway` — HTTP/JSON adapter para el daemon. +//! `shuma-gateway` — adaptador HTTP/JSON + WebSocket para el daemon shuma. //! -//! Acepta `POST /rpc` con body JSON serializado como `shuma_protocol::Request`, -//! hace round-trip al admin socket via postcard, devuelve `Response` como JSON. +//! Endpoints: +//! - `POST /rpc` : body JSON = `shuma_protocol::Request` → round-trip postcard +//! contra el admin socket → `Response` como JSON. Una request por conexión +//! (request/response 1:1). Sirve para WorkspaceList, Health, Stats, Run, etc. +//! - `GET /ws/pty` : WebSocket full-duplex hacia una **sesión PTY +//! persistente** del daemon. El primer mensaje (texto JSON) abre el +//! puente: con `{"session":""}` se **adjunta** a una sesión +//! existente; con `{"program":"claude","args":[...],"cwd":".","label":"…"}` +//! **crea** una sesión persistente y se adjunta (antes de la salida manda +//! `{"t":"session","id":""}` con el id, para re-adjuntarse luego). +//! Después, los frames binarios del cliente son stdin (teclas) y los del +//! servidor la salida cruda del terminal (empezando por el scrollback). +//! Resize por `{"t":"resize","rows":R,"cols":C}`. **Cerrar el WS = +//! DETACH**: la sesión sigue viva; se la mata con `PtyKill` (`POST /rpc`). +//! - `GET /term` : cliente móvil de terminal (HTML autocontenido con +//! xterm.js). Lista sesiones por `/rpc`, adjunta por `/ws/pty`. El token va +//! en `?token=…`. Pensado para abrir desde un teléfono en la misma red. +//! - `GET /` y `GET /health` : healthcheck. //! -//! Diseñado para clients no-Rust (curl, Python, web app) que no pueden -//! hablar postcard directo. NO es un proxy completo — sólo translation -//! layer del protocolo. +//! Auth opcional por token (`SHIPOTE_GATEWAY_TOKEN`): header +//! `Authorization: Bearer ` o, para clientes WS que no fijan headers, +//! `?token=` en la URL. Sin token configurado, el gateway queda abierto +//! (pensado para escucharse en loopback o detrás de un túnel). //! -//! Sin dep de axum/hyper: HTTP parser ad-hoc, suficiente para 1 endpoint. +//! Pensado para clientes no-Rust (app Android, web, curl) que no hablan postcard. +use std::collections::HashSet; +use std::path::PathBuf; +use std::sync::{Arc, Mutex}; +use std::time::Duration; + +use axum::{ + body::Bytes, + extract::{ + ws::{Message, WebSocket, WebSocketUpgrade}, + Query, State, + }, + http::{header::AUTHORIZATION, HeaderMap, StatusCode}, + response::{Html, IntoResponse, Response as AxumResponse}, + routing::{get, post}, + Json, Router, +}; +use serde::Deserialize; use shuma_protocol::{default_socket_path, read_frame, write_frame, Request, Response}; -use std::sync::Arc; -use tokio::io::{AsyncReadExt, AsyncWriteExt}; -use tokio::net::{TcpListener, TcpStream, UnixStream}; +use tokio::net::UnixStream; +use tokio::sync::broadcast; use tracing::{info, warn}; +use ulid::Ulid; const DEFAULT_LISTEN: &str = "127.0.0.1:7378"; +/// Cliente móvil de terminal (E4): página autocontenida servida en `/term`. +/// Adjunta a sesiones PTY persistentes vía `/rpc` + `/ws/pty`. El token (si +/// el gateway lo exige) va en `?token=…` de la URL. +const TERM_HTML: &str = include_str!("term.html"); + +// xterm.js (5.3.0) + fit addon (0.8.0) VENDORIZADOS — embebidos en el binario +// y servidos en `/vendor/…` para que el cliente móvil ande 100% offline en una +// LAN sin internet (cero dependencia de CDN). ~290 KB al binario. +const XTERM_CSS: &str = include_str!("vendor/xterm.min.css"); +const XTERM_JS: &str = include_str!("vendor/xterm.min.js"); +const XTERM_FIT_JS: &str = include_str!("vendor/xterm-addon-fit.min.js"); + +#[derive(Clone)] +struct AppState { + sock: Arc, + token: Option>, + /// Bus de eventos de supervisión: los hooks de Claude Code los publican + /// por `POST /event` y los clientes (consola) los reciben por + /// `GET /ws/events`. El gateway solo retransmite el JSON tal cual. + events: broadcast::Sender, + /// Endpoints UnifiedPush registrados por la app (consola): cada `/event` + /// se reenvía a ellos para entrega en background con la app cerrada. + up: Arc, + /// Cliente HTTP para empujar a los endpoints UnifiedPush (ntfy). + http: reqwest::Client, +} + +/// Registro persistente de endpoints UnifiedPush. Set en memoria + archivo +/// JSON (sobrevive reinicios del gateway). +struct UpStore { + path: PathBuf, + set: Mutex>, +} + +impl UpStore { + fn load(path: PathBuf) -> Self { + let set = std::fs::read_to_string(&path) + .ok() + .and_then(|s| serde_json::from_str::>(&s).ok()) + .map(|v| v.into_iter().collect()) + .unwrap_or_default(); + UpStore { path, set: Mutex::new(set) } + } + fn save(&self) { + if let Some(dir) = self.path.parent() { + let _ = std::fs::create_dir_all(dir); + } + let v: Vec = self.set.lock().unwrap().iter().cloned().collect(); + if let Ok(s) = serde_json::to_string(&v) { + let _ = std::fs::write(&self.path, s); + } + } + fn add(&self, ep: String) { + let nuevo = self.set.lock().unwrap().insert(ep); + if nuevo { + self.save(); + } + } + fn remove(&self, ep: &str) { + let había = self.set.lock().unwrap().remove(ep); + if había { + self.save(); + } + } + fn list(&self) -> Vec { + self.set.lock().unwrap().iter().cloned().collect() + } +} + #[tokio::main] async fn main() -> anyhow::Result<()> { + bitacora::abrir("shuma"); init_tracing(); let listen = std::env::var("SHIPOTE_GATEWAY_LISTEN").unwrap_or_else(|_| DEFAULT_LISTEN.into()); - let daemon_sock = Arc::new(default_socket_path()); - let listener = TcpListener::bind(&listen).await?; - info!(listen = %listen, daemon = %daemon_sock.display(), "shuma-gateway listening"); + let token = std::env::var("SHIPOTE_GATEWAY_TOKEN") + .ok() + .filter(|s| !s.is_empty()) + .map(Arc::new); - loop { - match listener.accept().await { - Ok((stream, peer)) => { - let sock = daemon_sock.clone(); - tokio::spawn(async move { - if let Err(e) = handle_http(stream, sock).await { - warn!(?e, ?peer, "request error"); - } - }); - } - Err(e) => { - warn!(?e, "accept failed"); - tokio::time::sleep(std::time::Duration::from_millis(100)).await; - } - } - } + let (events, _) = broadcast::channel::(256); + let up_path = std::env::var("SHUMA_UP_STORE") + .unwrap_or_else(|_| "/home/sergio/.local/share/shuma/up-endpoints.json".into()); + let up = Arc::new(UpStore::load(PathBuf::from(up_path))); + let http = reqwest::Client::builder() + .timeout(Duration::from_secs(8)) + .build()?; + info!(endpoints = up.list().len(), "UnifiedPush endpoints cargados"); + let state = AppState { + sock: Arc::new(default_socket_path()), + token, + events, + up, + http, + }; + + let app = Router::new() + .route("/", get(health)) + .route("/health", get(health)) + .route("/term", get(term_page)) + .route("/vendor/xterm.css", get(vendor_css)) + .route("/vendor/xterm.js", get(vendor_js)) + .route("/vendor/xterm-addon-fit.js", get(vendor_fit)) + .route("/rpc", post(rpc)) + .route("/ws/pty", get(ws_pty)) + .route("/event", post(post_event)) + .route("/ws/events", get(ws_events)) + .route("/register", post(post_register)) + .route("/unregister", post(post_unregister)) + .with_state(state.clone()); + + let listener = tokio::net::TcpListener::bind(&listen).await?; + info!( + listen = %listen, + daemon = %state.sock.display(), + auth = state.token.is_some(), + "shuma-gateway listening" + ); + axum::serve(listener, app).await?; + Ok(()) } -async fn handle_http(mut stream: TcpStream, daemon_sock: Arc) -> anyhow::Result<()> { - // Parser HTTP mínimo: read hasta `\r\n\r\n`, parsear request line + - // Content-Length, después leer body exacto. - let mut buf = Vec::with_capacity(4096); - let mut tmp = [0u8; 4096]; - let header_end; - loop { - let n = stream.read(&mut tmp).await?; - if n == 0 { - return Ok(()); // closed - } - buf.extend_from_slice(&tmp[..n]); - if let Some(pos) = find_double_crlf(&buf) { - header_end = pos + 4; - break; - } - if buf.len() > 64 * 1024 { - return write_error(&mut stream, 413, "headers too large").await; - } - } +async fn health() -> &'static str { + "shuma-gateway ok\n" +} - let header_str = std::str::from_utf8(&buf[..header_end - 4])?; - let mut lines = header_str.lines(); - let request_line = lines.next().unwrap_or(""); - let mut parts = request_line.split_whitespace(); - let method = parts.next().unwrap_or(""); - let path = parts.next().unwrap_or(""); - let mut content_length: usize = 0; - for line in lines { - if let Some(v) = line.strip_prefix("Content-Length:").or_else(|| line.strip_prefix("content-length:")) { - content_length = v.trim().parse().unwrap_or(0); +/// GET /term — el cliente móvil de terminal. HTML estático (sin secretos): el +/// token va por la URL y lo usa el JS para `/rpc` y `/ws/pty`, que sí están +/// gateados. Por eso la página en sí no requiere auth para cargar. +async fn term_page() -> Html<&'static str> { + Html(TERM_HTML) +} + +/// Activos vendorizados de xterm.js, servidos con su content-type. Estáticos +/// y públicos (sin secretos): no requieren token. +async fn vendor_css() -> impl IntoResponse { + ( + [(axum::http::header::CONTENT_TYPE, "text/css; charset=utf-8")], + XTERM_CSS, + ) +} +async fn vendor_js() -> impl IntoResponse { + ( + [( + axum::http::header::CONTENT_TYPE, + "application/javascript; charset=utf-8", + )], + XTERM_JS, + ) +} +async fn vendor_fit() -> impl IntoResponse { + ( + [( + axum::http::header::CONTENT_TYPE, + "application/javascript; charset=utf-8", + )], + XTERM_FIT_JS, + ) +} + +// ===================================================================== +// Auth +// ===================================================================== + +#[derive(Deserialize)] +struct TokenQuery { + token: Option, +} + +fn authorized(state: &AppState, headers: &HeaderMap, query_token: Option<&str>) -> bool { + let Some(expected) = state.token.as_deref() else { + return true; // sin token configurado = abierto + }; + if let Some(bearer) = headers + .get(AUTHORIZATION) + .and_then(|v| v.to_str().ok()) + .and_then(|h| h.strip_prefix("Bearer ")) + { + if ct_eq(bearer.trim(), expected) { + return true; } } + matches!(query_token, Some(t) if ct_eq(t, expected)) +} - // Rutas: - if method == "GET" && (path == "/" || path == "/health") { - return write_text(&mut stream, 200, "shuma-gateway ok\n").await; +/// Comparación en tiempo constante para no filtrar el token por timing. +fn ct_eq(a: &str, b: &str) -> bool { + let (a, b) = (a.as_bytes(), b.as_bytes()); + if a.len() != b.len() { + return false; } - if method != "POST" || path != "/rpc" { - return write_error(&mut stream, 404, "use POST /rpc").await; + let mut diff = 0u8; + for (x, y) in a.iter().zip(b.iter()) { + diff |= x ^ y; } + diff == 0 +} - // Leer body. - let mut body = buf[header_end..].to_vec(); - while body.len() < content_length { - let n = stream.read(&mut tmp).await?; - if n == 0 { - break; - } - body.extend_from_slice(&tmp[..n]); - } - body.truncate(content_length); +// ===================================================================== +// POST /rpc — una Request JSON → una Response JSON +// ===================================================================== - // Parsear JSON → Request. +async fn rpc(State(state): State, headers: HeaderMap, body: Bytes) -> AxumResponse { + if !authorized(&state, &headers, None) { + return (StatusCode::UNAUTHORIZED, Json(err("unauthorized"))).into_response(); + } let req: Request = match serde_json::from_slice(&body) { Ok(r) => r, - Err(e) => return write_error(&mut stream, 400, &format!("bad json: {e}")).await, + Err(e) => { + return (StatusCode::BAD_REQUEST, Json(err(&format!("bad json: {e}")))).into_response() + } }; - - // Round-trip al daemon. - let resp = match round_trip_daemon(&daemon_sock, &req).await { - Ok(r) => r, - Err(e) => return write_error(&mut stream, 502, &format!("daemon: {e}")).await, - }; - - // Serializar Response → JSON. - let body_json = serde_json::to_vec(&resp)?; - write_response(&mut stream, 200, "application/json", &body_json).await + match round_trip(&state.sock, &req).await { + Ok(resp) => Json(resp).into_response(), + Err(e) => (StatusCode::BAD_GATEWAY, Json(err(&format!("daemon: {e}")))).into_response(), + } } -async fn round_trip_daemon(sock: &std::path::Path, req: &Request) -> anyhow::Result { +fn err(msg: &str) -> serde_json::Value { + serde_json::json!({ "error": msg }) +} + +async fn round_trip(sock: &std::path::Path, req: &Request) -> anyhow::Result { let mut stream = UnixStream::connect(sock).await?; write_frame(&mut stream, req).await?; let resp: Response = read_frame(&mut stream).await?; Ok(resp) } -fn find_double_crlf(buf: &[u8]) -> Option { - buf.windows(4).position(|w| w == b"\r\n\r\n") +// ===================================================================== +// GET /ws/pty — WebSocket ↔ subprotocolo ExecPty del daemon +// ===================================================================== + +/// Primer mensaje del cliente WS (texto JSON): abre el puente a una +/// sesión. Con `session` se adjunta a una existente; con `program` crea +/// una nueva y se adjunta. +#[derive(Deserialize)] +struct PtyOpen { + /// Id (ULID) de una sesión existente a la que adjuntarse. + #[serde(default)] + session: Option, + /// Programa a lanzar si se crea una sesión nueva (ignorado si hay + /// `session`). + #[serde(default)] + program: Option, + #[serde(default)] + args: Vec, + #[serde(default = "default_cwd")] + cwd: String, + /// Etiqueta legible para la sesión nueva. + #[serde(default)] + label: String, + #[serde(default = "default_rows")] + rows: u16, + #[serde(default = "default_cols")] + cols: u16, } -async fn write_response( - stream: &mut TcpStream, - code: u16, - content_type: &str, - body: &[u8], -) -> anyhow::Result<()> { - let status = match code { - 200 => "OK", - 400 => "Bad Request", - 404 => "Not Found", - 413 => "Payload Too Large", - 502 => "Bad Gateway", - _ => "Unknown", +fn default_cwd() -> String { + ".".into() +} +fn default_rows() -> u16 { + 24 +} +fn default_cols() -> u16 { + 80 +} + +/// Mensaje de control (texto JSON) durante un PTY activo. +#[derive(Deserialize)] +struct PtyControl { + t: String, + rows: Option, + cols: Option, +} + +async fn ws_pty( + State(state): State, + headers: HeaderMap, + Query(q): Query, + ws: WebSocketUpgrade, +) -> AxumResponse { + if !authorized(&state, &headers, q.token.as_deref()) { + return (StatusCode::UNAUTHORIZED, "unauthorized").into_response(); + } + let sock = state.sock.clone(); + ws.on_upgrade(move |socket| pty_bridge(socket, sock)) +} + +// ===================================================================== +// Bus de eventos de supervisión (hooks de Claude Code → consola) +// ===================================================================== + +/// POST /event — un hook publica un evento (JSON arbitrario) que se +/// retransmite tal cual a los clientes de `/ws/events`. Pensado para los +/// hooks `Notification`/`Stop` de Claude Code, que corren en localhost. +async fn post_event(State(state): State, headers: HeaderMap, body: Bytes) -> AxumResponse { + if !authorized(&state, &headers, None) { + return (StatusCode::UNAUTHORIZED, Json(err("unauthorized"))).into_response(); + } + let raw = match String::from_utf8(body.to_vec()) { + Ok(s) if !s.trim().is_empty() => s, + _ => return (StatusCode::BAD_REQUEST, Json(err("evento vacío o no-UTF8"))).into_response(), }; - let head = format!( - "HTTP/1.1 {code} {status}\r\n\ - Content-Type: {content_type}\r\n\ - Content-Length: {}\r\n\ - Connection: close\r\n\ - \r\n", - body.len() - ); - stream.write_all(head.as_bytes()).await?; - stream.write_all(body).await?; - stream.flush().await?; - Ok(()) + // Sellamos un `id` único si no lo trae: el cliente deduplica entre el WS + // (foreground) y el push UnifiedPush (background), que entregan lo mismo. + let payload = match serde_json::from_str::(&raw) { + Ok(serde_json::Value::Object(mut m)) => { + m.entry("id") + .or_insert_with(|| serde_json::Value::String(Ulid::new().to_string())); + serde_json::to_string(&m).unwrap_or(raw) + } + _ => raw, + }; + + // 1) Reenvío a los endpoints UnifiedPush (background, app cerrada). + let endpoints = state.up.list(); + for ep in endpoints { + let http = state.http.clone(); + let up = state.up.clone(); + let msg = payload.clone(); + tokio::spawn(async move { + match http + .post(&ep) + .header("Content-Type", "application/json") + .body(msg) + .send() + .await + { + // 404/410 = endpoint muerto (la app se dio de baja): purgar. + Ok(r) if r.status() == 404 || r.status() == 410 => up.remove(&ep), + _ => {} + } + }); + } + + // 2) Broadcast a los clientes WS (foreground). send() falla solo si no hay + // suscriptores: no es error, simplemente nadie escucha ahora. + let subscribers = state.events.send(payload).unwrap_or(0); + (StatusCode::OK, Json(serde_json::json!({ "ok": true, "subscribers": subscribers }))).into_response() } -async fn write_text(stream: &mut TcpStream, code: u16, body: &str) -> anyhow::Result<()> { - write_response(stream, code, "text/plain", body.as_bytes()).await +#[derive(Deserialize)] +struct RegisterBody { + endpoint: String, } -async fn write_error(stream: &mut TcpStream, code: u16, msg: &str) -> anyhow::Result<()> { - let body = serde_json::json!({ "error": msg }).to_string(); - write_response(stream, code, "application/json", body.as_bytes()).await +/// POST /register — la app registra su endpoint UnifiedPush para recibir los +/// eventos en background. Idempotente. +async fn post_register( + State(state): State, + headers: HeaderMap, + Json(b): Json, +) -> AxumResponse { + if !authorized(&state, &headers, None) { + return (StatusCode::UNAUTHORIZED, Json(err("unauthorized"))).into_response(); + } + if !b.endpoint.starts_with("http") { + return (StatusCode::BAD_REQUEST, Json(err("endpoint inválido"))).into_response(); + } + state.up.add(b.endpoint); + info!(total = state.up.list().len(), "endpoint UnifiedPush registrado"); + (StatusCode::OK, Json(serde_json::json!({ "ok": true }))).into_response() +} + +/// POST /unregister — la app se da de baja (cambió de distribuidor, etc.). +async fn post_unregister( + State(state): State, + headers: HeaderMap, + Json(b): Json, +) -> AxumResponse { + if !authorized(&state, &headers, None) { + return (StatusCode::UNAUTHORIZED, Json(err("unauthorized"))).into_response(); + } + state.up.remove(&b.endpoint); + (StatusCode::OK, Json(serde_json::json!({ "ok": true }))).into_response() +} + +/// GET /ws/events — el cliente se suscribe y recibe cada evento como texto. +async fn ws_events( + State(state): State, + headers: HeaderMap, + Query(q): Query, + ws: WebSocketUpgrade, +) -> AxumResponse { + if !authorized(&state, &headers, q.token.as_deref()) { + return (StatusCode::UNAUTHORIZED, "unauthorized").into_response(); + } + let rx = state.events.subscribe(); + ws.on_upgrade(move |socket| events_bridge(socket, rx)) +} + +async fn events_bridge(mut ws: WebSocket, mut rx: broadcast::Receiver) { + loop { + tokio::select! { + msg = rx.recv() => match msg { + Ok(s) => { + if ws.send(Message::Text(s)).await.is_err() { + break; + } + } + // Si el cliente se retrasa y pierde eventos, seguimos con los + // siguientes en vez de cortar la conexión. + Err(broadcast::error::RecvError::Lagged(_)) => continue, + Err(broadcast::error::RecvError::Closed) => break, + }, + inbound = ws.recv() => match inbound { + // Ignoramos lo que mande el cliente (keepalive); solo nos + // importa detectar el cierre/caída para soltar la suscripción. + Some(Ok(_)) => continue, + _ => break, + }, + } + } +} + +async fn pty_bridge(mut ws: WebSocket, sock: Arc) { + // 1) Primer mensaje = spec de apertura (texto JSON). + let open: PtyOpen = loop { + match ws.recv().await { + Some(Ok(Message::Text(t))) => { + let v: serde_json::Value = match serde_json::from_str(&t) { + Ok(v) => v, + Err(e) => { + let _ = ws.send(Message::Text(ctl_err(&format!("bad open: {e}")))).await; + return; + } + }; + // Un cliente puede encolar un control (p.ej. {"t":"resize"}) + // antes del open por una carrera de UI (el layout dispara el + // resize antes de que onOpen mande el open). Esos frames llevan + // `t`; ignóralos y sigue esperando el verdadero open. + if v.get("t").is_some() { + continue; + } + match serde_json::from_value(v) { + Ok(o) => break o, + Err(e) => { + let _ = ws.send(Message::Text(ctl_err(&format!("bad open: {e}")))).await; + return; + } + } + } + Some(Ok(Message::Ping(_) | Message::Pong(_))) => continue, + _ => return, // cerró o mandó binario antes de abrir + } + }; + + // 2) Conectar al daemon. + let daemon = match UnixStream::connect(&*sock).await { + Ok(s) => s, + Err(e) => { + let _ = ws.send(Message::Text(ctl_err(&format!("daemon: {e}")))).await; + return; + } + }; + let (mut drd, mut dwr) = daemon.into_split(); + + // 3) Resolver la sesión: adjuntar a una existente, o crear una nueva + // (PtySpawn 1:1 sobre la misma conexión) y luego adjuntar. + let session: Ulid = if let Some(s) = open.session.as_deref() { + match Ulid::from_string(s) { + Ok(id) => id, + Err(_) => { + let _ = ws.send(Message::Text(ctl_err("session id inválido"))).await; + return; + } + } + } else if let Some(program) = open.program.clone() { + let spawn = Request::PtySpawn { + cwd: open.cwd.clone(), + program, + args: open.args.clone(), + rows: open.rows, + cols: open.cols, + label: open.label.clone(), + }; + if write_frame(&mut dwr, &spawn).await.is_err() { + let _ = ws.send(Message::Text(ctl_err("write spawn failed"))).await; + return; + } + match read_frame::(&mut drd).await { + Ok(Response::PtySpawned { session }) => { + // El cliente aprende el id para poder re-adjuntarse luego. + let _ = ws + .send(Message::Text( + serde_json::json!({"t":"session","id":session.to_string()}).to_string(), + )) + .await; + session + } + Ok(Response::Error { message }) => { + let _ = ws.send(Message::Text(ctl_err(&message))).await; + return; + } + Ok(other) => { + let _ = ws + .send(Message::Text(ctl_err(&format!( + "respuesta inesperada al spawn: {other:?}" + )))) + .await; + return; + } + Err(e) => { + let _ = ws.send(Message::Text(ctl_err(&format!("daemon: {e}")))).await; + return; + } + } + } else { + let _ = ws + .send(Message::Text(ctl_err("falta 'session' o 'program'"))) + .await; + return; + }; + + // 4) Adjuntarse a la sesión. A partir de aquí la conexión es + // full-duplex; cerrar el WS = DETACH (la sesión sobrevive). + let attach = Request::PtyAttach { + session, + rows: open.rows, + cols: open.cols, + }; + if write_frame(&mut dwr, &attach).await.is_err() { + let _ = ws.send(Message::Text(ctl_err("write attach failed"))).await; + return; + } + + // 5) Puente full-duplex. tokio::select! suelta la rama no completada + // antes de correr el handler, así `ws` se puede usar en ambas ramas. + loop { + tokio::select! { + frame = read_frame::(&mut drd) => { + match frame { + Ok(Response::ExecBytes(b)) => { + if ws.send(Message::Binary(b)).await.is_err() { + break; + } + } + Ok(Response::ExecExited(code)) => { + let _ = ws.send(Message::Text(format!("{{\"t\":\"exit\",\"code\":{code}}}"))).await; + break; + } + Ok(Response::ExecFailed(m)) => { + let _ = ws.send(Message::Text(ctl_err(&m))).await; + break; + } + Ok(_) => {} // otros frames no aplican al PTY + Err(_) => break, // daemon cerró + } + } + msg = ws.recv() => { + match msg { + Some(Ok(Message::Binary(bytes))) => { + if write_frame(&mut dwr, &Request::PtyInput { bytes }).await.is_err() { + break; + } + } + Some(Ok(Message::Text(t))) => { + if let Ok(c) = serde_json::from_str::(&t) { + if c.t == "resize" { + if let (Some(rows), Some(cols)) = (c.rows, c.cols) { + let _ = write_frame(&mut dwr, &Request::PtyResize { rows, cols }).await; + } + } + } + } + Some(Ok(Message::Close(_))) | None => break, + Some(Ok(_)) => {} // ping/pong: axum responde solo + Some(Err(_)) => break, + } + } + } + } + // Al salir, drd/dwr se dropean → el daemon ve EOF → mata el PTY (convención SSH). + warn!("pty bridge closed"); +} + +/// Mensaje de control de error hacia el cliente WS (JSON con string escapado). +fn ctl_err(msg: &str) -> String { + serde_json::json!({ "t": "error", "msg": msg }).to_string() } fn init_tracing() { use tracing_subscriber::{fmt, EnvFilter}; - let filter = EnvFilter::try_from_env("SHIPOTE_GATEWAY_LOG").unwrap_or_else(|_| EnvFilter::new("info")); - fmt().with_env_filter(filter).init(); + let filter = + EnvFilter::try_from_env("SHIPOTE_GATEWAY_LOG").unwrap_or_else(|_| EnvFilter::new("info")); + // try_init: bitacora::abrir ya puede haber instalado el subscriber global. + let _ = fmt().with_env_filter(filter).try_init(); +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn ct_eq_matches_and_rejects() { + assert!(ct_eq("s3cr3t", "s3cr3t")); + assert!(!ct_eq("s3cr3t", "s3cr3T")); + assert!(!ct_eq("short", "longer")); + } + + fn test_state(token: Option<&str>) -> AppState { + let (events, _) = broadcast::channel::(8); + AppState { + sock: Arc::new(PathBuf::from("/x")), + token: token.map(|t| Arc::new(t.to_string())), + events, + up: Arc::new(UpStore::load(PathBuf::from("/x/up.json"))), + http: reqwest::Client::new(), + } + } + + #[test] + fn auth_open_when_no_token() { + let st = test_state(None); + assert!(authorized(&st, &HeaderMap::new(), None)); + } + + #[test] + fn auth_requires_token_when_set() { + let st = test_state(Some("abc")); + assert!(!authorized(&st, &HeaderMap::new(), None)); + assert!(authorized(&st, &HeaderMap::new(), Some("abc"))); + assert!(!authorized(&st, &HeaderMap::new(), Some("nope"))); + } + + #[test] + fn term_html_habla_el_protocolo_del_gateway() { + // El cliente embebido debe cablear los endpoints/protocolo reales: + // listar por /rpc "PtyList", adjuntar por /ws/pty, resize/kill. + assert!(TERM_HTML.contains("/ws/pty")); + assert!(TERM_HTML.contains("\"PtyList\"")); + assert!(TERM_HTML.contains("PtyKill")); + assert!(TERM_HTML.contains("\"resize\"")); + // Y monta un terminal de verdad (xterm) + pasa el token a las dos vías. + assert!(TERM_HTML.contains("xterm")); + assert!(TERM_HTML.contains("Bearer ")); + assert!(TERM_HTML.contains("token=")); + } + + #[test] + fn term_html_usa_vendor_local_no_cdn() { + // Offline-LAN: la página referencia los activos locales, no un CDN. + assert!(TERM_HTML.contains("/vendor/xterm.css")); + assert!(TERM_HTML.contains("/vendor/xterm.js")); + assert!(TERM_HTML.contains("/vendor/xterm-addon-fit.js")); + assert!(!TERM_HTML.contains("cdn.jsdelivr.net")); + } + + #[test] + fn vendor_assets_embebidos_son_los_reales() { + // xterm.js define `Terminal`; el fit addon define `FitAddon`. Tamaños + // razonables (no páginas de error de ~0 bytes). + assert!(XTERM_JS.contains("Terminal")); + assert!(XTERM_JS.len() > 100_000); + assert!(XTERM_FIT_JS.contains("FitAddon")); + assert!(XTERM_CSS.contains(".xterm")); + } + + #[tokio::test] + async fn term_page_devuelve_html_sin_auth() { + // La página carga sin token (no tiene secretos); el gateo está en + // /rpc y /ws/pty, que el JS llama con el token de la URL. + let Html(body) = term_page().await; + assert!(body.contains("")); + assert_eq!(body, TERM_HTML); + } + + #[test] + fn pty_open_spawn_parses_with_defaults() { + let o: PtyOpen = serde_json::from_str(r#"{"program":"claude","args":["code"]}"#).unwrap(); + assert_eq!(o.program.as_deref(), Some("claude")); + assert_eq!(o.args, vec!["code"]); + assert_eq!(o.session, None); + assert_eq!(o.rows, 24); + assert_eq!(o.cols, 80); + assert_eq!(o.cwd, "."); + assert_eq!(o.label, ""); + } + + #[test] + fn pty_open_attach_parses() { + let o: PtyOpen = + serde_json::from_str(r#"{"session":"01ARZ3NDEKTSV4RRFFQ69G5FAV","rows":40}"#).unwrap(); + assert_eq!(o.session.as_deref(), Some("01ARZ3NDEKTSV4RRFFQ69G5FAV")); + assert_eq!(o.program, None); + assert_eq!(o.rows, 40); + assert_eq!(o.cols, 80); + } + + #[test] + fn pty_control_resize_parses() { + let c: PtyControl = serde_json::from_str(r#"{"t":"resize","rows":40,"cols":120}"#).unwrap(); + assert_eq!(c.t, "resize"); + assert_eq!(c.rows, Some(40)); + assert_eq!(c.cols, Some(120)); + } } diff --git a/02_ruway/shuma/shuma-gateway/src/term.html b/02_ruway/shuma/shuma-gateway/src/term.html new file mode 100644 index 0000000..26da754 --- /dev/null +++ b/02_ruway/shuma/shuma-gateway/src/term.html @@ -0,0 +1,198 @@ + + + + + + + + shuma · terminal + + + + +
+
+ shuma· terminal + + + +
+
+
+ + +
+
+
+
+
+
+ + + + + + diff --git a/02_ruway/shuma/shuma-gateway/src/vendor/xterm-addon-fit.min.js b/02_ruway/shuma/shuma-gateway/src/vendor/xterm-addon-fit.min.js new file mode 100644 index 0000000..7384f10 --- /dev/null +++ b/02_ruway/shuma/shuma-gateway/src/vendor/xterm-addon-fit.min.js @@ -0,0 +1,8 @@ +/** + * Skipped minification because the original files appears to be already minified. + * Original file: /npm/xterm-addon-fit@0.8.0/lib/xterm-addon-fit.js + * + * Do NOT use SRI with dynamically generated files! More information: https://www.jsdelivr.com/using-sri-with-dynamic-files + */ +!function(e,t){"object"==typeof exports&&"object"==typeof module?module.exports=t():"function"==typeof define&&define.amd?define([],t):"object"==typeof exports?exports.FitAddon=t():e.FitAddon=t()}(self,(()=>(()=>{"use strict";var e={};return(()=>{var t=e;Object.defineProperty(t,"__esModule",{value:!0}),t.FitAddon=void 0,t.FitAddon=class{activate(e){this._terminal=e}dispose(){}fit(){const e=this.proposeDimensions();if(!e||!this._terminal||isNaN(e.cols)||isNaN(e.rows))return;const t=this._terminal._core;this._terminal.rows===e.rows&&this._terminal.cols===e.cols||(t._renderService.clear(),this._terminal.resize(e.cols,e.rows))}proposeDimensions(){if(!this._terminal)return;if(!this._terminal.element||!this._terminal.element.parentElement)return;const e=this._terminal._core,t=e._renderService.dimensions;if(0===t.css.cell.width||0===t.css.cell.height)return;const r=0===this._terminal.options.scrollback?0:e.viewport.scrollBarWidth,i=window.getComputedStyle(this._terminal.element.parentElement),o=parseInt(i.getPropertyValue("height")),s=Math.max(0,parseInt(i.getPropertyValue("width"))),n=window.getComputedStyle(this._terminal.element),l=o-(parseInt(n.getPropertyValue("padding-top"))+parseInt(n.getPropertyValue("padding-bottom"))),a=s-(parseInt(n.getPropertyValue("padding-right"))+parseInt(n.getPropertyValue("padding-left")))-r;return{cols:Math.max(2,Math.floor(a/t.css.cell.width)),rows:Math.max(1,Math.floor(l/t.css.cell.height))}}}})(),e})())); +//# sourceMappingURL=xterm-addon-fit.js.map \ No newline at end of file diff --git a/02_ruway/shuma/shuma-gateway/src/vendor/xterm.min.css b/02_ruway/shuma/shuma-gateway/src/vendor/xterm.min.css new file mode 100644 index 0000000..a01416f --- /dev/null +++ b/02_ruway/shuma/shuma-gateway/src/vendor/xterm.min.css @@ -0,0 +1,8 @@ +/** + * Minified by jsDelivr using clean-css v5.3.3. + * Original file: /npm/xterm@5.3.0/css/xterm.css + * + * Do NOT use SRI with dynamically generated files! More information: https://www.jsdelivr.com/using-sri-with-dynamic-files + */ +.xterm{cursor:text;position:relative;user-select:none;-ms-user-select:none;-webkit-user-select:none}.xterm.focus,.xterm:focus{outline:0}.xterm .xterm-helpers{position:absolute;top:0;z-index:5}.xterm .xterm-helper-textarea{padding:0;border:0;margin:0;position:absolute;opacity:0;left:-9999em;top:0;width:0;height:0;z-index:-5;white-space:nowrap;overflow:hidden;resize:none}.xterm .composition-view{background:#000;color:#fff;display:none;position:absolute;white-space:nowrap;z-index:1}.xterm .composition-view.active{display:block}.xterm .xterm-viewport{background-color:#000;overflow-y:scroll;cursor:default;position:absolute;right:0;left:0;top:0;bottom:0}.xterm .xterm-screen{position:relative}.xterm .xterm-screen canvas{position:absolute;left:0;top:0}.xterm .xterm-scroll-area{visibility:hidden}.xterm-char-measure-element{display:inline-block;visibility:hidden;position:absolute;top:0;left:-9999em;line-height:normal}.xterm.enable-mouse-events{cursor:default}.xterm .xterm-cursor-pointer,.xterm.xterm-cursor-pointer{cursor:pointer}.xterm.column-select.focus{cursor:crosshair}.xterm .xterm-accessibility,.xterm .xterm-message{position:absolute;left:0;top:0;bottom:0;right:0;z-index:10;color:transparent;pointer-events:none}.xterm .live-region{position:absolute;left:-9999px;width:1px;height:1px;overflow:hidden}.xterm-dim{opacity:1!important}.xterm-underline-1{text-decoration:underline}.xterm-underline-2{text-decoration:double underline}.xterm-underline-3{text-decoration:wavy underline}.xterm-underline-4{text-decoration:dotted underline}.xterm-underline-5{text-decoration:dashed underline}.xterm-overline{text-decoration:overline}.xterm-overline.xterm-underline-1{text-decoration:overline underline}.xterm-overline.xterm-underline-2{text-decoration:overline double underline}.xterm-overline.xterm-underline-3{text-decoration:overline wavy underline}.xterm-overline.xterm-underline-4{text-decoration:overline dotted underline}.xterm-overline.xterm-underline-5{text-decoration:overline dashed underline}.xterm-strikethrough{text-decoration:line-through}.xterm-screen .xterm-decoration-container .xterm-decoration{z-index:6;position:absolute}.xterm-screen .xterm-decoration-container .xterm-decoration.xterm-decoration-top-layer{z-index:7}.xterm-decoration-overview-ruler{z-index:8;position:absolute;top:0;right:0;pointer-events:none}.xterm-decoration-top{z-index:2;position:relative} +/*# sourceMappingURL=/sm/c5efcc609fa43782768884f031126e0ac5752fe8f1f6c9d1eed7903b1bba39c1.map */ \ No newline at end of file diff --git a/02_ruway/shuma/shuma-gateway/src/vendor/xterm.min.js b/02_ruway/shuma/shuma-gateway/src/vendor/xterm.min.js new file mode 100644 index 0000000..d7bd63f --- /dev/null +++ b/02_ruway/shuma/shuma-gateway/src/vendor/xterm.min.js @@ -0,0 +1,8 @@ +/** + * Skipped minification because the original files appears to be already minified. + * Original file: /npm/xterm@5.3.0/lib/xterm.js + * + * Do NOT use SRI with dynamically generated files! More information: https://www.jsdelivr.com/using-sri-with-dynamic-files + */ +!function(e,t){if("object"==typeof exports&&"object"==typeof module)module.exports=t();else if("function"==typeof define&&define.amd)define([],t);else{var i=t();for(var s in i)("object"==typeof exports?exports:e)[s]=i[s]}}(self,(()=>(()=>{"use strict";var e={4567:function(e,t,i){var s=this&&this.__decorate||function(e,t,i,s){var r,n=arguments.length,o=n<3?t:null===s?s=Object.getOwnPropertyDescriptor(t,i):s;if("object"==typeof Reflect&&"function"==typeof Reflect.decorate)o=Reflect.decorate(e,t,i,s);else for(var a=e.length-1;a>=0;a--)(r=e[a])&&(o=(n<3?r(o):n>3?r(t,i,o):r(t,i))||o);return n>3&&o&&Object.defineProperty(t,i,o),o},r=this&&this.__param||function(e,t){return function(i,s){t(i,s,e)}};Object.defineProperty(t,"__esModule",{value:!0}),t.AccessibilityManager=void 0;const n=i(9042),o=i(6114),a=i(9924),h=i(844),c=i(5596),l=i(4725),d=i(3656);let _=t.AccessibilityManager=class extends h.Disposable{constructor(e,t){super(),this._terminal=e,this._renderService=t,this._liveRegionLineCount=0,this._charsToConsume=[],this._charsToAnnounce="",this._accessibilityContainer=document.createElement("div"),this._accessibilityContainer.classList.add("xterm-accessibility"),this._rowContainer=document.createElement("div"),this._rowContainer.setAttribute("role","list"),this._rowContainer.classList.add("xterm-accessibility-tree"),this._rowElements=[];for(let e=0;ethis._handleBoundaryFocus(e,0),this._bottomBoundaryFocusListener=e=>this._handleBoundaryFocus(e,1),this._rowElements[0].addEventListener("focus",this._topBoundaryFocusListener),this._rowElements[this._rowElements.length-1].addEventListener("focus",this._bottomBoundaryFocusListener),this._refreshRowsDimensions(),this._accessibilityContainer.appendChild(this._rowContainer),this._liveRegion=document.createElement("div"),this._liveRegion.classList.add("live-region"),this._liveRegion.setAttribute("aria-live","assertive"),this._accessibilityContainer.appendChild(this._liveRegion),this._liveRegionDebouncer=this.register(new a.TimeBasedDebouncer(this._renderRows.bind(this))),!this._terminal.element)throw new Error("Cannot enable accessibility before Terminal.open");this._terminal.element.insertAdjacentElement("afterbegin",this._accessibilityContainer),this.register(this._terminal.onResize((e=>this._handleResize(e.rows)))),this.register(this._terminal.onRender((e=>this._refreshRows(e.start,e.end)))),this.register(this._terminal.onScroll((()=>this._refreshRows()))),this.register(this._terminal.onA11yChar((e=>this._handleChar(e)))),this.register(this._terminal.onLineFeed((()=>this._handleChar("\n")))),this.register(this._terminal.onA11yTab((e=>this._handleTab(e)))),this.register(this._terminal.onKey((e=>this._handleKey(e.key)))),this.register(this._terminal.onBlur((()=>this._clearLiveRegion()))),this.register(this._renderService.onDimensionsChange((()=>this._refreshRowsDimensions()))),this._screenDprMonitor=new c.ScreenDprMonitor(window),this.register(this._screenDprMonitor),this._screenDprMonitor.setListener((()=>this._refreshRowsDimensions())),this.register((0,d.addDisposableDomListener)(window,"resize",(()=>this._refreshRowsDimensions()))),this._refreshRows(),this.register((0,h.toDisposable)((()=>{this._accessibilityContainer.remove(),this._rowElements.length=0})))}_handleTab(e){for(let t=0;t0?this._charsToConsume.shift()!==e&&(this._charsToAnnounce+=e):this._charsToAnnounce+=e,"\n"===e&&(this._liveRegionLineCount++,21===this._liveRegionLineCount&&(this._liveRegion.textContent+=n.tooMuchOutput)),o.isMac&&this._liveRegion.textContent&&this._liveRegion.textContent.length>0&&!this._liveRegion.parentNode&&setTimeout((()=>{this._accessibilityContainer.appendChild(this._liveRegion)}),0))}_clearLiveRegion(){this._liveRegion.textContent="",this._liveRegionLineCount=0,o.isMac&&this._liveRegion.remove()}_handleKey(e){this._clearLiveRegion(),/\p{Control}/u.test(e)||this._charsToConsume.push(e)}_refreshRows(e,t){this._liveRegionDebouncer.refresh(e,t,this._terminal.rows)}_renderRows(e,t){const i=this._terminal.buffer,s=i.lines.length.toString();for(let r=e;r<=t;r++){const e=i.translateBufferLineToString(i.ydisp+r,!0),t=(i.ydisp+r+1).toString(),n=this._rowElements[r];n&&(0===e.length?n.innerText=" ":n.textContent=e,n.setAttribute("aria-posinset",t),n.setAttribute("aria-setsize",s))}this._announceCharacters()}_announceCharacters(){0!==this._charsToAnnounce.length&&(this._liveRegion.textContent+=this._charsToAnnounce,this._charsToAnnounce="")}_handleBoundaryFocus(e,t){const i=e.target,s=this._rowElements[0===t?1:this._rowElements.length-2];if(i.getAttribute("aria-posinset")===(0===t?"1":`${this._terminal.buffer.lines.length}`))return;if(e.relatedTarget!==s)return;let r,n;if(0===t?(r=i,n=this._rowElements.pop(),this._rowContainer.removeChild(n)):(r=this._rowElements.shift(),n=i,this._rowContainer.removeChild(r)),r.removeEventListener("focus",this._topBoundaryFocusListener),n.removeEventListener("focus",this._bottomBoundaryFocusListener),0===t){const e=this._createAccessibilityTreeNode();this._rowElements.unshift(e),this._rowContainer.insertAdjacentElement("afterbegin",e)}else{const e=this._createAccessibilityTreeNode();this._rowElements.push(e),this._rowContainer.appendChild(e)}this._rowElements[0].addEventListener("focus",this._topBoundaryFocusListener),this._rowElements[this._rowElements.length-1].addEventListener("focus",this._bottomBoundaryFocusListener),this._terminal.scrollLines(0===t?-1:1),this._rowElements[0===t?1:this._rowElements.length-2].focus(),e.preventDefault(),e.stopImmediatePropagation()}_handleResize(e){this._rowElements[this._rowElements.length-1].removeEventListener("focus",this._bottomBoundaryFocusListener);for(let e=this._rowContainer.children.length;ee;)this._rowContainer.removeChild(this._rowElements.pop());this._rowElements[this._rowElements.length-1].addEventListener("focus",this._bottomBoundaryFocusListener),this._refreshRowsDimensions()}_createAccessibilityTreeNode(){const e=document.createElement("div");return e.setAttribute("role","listitem"),e.tabIndex=-1,this._refreshRowDimensions(e),e}_refreshRowsDimensions(){if(this._renderService.dimensions.css.cell.height){this._accessibilityContainer.style.width=`${this._renderService.dimensions.css.canvas.width}px`,this._rowElements.length!==this._terminal.rows&&this._handleResize(this._terminal.rows);for(let e=0;e{function i(e){return e.replace(/\r?\n/g,"\r")}function s(e,t){return t?"[200~"+e+"[201~":e}function r(e,t,r,n){e=s(e=i(e),r.decPrivateModes.bracketedPasteMode&&!0!==n.rawOptions.ignoreBracketedPasteMode),r.triggerDataEvent(e,!0),t.value=""}function n(e,t,i){const s=i.getBoundingClientRect(),r=e.clientX-s.left-10,n=e.clientY-s.top-10;t.style.width="20px",t.style.height="20px",t.style.left=`${r}px`,t.style.top=`${n}px`,t.style.zIndex="1000",t.focus()}Object.defineProperty(t,"__esModule",{value:!0}),t.rightClickHandler=t.moveTextAreaUnderMouseCursor=t.paste=t.handlePasteEvent=t.copyHandler=t.bracketTextForPaste=t.prepareTextForTerminal=void 0,t.prepareTextForTerminal=i,t.bracketTextForPaste=s,t.copyHandler=function(e,t){e.clipboardData&&e.clipboardData.setData("text/plain",t.selectionText),e.preventDefault()},t.handlePasteEvent=function(e,t,i,s){e.stopPropagation(),e.clipboardData&&r(e.clipboardData.getData("text/plain"),t,i,s)},t.paste=r,t.moveTextAreaUnderMouseCursor=n,t.rightClickHandler=function(e,t,i,s,r){n(e,t,i),r&&s.rightClickSelect(e),t.value=s.selectionText,t.select()}},7239:(e,t,i)=>{Object.defineProperty(t,"__esModule",{value:!0}),t.ColorContrastCache=void 0;const s=i(1505);t.ColorContrastCache=class{constructor(){this._color=new s.TwoKeyMap,this._css=new s.TwoKeyMap}setCss(e,t,i){this._css.set(e,t,i)}getCss(e,t){return this._css.get(e,t)}setColor(e,t,i){this._color.set(e,t,i)}getColor(e,t){return this._color.get(e,t)}clear(){this._color.clear(),this._css.clear()}}},3656:(e,t)=>{Object.defineProperty(t,"__esModule",{value:!0}),t.addDisposableDomListener=void 0,t.addDisposableDomListener=function(e,t,i,s){e.addEventListener(t,i,s);let r=!1;return{dispose:()=>{r||(r=!0,e.removeEventListener(t,i,s))}}}},6465:function(e,t,i){var s=this&&this.__decorate||function(e,t,i,s){var r,n=arguments.length,o=n<3?t:null===s?s=Object.getOwnPropertyDescriptor(t,i):s;if("object"==typeof Reflect&&"function"==typeof Reflect.decorate)o=Reflect.decorate(e,t,i,s);else for(var a=e.length-1;a>=0;a--)(r=e[a])&&(o=(n<3?r(o):n>3?r(t,i,o):r(t,i))||o);return n>3&&o&&Object.defineProperty(t,i,o),o},r=this&&this.__param||function(e,t){return function(i,s){t(i,s,e)}};Object.defineProperty(t,"__esModule",{value:!0}),t.Linkifier2=void 0;const n=i(3656),o=i(8460),a=i(844),h=i(2585);let c=t.Linkifier2=class extends a.Disposable{get currentLink(){return this._currentLink}constructor(e){super(),this._bufferService=e,this._linkProviders=[],this._linkCacheDisposables=[],this._isMouseOut=!0,this._wasResized=!1,this._activeLine=-1,this._onShowLinkUnderline=this.register(new o.EventEmitter),this.onShowLinkUnderline=this._onShowLinkUnderline.event,this._onHideLinkUnderline=this.register(new o.EventEmitter),this.onHideLinkUnderline=this._onHideLinkUnderline.event,this.register((0,a.getDisposeArrayDisposable)(this._linkCacheDisposables)),this.register((0,a.toDisposable)((()=>{this._lastMouseEvent=void 0}))),this.register(this._bufferService.onResize((()=>{this._clearCurrentLink(),this._wasResized=!0})))}registerLinkProvider(e){return this._linkProviders.push(e),{dispose:()=>{const t=this._linkProviders.indexOf(e);-1!==t&&this._linkProviders.splice(t,1)}}}attachToDom(e,t,i){this._element=e,this._mouseService=t,this._renderService=i,this.register((0,n.addDisposableDomListener)(this._element,"mouseleave",(()=>{this._isMouseOut=!0,this._clearCurrentLink()}))),this.register((0,n.addDisposableDomListener)(this._element,"mousemove",this._handleMouseMove.bind(this))),this.register((0,n.addDisposableDomListener)(this._element,"mousedown",this._handleMouseDown.bind(this))),this.register((0,n.addDisposableDomListener)(this._element,"mouseup",this._handleMouseUp.bind(this)))}_handleMouseMove(e){if(this._lastMouseEvent=e,!this._element||!this._mouseService)return;const t=this._positionFromMouseEvent(e,this._element,this._mouseService);if(!t)return;this._isMouseOut=!1;const i=e.composedPath();for(let e=0;e{null==e||e.forEach((e=>{e.link.dispose&&e.link.dispose()}))})),this._activeProviderReplies=new Map,this._activeLine=e.y);let r=!1;for(const[i,n]of this._linkProviders.entries())t?(null===(s=this._activeProviderReplies)||void 0===s?void 0:s.get(i))&&(r=this._checkLinkProviderResult(i,e,r)):n.provideLinks(e.y,(t=>{var s,n;if(this._isMouseOut)return;const o=null==t?void 0:t.map((e=>({link:e})));null===(s=this._activeProviderReplies)||void 0===s||s.set(i,o),r=this._checkLinkProviderResult(i,e,r),(null===(n=this._activeProviderReplies)||void 0===n?void 0:n.size)===this._linkProviders.length&&this._removeIntersectingLinks(e.y,this._activeProviderReplies)}))}_removeIntersectingLinks(e,t){const i=new Set;for(let s=0;se?this._bufferService.cols:s.link.range.end.x;for(let e=n;e<=o;e++){if(i.has(e)){r.splice(t--,1);break}i.add(e)}}}}_checkLinkProviderResult(e,t,i){var s;if(!this._activeProviderReplies)return i;const r=this._activeProviderReplies.get(e);let n=!1;for(let t=0;tthis._linkAtPosition(e.link,t)));e&&(i=!0,this._handleNewLink(e))}if(this._activeProviderReplies.size===this._linkProviders.length&&!i)for(let e=0;ethis._linkAtPosition(e.link,t)));if(r){i=!0,this._handleNewLink(r);break}}return i}_handleMouseDown(){this._mouseDownLink=this._currentLink}_handleMouseUp(e){if(!this._element||!this._mouseService||!this._currentLink)return;const t=this._positionFromMouseEvent(e,this._element,this._mouseService);t&&this._mouseDownLink===this._currentLink&&this._linkAtPosition(this._currentLink.link,t)&&this._currentLink.link.activate(e,this._currentLink.link.text)}_clearCurrentLink(e,t){this._element&&this._currentLink&&this._lastMouseEvent&&(!e||!t||this._currentLink.link.range.start.y>=e&&this._currentLink.link.range.end.y<=t)&&(this._linkLeave(this._element,this._currentLink.link,this._lastMouseEvent),this._currentLink=void 0,(0,a.disposeArray)(this._linkCacheDisposables))}_handleNewLink(e){if(!this._element||!this._lastMouseEvent||!this._mouseService)return;const t=this._positionFromMouseEvent(this._lastMouseEvent,this._element,this._mouseService);t&&this._linkAtPosition(e.link,t)&&(this._currentLink=e,this._currentLink.state={decorations:{underline:void 0===e.link.decorations||e.link.decorations.underline,pointerCursor:void 0===e.link.decorations||e.link.decorations.pointerCursor},isHovered:!0},this._linkHover(this._element,e.link,this._lastMouseEvent),e.link.decorations={},Object.defineProperties(e.link.decorations,{pointerCursor:{get:()=>{var e,t;return null===(t=null===(e=this._currentLink)||void 0===e?void 0:e.state)||void 0===t?void 0:t.decorations.pointerCursor},set:e=>{var t,i;(null===(t=this._currentLink)||void 0===t?void 0:t.state)&&this._currentLink.state.decorations.pointerCursor!==e&&(this._currentLink.state.decorations.pointerCursor=e,this._currentLink.state.isHovered&&(null===(i=this._element)||void 0===i||i.classList.toggle("xterm-cursor-pointer",e)))}},underline:{get:()=>{var e,t;return null===(t=null===(e=this._currentLink)||void 0===e?void 0:e.state)||void 0===t?void 0:t.decorations.underline},set:t=>{var i,s,r;(null===(i=this._currentLink)||void 0===i?void 0:i.state)&&(null===(r=null===(s=this._currentLink)||void 0===s?void 0:s.state)||void 0===r?void 0:r.decorations.underline)!==t&&(this._currentLink.state.decorations.underline=t,this._currentLink.state.isHovered&&this._fireUnderlineEvent(e.link,t))}}}),this._renderService&&this._linkCacheDisposables.push(this._renderService.onRenderedViewportChange((e=>{if(!this._currentLink)return;const t=0===e.start?0:e.start+1+this._bufferService.buffer.ydisp,i=this._bufferService.buffer.ydisp+1+e.end;if(this._currentLink.link.range.start.y>=t&&this._currentLink.link.range.end.y<=i&&(this._clearCurrentLink(t,i),this._lastMouseEvent&&this._element)){const e=this._positionFromMouseEvent(this._lastMouseEvent,this._element,this._mouseService);e&&this._askForLink(e,!1)}}))))}_linkHover(e,t,i){var s;(null===(s=this._currentLink)||void 0===s?void 0:s.state)&&(this._currentLink.state.isHovered=!0,this._currentLink.state.decorations.underline&&this._fireUnderlineEvent(t,!0),this._currentLink.state.decorations.pointerCursor&&e.classList.add("xterm-cursor-pointer")),t.hover&&t.hover(i,t.text)}_fireUnderlineEvent(e,t){const i=e.range,s=this._bufferService.buffer.ydisp,r=this._createLinkUnderlineEvent(i.start.x-1,i.start.y-s-1,i.end.x,i.end.y-s-1,void 0);(t?this._onShowLinkUnderline:this._onHideLinkUnderline).fire(r)}_linkLeave(e,t,i){var s;(null===(s=this._currentLink)||void 0===s?void 0:s.state)&&(this._currentLink.state.isHovered=!1,this._currentLink.state.decorations.underline&&this._fireUnderlineEvent(t,!1),this._currentLink.state.decorations.pointerCursor&&e.classList.remove("xterm-cursor-pointer")),t.leave&&t.leave(i,t.text)}_linkAtPosition(e,t){const i=e.range.start.y*this._bufferService.cols+e.range.start.x,s=e.range.end.y*this._bufferService.cols+e.range.end.x,r=t.y*this._bufferService.cols+t.x;return i<=r&&r<=s}_positionFromMouseEvent(e,t,i){const s=i.getCoords(e,t,this._bufferService.cols,this._bufferService.rows);if(s)return{x:s[0],y:s[1]+this._bufferService.buffer.ydisp}}_createLinkUnderlineEvent(e,t,i,s,r){return{x1:e,y1:t,x2:i,y2:s,cols:this._bufferService.cols,fg:r}}};t.Linkifier2=c=s([r(0,h.IBufferService)],c)},9042:(e,t)=>{Object.defineProperty(t,"__esModule",{value:!0}),t.tooMuchOutput=t.promptLabel=void 0,t.promptLabel="Terminal input",t.tooMuchOutput="Too much output to announce, navigate to rows manually to read"},3730:function(e,t,i){var s=this&&this.__decorate||function(e,t,i,s){var r,n=arguments.length,o=n<3?t:null===s?s=Object.getOwnPropertyDescriptor(t,i):s;if("object"==typeof Reflect&&"function"==typeof Reflect.decorate)o=Reflect.decorate(e,t,i,s);else for(var a=e.length-1;a>=0;a--)(r=e[a])&&(o=(n<3?r(o):n>3?r(t,i,o):r(t,i))||o);return n>3&&o&&Object.defineProperty(t,i,o),o},r=this&&this.__param||function(e,t){return function(i,s){t(i,s,e)}};Object.defineProperty(t,"__esModule",{value:!0}),t.OscLinkProvider=void 0;const n=i(511),o=i(2585);let a=t.OscLinkProvider=class{constructor(e,t,i){this._bufferService=e,this._optionsService=t,this._oscLinkService=i}provideLinks(e,t){var i;const s=this._bufferService.buffer.lines.get(e-1);if(!s)return void t(void 0);const r=[],o=this._optionsService.rawOptions.linkHandler,a=new n.CellData,c=s.getTrimmedLength();let l=-1,d=-1,_=!1;for(let t=0;to?o.activate(e,t,i):h(0,t),hover:(e,t)=>{var s;return null===(s=null==o?void 0:o.hover)||void 0===s?void 0:s.call(o,e,t,i)},leave:(e,t)=>{var s;return null===(s=null==o?void 0:o.leave)||void 0===s?void 0:s.call(o,e,t,i)}})}_=!1,a.hasExtendedAttrs()&&a.extended.urlId?(d=t,l=a.extended.urlId):(d=-1,l=-1)}}t(r)}};function h(e,t){if(confirm(`Do you want to navigate to ${t}?\n\nWARNING: This link could potentially be dangerous`)){const e=window.open();if(e){try{e.opener=null}catch(e){}e.location.href=t}else console.warn("Opening link blocked as opener could not be cleared")}}t.OscLinkProvider=a=s([r(0,o.IBufferService),r(1,o.IOptionsService),r(2,o.IOscLinkService)],a)},6193:(e,t)=>{Object.defineProperty(t,"__esModule",{value:!0}),t.RenderDebouncer=void 0,t.RenderDebouncer=class{constructor(e,t){this._parentWindow=e,this._renderCallback=t,this._refreshCallbacks=[]}dispose(){this._animationFrame&&(this._parentWindow.cancelAnimationFrame(this._animationFrame),this._animationFrame=void 0)}addRefreshCallback(e){return this._refreshCallbacks.push(e),this._animationFrame||(this._animationFrame=this._parentWindow.requestAnimationFrame((()=>this._innerRefresh()))),this._animationFrame}refresh(e,t,i){this._rowCount=i,e=void 0!==e?e:0,t=void 0!==t?t:this._rowCount-1,this._rowStart=void 0!==this._rowStart?Math.min(this._rowStart,e):e,this._rowEnd=void 0!==this._rowEnd?Math.max(this._rowEnd,t):t,this._animationFrame||(this._animationFrame=this._parentWindow.requestAnimationFrame((()=>this._innerRefresh())))}_innerRefresh(){if(this._animationFrame=void 0,void 0===this._rowStart||void 0===this._rowEnd||void 0===this._rowCount)return void this._runRefreshCallbacks();const e=Math.max(this._rowStart,0),t=Math.min(this._rowEnd,this._rowCount-1);this._rowStart=void 0,this._rowEnd=void 0,this._renderCallback(e,t),this._runRefreshCallbacks()}_runRefreshCallbacks(){for(const e of this._refreshCallbacks)e(0);this._refreshCallbacks=[]}}},5596:(e,t,i)=>{Object.defineProperty(t,"__esModule",{value:!0}),t.ScreenDprMonitor=void 0;const s=i(844);class r extends s.Disposable{constructor(e){super(),this._parentWindow=e,this._currentDevicePixelRatio=this._parentWindow.devicePixelRatio,this.register((0,s.toDisposable)((()=>{this.clearListener()})))}setListener(e){this._listener&&this.clearListener(),this._listener=e,this._outerListener=()=>{this._listener&&(this._listener(this._parentWindow.devicePixelRatio,this._currentDevicePixelRatio),this._updateDpr())},this._updateDpr()}_updateDpr(){var e;this._outerListener&&(null===(e=this._resolutionMediaMatchList)||void 0===e||e.removeListener(this._outerListener),this._currentDevicePixelRatio=this._parentWindow.devicePixelRatio,this._resolutionMediaMatchList=this._parentWindow.matchMedia(`screen and (resolution: ${this._parentWindow.devicePixelRatio}dppx)`),this._resolutionMediaMatchList.addListener(this._outerListener))}clearListener(){this._resolutionMediaMatchList&&this._listener&&this._outerListener&&(this._resolutionMediaMatchList.removeListener(this._outerListener),this._resolutionMediaMatchList=void 0,this._listener=void 0,this._outerListener=void 0)}}t.ScreenDprMonitor=r},3236:(e,t,i)=>{Object.defineProperty(t,"__esModule",{value:!0}),t.Terminal=void 0;const s=i(3614),r=i(3656),n=i(6465),o=i(9042),a=i(3730),h=i(1680),c=i(3107),l=i(5744),d=i(2950),_=i(1296),u=i(428),f=i(4269),v=i(5114),p=i(8934),g=i(3230),m=i(9312),S=i(4725),C=i(6731),b=i(8055),y=i(8969),w=i(8460),E=i(844),k=i(6114),L=i(8437),D=i(2584),R=i(7399),x=i(5941),A=i(9074),B=i(2585),T=i(5435),M=i(4567),O="undefined"!=typeof window?window.document:null;class P extends y.CoreTerminal{get onFocus(){return this._onFocus.event}get onBlur(){return this._onBlur.event}get onA11yChar(){return this._onA11yCharEmitter.event}get onA11yTab(){return this._onA11yTabEmitter.event}get onWillOpen(){return this._onWillOpen.event}constructor(e={}){super(e),this.browser=k,this._keyDownHandled=!1,this._keyDownSeen=!1,this._keyPressHandled=!1,this._unprocessedDeadKey=!1,this._accessibilityManager=this.register(new E.MutableDisposable),this._onCursorMove=this.register(new w.EventEmitter),this.onCursorMove=this._onCursorMove.event,this._onKey=this.register(new w.EventEmitter),this.onKey=this._onKey.event,this._onRender=this.register(new w.EventEmitter),this.onRender=this._onRender.event,this._onSelectionChange=this.register(new w.EventEmitter),this.onSelectionChange=this._onSelectionChange.event,this._onTitleChange=this.register(new w.EventEmitter),this.onTitleChange=this._onTitleChange.event,this._onBell=this.register(new w.EventEmitter),this.onBell=this._onBell.event,this._onFocus=this.register(new w.EventEmitter),this._onBlur=this.register(new w.EventEmitter),this._onA11yCharEmitter=this.register(new w.EventEmitter),this._onA11yTabEmitter=this.register(new w.EventEmitter),this._onWillOpen=this.register(new w.EventEmitter),this._setup(),this.linkifier2=this.register(this._instantiationService.createInstance(n.Linkifier2)),this.linkifier2.registerLinkProvider(this._instantiationService.createInstance(a.OscLinkProvider)),this._decorationService=this._instantiationService.createInstance(A.DecorationService),this._instantiationService.setService(B.IDecorationService,this._decorationService),this.register(this._inputHandler.onRequestBell((()=>this._onBell.fire()))),this.register(this._inputHandler.onRequestRefreshRows(((e,t)=>this.refresh(e,t)))),this.register(this._inputHandler.onRequestSendFocus((()=>this._reportFocus()))),this.register(this._inputHandler.onRequestReset((()=>this.reset()))),this.register(this._inputHandler.onRequestWindowsOptionsReport((e=>this._reportWindowsOptions(e)))),this.register(this._inputHandler.onColor((e=>this._handleColorEvent(e)))),this.register((0,w.forwardEvent)(this._inputHandler.onCursorMove,this._onCursorMove)),this.register((0,w.forwardEvent)(this._inputHandler.onTitleChange,this._onTitleChange)),this.register((0,w.forwardEvent)(this._inputHandler.onA11yChar,this._onA11yCharEmitter)),this.register((0,w.forwardEvent)(this._inputHandler.onA11yTab,this._onA11yTabEmitter)),this.register(this._bufferService.onResize((e=>this._afterResize(e.cols,e.rows)))),this.register((0,E.toDisposable)((()=>{var e,t;this._customKeyEventHandler=void 0,null===(t=null===(e=this.element)||void 0===e?void 0:e.parentNode)||void 0===t||t.removeChild(this.element)})))}_handleColorEvent(e){if(this._themeService)for(const t of e){let e,i="";switch(t.index){case 256:e="foreground",i="10";break;case 257:e="background",i="11";break;case 258:e="cursor",i="12";break;default:e="ansi",i="4;"+t.index}switch(t.type){case 0:const s=b.color.toColorRGB("ansi"===e?this._themeService.colors.ansi[t.index]:this._themeService.colors[e]);this.coreService.triggerDataEvent(`${D.C0.ESC}]${i};${(0,x.toRgbString)(s)}${D.C1_ESCAPED.ST}`);break;case 1:if("ansi"===e)this._themeService.modifyColors((e=>e.ansi[t.index]=b.rgba.toColor(...t.color)));else{const i=e;this._themeService.modifyColors((e=>e[i]=b.rgba.toColor(...t.color)))}break;case 2:this._themeService.restoreColor(t.index)}}}_setup(){super._setup(),this._customKeyEventHandler=void 0}get buffer(){return this.buffers.active}focus(){this.textarea&&this.textarea.focus({preventScroll:!0})}_handleScreenReaderModeOptionChange(e){e?!this._accessibilityManager.value&&this._renderService&&(this._accessibilityManager.value=this._instantiationService.createInstance(M.AccessibilityManager,this)):this._accessibilityManager.clear()}_handleTextAreaFocus(e){this.coreService.decPrivateModes.sendFocus&&this.coreService.triggerDataEvent(D.C0.ESC+"[I"),this.updateCursorStyle(e),this.element.classList.add("focus"),this._showCursor(),this._onFocus.fire()}blur(){var e;return null===(e=this.textarea)||void 0===e?void 0:e.blur()}_handleTextAreaBlur(){this.textarea.value="",this.refresh(this.buffer.y,this.buffer.y),this.coreService.decPrivateModes.sendFocus&&this.coreService.triggerDataEvent(D.C0.ESC+"[O"),this.element.classList.remove("focus"),this._onBlur.fire()}_syncTextArea(){if(!this.textarea||!this.buffer.isCursorInViewport||this._compositionHelper.isComposing||!this._renderService)return;const e=this.buffer.ybase+this.buffer.y,t=this.buffer.lines.get(e);if(!t)return;const i=Math.min(this.buffer.x,this.cols-1),s=this._renderService.dimensions.css.cell.height,r=t.getWidth(i),n=this._renderService.dimensions.css.cell.width*r,o=this.buffer.y*this._renderService.dimensions.css.cell.height,a=i*this._renderService.dimensions.css.cell.width;this.textarea.style.left=a+"px",this.textarea.style.top=o+"px",this.textarea.style.width=n+"px",this.textarea.style.height=s+"px",this.textarea.style.lineHeight=s+"px",this.textarea.style.zIndex="-5"}_initGlobal(){this._bindKeys(),this.register((0,r.addDisposableDomListener)(this.element,"copy",(e=>{this.hasSelection()&&(0,s.copyHandler)(e,this._selectionService)})));const e=e=>(0,s.handlePasteEvent)(e,this.textarea,this.coreService,this.optionsService);this.register((0,r.addDisposableDomListener)(this.textarea,"paste",e)),this.register((0,r.addDisposableDomListener)(this.element,"paste",e)),k.isFirefox?this.register((0,r.addDisposableDomListener)(this.element,"mousedown",(e=>{2===e.button&&(0,s.rightClickHandler)(e,this.textarea,this.screenElement,this._selectionService,this.options.rightClickSelectsWord)}))):this.register((0,r.addDisposableDomListener)(this.element,"contextmenu",(e=>{(0,s.rightClickHandler)(e,this.textarea,this.screenElement,this._selectionService,this.options.rightClickSelectsWord)}))),k.isLinux&&this.register((0,r.addDisposableDomListener)(this.element,"auxclick",(e=>{1===e.button&&(0,s.moveTextAreaUnderMouseCursor)(e,this.textarea,this.screenElement)})))}_bindKeys(){this.register((0,r.addDisposableDomListener)(this.textarea,"keyup",(e=>this._keyUp(e)),!0)),this.register((0,r.addDisposableDomListener)(this.textarea,"keydown",(e=>this._keyDown(e)),!0)),this.register((0,r.addDisposableDomListener)(this.textarea,"keypress",(e=>this._keyPress(e)),!0)),this.register((0,r.addDisposableDomListener)(this.textarea,"compositionstart",(()=>this._compositionHelper.compositionstart()))),this.register((0,r.addDisposableDomListener)(this.textarea,"compositionupdate",(e=>this._compositionHelper.compositionupdate(e)))),this.register((0,r.addDisposableDomListener)(this.textarea,"compositionend",(()=>this._compositionHelper.compositionend()))),this.register((0,r.addDisposableDomListener)(this.textarea,"input",(e=>this._inputEvent(e)),!0)),this.register(this.onRender((()=>this._compositionHelper.updateCompositionElements())))}open(e){var t;if(!e)throw new Error("Terminal requires a parent element.");e.isConnected||this._logService.debug("Terminal.open was called on an element that was not attached to the DOM"),this._document=e.ownerDocument,this.element=this._document.createElement("div"),this.element.dir="ltr",this.element.classList.add("terminal"),this.element.classList.add("xterm"),e.appendChild(this.element);const i=O.createDocumentFragment();this._viewportElement=O.createElement("div"),this._viewportElement.classList.add("xterm-viewport"),i.appendChild(this._viewportElement),this._viewportScrollArea=O.createElement("div"),this._viewportScrollArea.classList.add("xterm-scroll-area"),this._viewportElement.appendChild(this._viewportScrollArea),this.screenElement=O.createElement("div"),this.screenElement.classList.add("xterm-screen"),this._helperContainer=O.createElement("div"),this._helperContainer.classList.add("xterm-helpers"),this.screenElement.appendChild(this._helperContainer),i.appendChild(this.screenElement),this.textarea=O.createElement("textarea"),this.textarea.classList.add("xterm-helper-textarea"),this.textarea.setAttribute("aria-label",o.promptLabel),k.isChromeOS||this.textarea.setAttribute("aria-multiline","false"),this.textarea.setAttribute("autocorrect","off"),this.textarea.setAttribute("autocapitalize","off"),this.textarea.setAttribute("spellcheck","false"),this.textarea.tabIndex=0,this._coreBrowserService=this._instantiationService.createInstance(v.CoreBrowserService,this.textarea,null!==(t=this._document.defaultView)&&void 0!==t?t:window),this._instantiationService.setService(S.ICoreBrowserService,this._coreBrowserService),this.register((0,r.addDisposableDomListener)(this.textarea,"focus",(e=>this._handleTextAreaFocus(e)))),this.register((0,r.addDisposableDomListener)(this.textarea,"blur",(()=>this._handleTextAreaBlur()))),this._helperContainer.appendChild(this.textarea),this._charSizeService=this._instantiationService.createInstance(u.CharSizeService,this._document,this._helperContainer),this._instantiationService.setService(S.ICharSizeService,this._charSizeService),this._themeService=this._instantiationService.createInstance(C.ThemeService),this._instantiationService.setService(S.IThemeService,this._themeService),this._characterJoinerService=this._instantiationService.createInstance(f.CharacterJoinerService),this._instantiationService.setService(S.ICharacterJoinerService,this._characterJoinerService),this._renderService=this.register(this._instantiationService.createInstance(g.RenderService,this.rows,this.screenElement)),this._instantiationService.setService(S.IRenderService,this._renderService),this.register(this._renderService.onRenderedViewportChange((e=>this._onRender.fire(e)))),this.onResize((e=>this._renderService.resize(e.cols,e.rows))),this._compositionView=O.createElement("div"),this._compositionView.classList.add("composition-view"),this._compositionHelper=this._instantiationService.createInstance(d.CompositionHelper,this.textarea,this._compositionView),this._helperContainer.appendChild(this._compositionView),this.element.appendChild(i);try{this._onWillOpen.fire(this.element)}catch(e){}this._renderService.hasRenderer()||this._renderService.setRenderer(this._createRenderer()),this._mouseService=this._instantiationService.createInstance(p.MouseService),this._instantiationService.setService(S.IMouseService,this._mouseService),this.viewport=this._instantiationService.createInstance(h.Viewport,this._viewportElement,this._viewportScrollArea),this.viewport.onRequestScrollLines((e=>this.scrollLines(e.amount,e.suppressScrollEvent,1))),this.register(this._inputHandler.onRequestSyncScrollBar((()=>this.viewport.syncScrollArea()))),this.register(this.viewport),this.register(this.onCursorMove((()=>{this._renderService.handleCursorMove(),this._syncTextArea()}))),this.register(this.onResize((()=>this._renderService.handleResize(this.cols,this.rows)))),this.register(this.onBlur((()=>this._renderService.handleBlur()))),this.register(this.onFocus((()=>this._renderService.handleFocus()))),this.register(this._renderService.onDimensionsChange((()=>this.viewport.syncScrollArea()))),this._selectionService=this.register(this._instantiationService.createInstance(m.SelectionService,this.element,this.screenElement,this.linkifier2)),this._instantiationService.setService(S.ISelectionService,this._selectionService),this.register(this._selectionService.onRequestScrollLines((e=>this.scrollLines(e.amount,e.suppressScrollEvent)))),this.register(this._selectionService.onSelectionChange((()=>this._onSelectionChange.fire()))),this.register(this._selectionService.onRequestRedraw((e=>this._renderService.handleSelectionChanged(e.start,e.end,e.columnSelectMode)))),this.register(this._selectionService.onLinuxMouseSelection((e=>{this.textarea.value=e,this.textarea.focus(),this.textarea.select()}))),this.register(this._onScroll.event((e=>{this.viewport.syncScrollArea(),this._selectionService.refresh()}))),this.register((0,r.addDisposableDomListener)(this._viewportElement,"scroll",(()=>this._selectionService.refresh()))),this.linkifier2.attachToDom(this.screenElement,this._mouseService,this._renderService),this.register(this._instantiationService.createInstance(c.BufferDecorationRenderer,this.screenElement)),this.register((0,r.addDisposableDomListener)(this.element,"mousedown",(e=>this._selectionService.handleMouseDown(e)))),this.coreMouseService.areMouseEventsActive?(this._selectionService.disable(),this.element.classList.add("enable-mouse-events")):this._selectionService.enable(),this.options.screenReaderMode&&(this._accessibilityManager.value=this._instantiationService.createInstance(M.AccessibilityManager,this)),this.register(this.optionsService.onSpecificOptionChange("screenReaderMode",(e=>this._handleScreenReaderModeOptionChange(e)))),this.options.overviewRulerWidth&&(this._overviewRulerRenderer=this.register(this._instantiationService.createInstance(l.OverviewRulerRenderer,this._viewportElement,this.screenElement))),this.optionsService.onSpecificOptionChange("overviewRulerWidth",(e=>{!this._overviewRulerRenderer&&e&&this._viewportElement&&this.screenElement&&(this._overviewRulerRenderer=this.register(this._instantiationService.createInstance(l.OverviewRulerRenderer,this._viewportElement,this.screenElement)))})),this._charSizeService.measure(),this.refresh(0,this.rows-1),this._initGlobal(),this.bindMouse()}_createRenderer(){return this._instantiationService.createInstance(_.DomRenderer,this.element,this.screenElement,this._viewportElement,this.linkifier2)}bindMouse(){const e=this,t=this.element;function i(t){const i=e._mouseService.getMouseReportCoords(t,e.screenElement);if(!i)return!1;let s,r;switch(t.overrideType||t.type){case"mousemove":r=32,void 0===t.buttons?(s=3,void 0!==t.button&&(s=t.button<3?t.button:3)):s=1&t.buttons?0:4&t.buttons?1:2&t.buttons?2:3;break;case"mouseup":r=0,s=t.button<3?t.button:3;break;case"mousedown":r=1,s=t.button<3?t.button:3;break;case"wheel":if(0===e.viewport.getLinesScrolled(t))return!1;r=t.deltaY<0?0:1,s=4;break;default:return!1}return!(void 0===r||void 0===s||s>4)&&e.coreMouseService.triggerMouseEvent({col:i.col,row:i.row,x:i.x,y:i.y,button:s,action:r,ctrl:t.ctrlKey,alt:t.altKey,shift:t.shiftKey})}const s={mouseup:null,wheel:null,mousedrag:null,mousemove:null},n={mouseup:e=>(i(e),e.buttons||(this._document.removeEventListener("mouseup",s.mouseup),s.mousedrag&&this._document.removeEventListener("mousemove",s.mousedrag)),this.cancel(e)),wheel:e=>(i(e),this.cancel(e,!0)),mousedrag:e=>{e.buttons&&i(e)},mousemove:e=>{e.buttons||i(e)}};this.register(this.coreMouseService.onProtocolChange((e=>{e?("debug"===this.optionsService.rawOptions.logLevel&&this._logService.debug("Binding to mouse events:",this.coreMouseService.explainEvents(e)),this.element.classList.add("enable-mouse-events"),this._selectionService.disable()):(this._logService.debug("Unbinding from mouse events."),this.element.classList.remove("enable-mouse-events"),this._selectionService.enable()),8&e?s.mousemove||(t.addEventListener("mousemove",n.mousemove),s.mousemove=n.mousemove):(t.removeEventListener("mousemove",s.mousemove),s.mousemove=null),16&e?s.wheel||(t.addEventListener("wheel",n.wheel,{passive:!1}),s.wheel=n.wheel):(t.removeEventListener("wheel",s.wheel),s.wheel=null),2&e?s.mouseup||(t.addEventListener("mouseup",n.mouseup),s.mouseup=n.mouseup):(this._document.removeEventListener("mouseup",s.mouseup),t.removeEventListener("mouseup",s.mouseup),s.mouseup=null),4&e?s.mousedrag||(s.mousedrag=n.mousedrag):(this._document.removeEventListener("mousemove",s.mousedrag),s.mousedrag=null)}))),this.coreMouseService.activeProtocol=this.coreMouseService.activeProtocol,this.register((0,r.addDisposableDomListener)(t,"mousedown",(e=>{if(e.preventDefault(),this.focus(),this.coreMouseService.areMouseEventsActive&&!this._selectionService.shouldForceSelection(e))return i(e),s.mouseup&&this._document.addEventListener("mouseup",s.mouseup),s.mousedrag&&this._document.addEventListener("mousemove",s.mousedrag),this.cancel(e)}))),this.register((0,r.addDisposableDomListener)(t,"wheel",(e=>{if(!s.wheel){if(!this.buffer.hasScrollback){const t=this.viewport.getLinesScrolled(e);if(0===t)return;const i=D.C0.ESC+(this.coreService.decPrivateModes.applicationCursorKeys?"O":"[")+(e.deltaY<0?"A":"B");let s="";for(let e=0;e{if(!this.coreMouseService.areMouseEventsActive)return this.viewport.handleTouchStart(e),this.cancel(e)}),{passive:!0})),this.register((0,r.addDisposableDomListener)(t,"touchmove",(e=>{if(!this.coreMouseService.areMouseEventsActive)return this.viewport.handleTouchMove(e)?void 0:this.cancel(e)}),{passive:!1}))}refresh(e,t){var i;null===(i=this._renderService)||void 0===i||i.refreshRows(e,t)}updateCursorStyle(e){var t;(null===(t=this._selectionService)||void 0===t?void 0:t.shouldColumnSelect(e))?this.element.classList.add("column-select"):this.element.classList.remove("column-select")}_showCursor(){this.coreService.isCursorInitialized||(this.coreService.isCursorInitialized=!0,this.refresh(this.buffer.y,this.buffer.y))}scrollLines(e,t,i=0){var s;1===i?(super.scrollLines(e,t,i),this.refresh(0,this.rows-1)):null===(s=this.viewport)||void 0===s||s.scrollLines(e)}paste(e){(0,s.paste)(e,this.textarea,this.coreService,this.optionsService)}attachCustomKeyEventHandler(e){this._customKeyEventHandler=e}registerLinkProvider(e){return this.linkifier2.registerLinkProvider(e)}registerCharacterJoiner(e){if(!this._characterJoinerService)throw new Error("Terminal must be opened first");const t=this._characterJoinerService.register(e);return this.refresh(0,this.rows-1),t}deregisterCharacterJoiner(e){if(!this._characterJoinerService)throw new Error("Terminal must be opened first");this._characterJoinerService.deregister(e)&&this.refresh(0,this.rows-1)}get markers(){return this.buffer.markers}registerMarker(e){return this.buffer.addMarker(this.buffer.ybase+this.buffer.y+e)}registerDecoration(e){return this._decorationService.registerDecoration(e)}hasSelection(){return!!this._selectionService&&this._selectionService.hasSelection}select(e,t,i){this._selectionService.setSelection(e,t,i)}getSelection(){return this._selectionService?this._selectionService.selectionText:""}getSelectionPosition(){if(this._selectionService&&this._selectionService.hasSelection)return{start:{x:this._selectionService.selectionStart[0],y:this._selectionService.selectionStart[1]},end:{x:this._selectionService.selectionEnd[0],y:this._selectionService.selectionEnd[1]}}}clearSelection(){var e;null===(e=this._selectionService)||void 0===e||e.clearSelection()}selectAll(){var e;null===(e=this._selectionService)||void 0===e||e.selectAll()}selectLines(e,t){var i;null===(i=this._selectionService)||void 0===i||i.selectLines(e,t)}_keyDown(e){if(this._keyDownHandled=!1,this._keyDownSeen=!0,this._customKeyEventHandler&&!1===this._customKeyEventHandler(e))return!1;const t=this.browser.isMac&&this.options.macOptionIsMeta&&e.altKey;if(!t&&!this._compositionHelper.keydown(e))return this.options.scrollOnUserInput&&this.buffer.ybase!==this.buffer.ydisp&&this.scrollToBottom(),!1;t||"Dead"!==e.key&&"AltGraph"!==e.key||(this._unprocessedDeadKey=!0);const i=(0,R.evaluateKeyboardEvent)(e,this.coreService.decPrivateModes.applicationCursorKeys,this.browser.isMac,this.options.macOptionIsMeta);if(this.updateCursorStyle(e),3===i.type||2===i.type){const t=this.rows-1;return this.scrollLines(2===i.type?-t:t),this.cancel(e,!0)}return 1===i.type&&this.selectAll(),!!this._isThirdLevelShift(this.browser,e)||(i.cancel&&this.cancel(e,!0),!i.key||!!(e.key&&!e.ctrlKey&&!e.altKey&&!e.metaKey&&1===e.key.length&&e.key.charCodeAt(0)>=65&&e.key.charCodeAt(0)<=90)||(this._unprocessedDeadKey?(this._unprocessedDeadKey=!1,!0):(i.key!==D.C0.ETX&&i.key!==D.C0.CR||(this.textarea.value=""),this._onKey.fire({key:i.key,domEvent:e}),this._showCursor(),this.coreService.triggerDataEvent(i.key,!0),!this.optionsService.rawOptions.screenReaderMode||e.altKey||e.ctrlKey?this.cancel(e,!0):void(this._keyDownHandled=!0))))}_isThirdLevelShift(e,t){const i=e.isMac&&!this.options.macOptionIsMeta&&t.altKey&&!t.ctrlKey&&!t.metaKey||e.isWindows&&t.altKey&&t.ctrlKey&&!t.metaKey||e.isWindows&&t.getModifierState("AltGraph");return"keypress"===t.type?i:i&&(!t.keyCode||t.keyCode>47)}_keyUp(e){this._keyDownSeen=!1,this._customKeyEventHandler&&!1===this._customKeyEventHandler(e)||(function(e){return 16===e.keyCode||17===e.keyCode||18===e.keyCode}(e)||this.focus(),this.updateCursorStyle(e),this._keyPressHandled=!1)}_keyPress(e){let t;if(this._keyPressHandled=!1,this._keyDownHandled)return!1;if(this._customKeyEventHandler&&!1===this._customKeyEventHandler(e))return!1;if(this.cancel(e),e.charCode)t=e.charCode;else if(null===e.which||void 0===e.which)t=e.keyCode;else{if(0===e.which||0===e.charCode)return!1;t=e.which}return!(!t||(e.altKey||e.ctrlKey||e.metaKey)&&!this._isThirdLevelShift(this.browser,e)||(t=String.fromCharCode(t),this._onKey.fire({key:t,domEvent:e}),this._showCursor(),this.coreService.triggerDataEvent(t,!0),this._keyPressHandled=!0,this._unprocessedDeadKey=!1,0))}_inputEvent(e){if(e.data&&"insertText"===e.inputType&&(!e.composed||!this._keyDownSeen)&&!this.optionsService.rawOptions.screenReaderMode){if(this._keyPressHandled)return!1;this._unprocessedDeadKey=!1;const t=e.data;return this.coreService.triggerDataEvent(t,!0),this.cancel(e),!0}return!1}resize(e,t){e!==this.cols||t!==this.rows?super.resize(e,t):this._charSizeService&&!this._charSizeService.hasValidSize&&this._charSizeService.measure()}_afterResize(e,t){var i,s;null===(i=this._charSizeService)||void 0===i||i.measure(),null===(s=this.viewport)||void 0===s||s.syncScrollArea(!0)}clear(){var e;if(0!==this.buffer.ybase||0!==this.buffer.y){this.buffer.clearAllMarkers(),this.buffer.lines.set(0,this.buffer.lines.get(this.buffer.ybase+this.buffer.y)),this.buffer.lines.length=1,this.buffer.ydisp=0,this.buffer.ybase=0,this.buffer.y=0;for(let e=1;e{Object.defineProperty(t,"__esModule",{value:!0}),t.TimeBasedDebouncer=void 0,t.TimeBasedDebouncer=class{constructor(e,t=1e3){this._renderCallback=e,this._debounceThresholdMS=t,this._lastRefreshMs=0,this._additionalRefreshRequested=!1}dispose(){this._refreshTimeoutID&&clearTimeout(this._refreshTimeoutID)}refresh(e,t,i){this._rowCount=i,e=void 0!==e?e:0,t=void 0!==t?t:this._rowCount-1,this._rowStart=void 0!==this._rowStart?Math.min(this._rowStart,e):e,this._rowEnd=void 0!==this._rowEnd?Math.max(this._rowEnd,t):t;const s=Date.now();if(s-this._lastRefreshMs>=this._debounceThresholdMS)this._lastRefreshMs=s,this._innerRefresh();else if(!this._additionalRefreshRequested){const e=s-this._lastRefreshMs,t=this._debounceThresholdMS-e;this._additionalRefreshRequested=!0,this._refreshTimeoutID=window.setTimeout((()=>{this._lastRefreshMs=Date.now(),this._innerRefresh(),this._additionalRefreshRequested=!1,this._refreshTimeoutID=void 0}),t)}}_innerRefresh(){if(void 0===this._rowStart||void 0===this._rowEnd||void 0===this._rowCount)return;const e=Math.max(this._rowStart,0),t=Math.min(this._rowEnd,this._rowCount-1);this._rowStart=void 0,this._rowEnd=void 0,this._renderCallback(e,t)}}},1680:function(e,t,i){var s=this&&this.__decorate||function(e,t,i,s){var r,n=arguments.length,o=n<3?t:null===s?s=Object.getOwnPropertyDescriptor(t,i):s;if("object"==typeof Reflect&&"function"==typeof Reflect.decorate)o=Reflect.decorate(e,t,i,s);else for(var a=e.length-1;a>=0;a--)(r=e[a])&&(o=(n<3?r(o):n>3?r(t,i,o):r(t,i))||o);return n>3&&o&&Object.defineProperty(t,i,o),o},r=this&&this.__param||function(e,t){return function(i,s){t(i,s,e)}};Object.defineProperty(t,"__esModule",{value:!0}),t.Viewport=void 0;const n=i(3656),o=i(4725),a=i(8460),h=i(844),c=i(2585);let l=t.Viewport=class extends h.Disposable{constructor(e,t,i,s,r,o,h,c){super(),this._viewportElement=e,this._scrollArea=t,this._bufferService=i,this._optionsService=s,this._charSizeService=r,this._renderService=o,this._coreBrowserService=h,this.scrollBarWidth=0,this._currentRowHeight=0,this._currentDeviceCellHeight=0,this._lastRecordedBufferLength=0,this._lastRecordedViewportHeight=0,this._lastRecordedBufferHeight=0,this._lastTouchY=0,this._lastScrollTop=0,this._wheelPartialScroll=0,this._refreshAnimationFrame=null,this._ignoreNextScrollEvent=!1,this._smoothScrollState={startTime:0,origin:-1,target:-1},this._onRequestScrollLines=this.register(new a.EventEmitter),this.onRequestScrollLines=this._onRequestScrollLines.event,this.scrollBarWidth=this._viewportElement.offsetWidth-this._scrollArea.offsetWidth||15,this.register((0,n.addDisposableDomListener)(this._viewportElement,"scroll",this._handleScroll.bind(this))),this._activeBuffer=this._bufferService.buffer,this.register(this._bufferService.buffers.onBufferActivate((e=>this._activeBuffer=e.activeBuffer))),this._renderDimensions=this._renderService.dimensions,this.register(this._renderService.onDimensionsChange((e=>this._renderDimensions=e))),this._handleThemeChange(c.colors),this.register(c.onChangeColors((e=>this._handleThemeChange(e)))),this.register(this._optionsService.onSpecificOptionChange("scrollback",(()=>this.syncScrollArea()))),setTimeout((()=>this.syncScrollArea()))}_handleThemeChange(e){this._viewportElement.style.backgroundColor=e.background.css}reset(){this._currentRowHeight=0,this._currentDeviceCellHeight=0,this._lastRecordedBufferLength=0,this._lastRecordedViewportHeight=0,this._lastRecordedBufferHeight=0,this._lastTouchY=0,this._lastScrollTop=0,this._coreBrowserService.window.requestAnimationFrame((()=>this.syncScrollArea()))}_refresh(e){if(e)return this._innerRefresh(),void(null!==this._refreshAnimationFrame&&this._coreBrowserService.window.cancelAnimationFrame(this._refreshAnimationFrame));null===this._refreshAnimationFrame&&(this._refreshAnimationFrame=this._coreBrowserService.window.requestAnimationFrame((()=>this._innerRefresh())))}_innerRefresh(){if(this._charSizeService.height>0){this._currentRowHeight=this._renderService.dimensions.device.cell.height/this._coreBrowserService.dpr,this._currentDeviceCellHeight=this._renderService.dimensions.device.cell.height,this._lastRecordedViewportHeight=this._viewportElement.offsetHeight;const e=Math.round(this._currentRowHeight*this._lastRecordedBufferLength)+(this._lastRecordedViewportHeight-this._renderService.dimensions.css.canvas.height);this._lastRecordedBufferHeight!==e&&(this._lastRecordedBufferHeight=e,this._scrollArea.style.height=this._lastRecordedBufferHeight+"px")}const e=this._bufferService.buffer.ydisp*this._currentRowHeight;this._viewportElement.scrollTop!==e&&(this._ignoreNextScrollEvent=!0,this._viewportElement.scrollTop=e),this._refreshAnimationFrame=null}syncScrollArea(e=!1){if(this._lastRecordedBufferLength!==this._bufferService.buffer.lines.length)return this._lastRecordedBufferLength=this._bufferService.buffer.lines.length,void this._refresh(e);this._lastRecordedViewportHeight===this._renderService.dimensions.css.canvas.height&&this._lastScrollTop===this._activeBuffer.ydisp*this._currentRowHeight&&this._renderDimensions.device.cell.height===this._currentDeviceCellHeight||this._refresh(e)}_handleScroll(e){if(this._lastScrollTop=this._viewportElement.scrollTop,!this._viewportElement.offsetParent)return;if(this._ignoreNextScrollEvent)return this._ignoreNextScrollEvent=!1,void this._onRequestScrollLines.fire({amount:0,suppressScrollEvent:!0});const t=Math.round(this._lastScrollTop/this._currentRowHeight)-this._bufferService.buffer.ydisp;this._onRequestScrollLines.fire({amount:t,suppressScrollEvent:!0})}_smoothScroll(){if(this._isDisposed||-1===this._smoothScrollState.origin||-1===this._smoothScrollState.target)return;const e=this._smoothScrollPercent();this._viewportElement.scrollTop=this._smoothScrollState.origin+Math.round(e*(this._smoothScrollState.target-this._smoothScrollState.origin)),e<1?this._coreBrowserService.window.requestAnimationFrame((()=>this._smoothScroll())):this._clearSmoothScrollState()}_smoothScrollPercent(){return this._optionsService.rawOptions.smoothScrollDuration&&this._smoothScrollState.startTime?Math.max(Math.min((Date.now()-this._smoothScrollState.startTime)/this._optionsService.rawOptions.smoothScrollDuration,1),0):1}_clearSmoothScrollState(){this._smoothScrollState.startTime=0,this._smoothScrollState.origin=-1,this._smoothScrollState.target=-1}_bubbleScroll(e,t){const i=this._viewportElement.scrollTop+this._lastRecordedViewportHeight;return!(t<0&&0!==this._viewportElement.scrollTop||t>0&&i0&&(s=e),r=""}}return{bufferElements:n,cursorElement:s}}getLinesScrolled(e){if(0===e.deltaY||e.shiftKey)return 0;let t=this._applyScrollModifier(e.deltaY,e);return e.deltaMode===WheelEvent.DOM_DELTA_PIXEL?(t/=this._currentRowHeight+0,this._wheelPartialScroll+=t,t=Math.floor(Math.abs(this._wheelPartialScroll))*(this._wheelPartialScroll>0?1:-1),this._wheelPartialScroll%=1):e.deltaMode===WheelEvent.DOM_DELTA_PAGE&&(t*=this._bufferService.rows),t}_applyScrollModifier(e,t){const i=this._optionsService.rawOptions.fastScrollModifier;return"alt"===i&&t.altKey||"ctrl"===i&&t.ctrlKey||"shift"===i&&t.shiftKey?e*this._optionsService.rawOptions.fastScrollSensitivity*this._optionsService.rawOptions.scrollSensitivity:e*this._optionsService.rawOptions.scrollSensitivity}handleTouchStart(e){this._lastTouchY=e.touches[0].pageY}handleTouchMove(e){const t=this._lastTouchY-e.touches[0].pageY;return this._lastTouchY=e.touches[0].pageY,0!==t&&(this._viewportElement.scrollTop+=t,this._bubbleScroll(e,t))}};t.Viewport=l=s([r(2,c.IBufferService),r(3,c.IOptionsService),r(4,o.ICharSizeService),r(5,o.IRenderService),r(6,o.ICoreBrowserService),r(7,o.IThemeService)],l)},3107:function(e,t,i){var s=this&&this.__decorate||function(e,t,i,s){var r,n=arguments.length,o=n<3?t:null===s?s=Object.getOwnPropertyDescriptor(t,i):s;if("object"==typeof Reflect&&"function"==typeof Reflect.decorate)o=Reflect.decorate(e,t,i,s);else for(var a=e.length-1;a>=0;a--)(r=e[a])&&(o=(n<3?r(o):n>3?r(t,i,o):r(t,i))||o);return n>3&&o&&Object.defineProperty(t,i,o),o},r=this&&this.__param||function(e,t){return function(i,s){t(i,s,e)}};Object.defineProperty(t,"__esModule",{value:!0}),t.BufferDecorationRenderer=void 0;const n=i(3656),o=i(4725),a=i(844),h=i(2585);let c=t.BufferDecorationRenderer=class extends a.Disposable{constructor(e,t,i,s){super(),this._screenElement=e,this._bufferService=t,this._decorationService=i,this._renderService=s,this._decorationElements=new Map,this._altBufferIsActive=!1,this._dimensionsChanged=!1,this._container=document.createElement("div"),this._container.classList.add("xterm-decoration-container"),this._screenElement.appendChild(this._container),this.register(this._renderService.onRenderedViewportChange((()=>this._doRefreshDecorations()))),this.register(this._renderService.onDimensionsChange((()=>{this._dimensionsChanged=!0,this._queueRefresh()}))),this.register((0,n.addDisposableDomListener)(window,"resize",(()=>this._queueRefresh()))),this.register(this._bufferService.buffers.onBufferActivate((()=>{this._altBufferIsActive=this._bufferService.buffer===this._bufferService.buffers.alt}))),this.register(this._decorationService.onDecorationRegistered((()=>this._queueRefresh()))),this.register(this._decorationService.onDecorationRemoved((e=>this._removeDecoration(e)))),this.register((0,a.toDisposable)((()=>{this._container.remove(),this._decorationElements.clear()})))}_queueRefresh(){void 0===this._animationFrame&&(this._animationFrame=this._renderService.addRefreshCallback((()=>{this._doRefreshDecorations(),this._animationFrame=void 0})))}_doRefreshDecorations(){for(const e of this._decorationService.decorations)this._renderDecoration(e);this._dimensionsChanged=!1}_renderDecoration(e){this._refreshStyle(e),this._dimensionsChanged&&this._refreshXPosition(e)}_createElement(e){var t,i;const s=document.createElement("div");s.classList.add("xterm-decoration"),s.classList.toggle("xterm-decoration-top-layer","top"===(null===(t=null==e?void 0:e.options)||void 0===t?void 0:t.layer)),s.style.width=`${Math.round((e.options.width||1)*this._renderService.dimensions.css.cell.width)}px`,s.style.height=(e.options.height||1)*this._renderService.dimensions.css.cell.height+"px",s.style.top=(e.marker.line-this._bufferService.buffers.active.ydisp)*this._renderService.dimensions.css.cell.height+"px",s.style.lineHeight=`${this._renderService.dimensions.css.cell.height}px`;const r=null!==(i=e.options.x)&&void 0!==i?i:0;return r&&r>this._bufferService.cols&&(s.style.display="none"),this._refreshXPosition(e,s),s}_refreshStyle(e){const t=e.marker.line-this._bufferService.buffers.active.ydisp;if(t<0||t>=this._bufferService.rows)e.element&&(e.element.style.display="none",e.onRenderEmitter.fire(e.element));else{let i=this._decorationElements.get(e);i||(i=this._createElement(e),e.element=i,this._decorationElements.set(e,i),this._container.appendChild(i),e.onDispose((()=>{this._decorationElements.delete(e),i.remove()}))),i.style.top=t*this._renderService.dimensions.css.cell.height+"px",i.style.display=this._altBufferIsActive?"none":"block",e.onRenderEmitter.fire(i)}}_refreshXPosition(e,t=e.element){var i;if(!t)return;const s=null!==(i=e.options.x)&&void 0!==i?i:0;"right"===(e.options.anchor||"left")?t.style.right=s?s*this._renderService.dimensions.css.cell.width+"px":"":t.style.left=s?s*this._renderService.dimensions.css.cell.width+"px":""}_removeDecoration(e){var t;null===(t=this._decorationElements.get(e))||void 0===t||t.remove(),this._decorationElements.delete(e),e.dispose()}};t.BufferDecorationRenderer=c=s([r(1,h.IBufferService),r(2,h.IDecorationService),r(3,o.IRenderService)],c)},5871:(e,t)=>{Object.defineProperty(t,"__esModule",{value:!0}),t.ColorZoneStore=void 0,t.ColorZoneStore=class{constructor(){this._zones=[],this._zonePool=[],this._zonePoolIndex=0,this._linePadding={full:0,left:0,center:0,right:0}}get zones(){return this._zonePool.length=Math.min(this._zonePool.length,this._zones.length),this._zones}clear(){this._zones.length=0,this._zonePoolIndex=0}addDecoration(e){if(e.options.overviewRulerOptions){for(const t of this._zones)if(t.color===e.options.overviewRulerOptions.color&&t.position===e.options.overviewRulerOptions.position){if(this._lineIntersectsZone(t,e.marker.line))return;if(this._lineAdjacentToZone(t,e.marker.line,e.options.overviewRulerOptions.position))return void this._addLineToZone(t,e.marker.line)}if(this._zonePoolIndex=e.startBufferLine&&t<=e.endBufferLine}_lineAdjacentToZone(e,t,i){return t>=e.startBufferLine-this._linePadding[i||"full"]&&t<=e.endBufferLine+this._linePadding[i||"full"]}_addLineToZone(e,t){e.startBufferLine=Math.min(e.startBufferLine,t),e.endBufferLine=Math.max(e.endBufferLine,t)}}},5744:function(e,t,i){var s=this&&this.__decorate||function(e,t,i,s){var r,n=arguments.length,o=n<3?t:null===s?s=Object.getOwnPropertyDescriptor(t,i):s;if("object"==typeof Reflect&&"function"==typeof Reflect.decorate)o=Reflect.decorate(e,t,i,s);else for(var a=e.length-1;a>=0;a--)(r=e[a])&&(o=(n<3?r(o):n>3?r(t,i,o):r(t,i))||o);return n>3&&o&&Object.defineProperty(t,i,o),o},r=this&&this.__param||function(e,t){return function(i,s){t(i,s,e)}};Object.defineProperty(t,"__esModule",{value:!0}),t.OverviewRulerRenderer=void 0;const n=i(5871),o=i(3656),a=i(4725),h=i(844),c=i(2585),l={full:0,left:0,center:0,right:0},d={full:0,left:0,center:0,right:0},_={full:0,left:0,center:0,right:0};let u=t.OverviewRulerRenderer=class extends h.Disposable{get _width(){return this._optionsService.options.overviewRulerWidth||0}constructor(e,t,i,s,r,o,a){var c;super(),this._viewportElement=e,this._screenElement=t,this._bufferService=i,this._decorationService=s,this._renderService=r,this._optionsService=o,this._coreBrowseService=a,this._colorZoneStore=new n.ColorZoneStore,this._shouldUpdateDimensions=!0,this._shouldUpdateAnchor=!0,this._lastKnownBufferLength=0,this._canvas=document.createElement("canvas"),this._canvas.classList.add("xterm-decoration-overview-ruler"),this._refreshCanvasDimensions(),null===(c=this._viewportElement.parentElement)||void 0===c||c.insertBefore(this._canvas,this._viewportElement);const l=this._canvas.getContext("2d");if(!l)throw new Error("Ctx cannot be null");this._ctx=l,this._registerDecorationListeners(),this._registerBufferChangeListeners(),this._registerDimensionChangeListeners(),this.register((0,h.toDisposable)((()=>{var e;null===(e=this._canvas)||void 0===e||e.remove()})))}_registerDecorationListeners(){this.register(this._decorationService.onDecorationRegistered((()=>this._queueRefresh(void 0,!0)))),this.register(this._decorationService.onDecorationRemoved((()=>this._queueRefresh(void 0,!0))))}_registerBufferChangeListeners(){this.register(this._renderService.onRenderedViewportChange((()=>this._queueRefresh()))),this.register(this._bufferService.buffers.onBufferActivate((()=>{this._canvas.style.display=this._bufferService.buffer===this._bufferService.buffers.alt?"none":"block"}))),this.register(this._bufferService.onScroll((()=>{this._lastKnownBufferLength!==this._bufferService.buffers.normal.lines.length&&(this._refreshDrawHeightConstants(),this._refreshColorZonePadding())})))}_registerDimensionChangeListeners(){this.register(this._renderService.onRender((()=>{this._containerHeight&&this._containerHeight===this._screenElement.clientHeight||(this._queueRefresh(!0),this._containerHeight=this._screenElement.clientHeight)}))),this.register(this._optionsService.onSpecificOptionChange("overviewRulerWidth",(()=>this._queueRefresh(!0)))),this.register((0,o.addDisposableDomListener)(this._coreBrowseService.window,"resize",(()=>this._queueRefresh(!0)))),this._queueRefresh(!0)}_refreshDrawConstants(){const e=Math.floor(this._canvas.width/3),t=Math.ceil(this._canvas.width/3);d.full=this._canvas.width,d.left=e,d.center=t,d.right=e,this._refreshDrawHeightConstants(),_.full=0,_.left=0,_.center=d.left,_.right=d.left+d.center}_refreshDrawHeightConstants(){l.full=Math.round(2*this._coreBrowseService.dpr);const e=this._canvas.height/this._bufferService.buffer.lines.length,t=Math.round(Math.max(Math.min(e,12),6)*this._coreBrowseService.dpr);l.left=t,l.center=t,l.right=t}_refreshColorZonePadding(){this._colorZoneStore.setPadding({full:Math.floor(this._bufferService.buffers.active.lines.length/(this._canvas.height-1)*l.full),left:Math.floor(this._bufferService.buffers.active.lines.length/(this._canvas.height-1)*l.left),center:Math.floor(this._bufferService.buffers.active.lines.length/(this._canvas.height-1)*l.center),right:Math.floor(this._bufferService.buffers.active.lines.length/(this._canvas.height-1)*l.right)}),this._lastKnownBufferLength=this._bufferService.buffers.normal.lines.length}_refreshCanvasDimensions(){this._canvas.style.width=`${this._width}px`,this._canvas.width=Math.round(this._width*this._coreBrowseService.dpr),this._canvas.style.height=`${this._screenElement.clientHeight}px`,this._canvas.height=Math.round(this._screenElement.clientHeight*this._coreBrowseService.dpr),this._refreshDrawConstants(),this._refreshColorZonePadding()}_refreshDecorations(){this._shouldUpdateDimensions&&this._refreshCanvasDimensions(),this._ctx.clearRect(0,0,this._canvas.width,this._canvas.height),this._colorZoneStore.clear();for(const e of this._decorationService.decorations)this._colorZoneStore.addDecoration(e);this._ctx.lineWidth=1;const e=this._colorZoneStore.zones;for(const t of e)"full"!==t.position&&this._renderColorZone(t);for(const t of e)"full"===t.position&&this._renderColorZone(t);this._shouldUpdateDimensions=!1,this._shouldUpdateAnchor=!1}_renderColorZone(e){this._ctx.fillStyle=e.color,this._ctx.fillRect(_[e.position||"full"],Math.round((this._canvas.height-1)*(e.startBufferLine/this._bufferService.buffers.active.lines.length)-l[e.position||"full"]/2),d[e.position||"full"],Math.round((this._canvas.height-1)*((e.endBufferLine-e.startBufferLine)/this._bufferService.buffers.active.lines.length)+l[e.position||"full"]))}_queueRefresh(e,t){this._shouldUpdateDimensions=e||this._shouldUpdateDimensions,this._shouldUpdateAnchor=t||this._shouldUpdateAnchor,void 0===this._animationFrame&&(this._animationFrame=this._coreBrowseService.window.requestAnimationFrame((()=>{this._refreshDecorations(),this._animationFrame=void 0})))}};t.OverviewRulerRenderer=u=s([r(2,c.IBufferService),r(3,c.IDecorationService),r(4,a.IRenderService),r(5,c.IOptionsService),r(6,a.ICoreBrowserService)],u)},2950:function(e,t,i){var s=this&&this.__decorate||function(e,t,i,s){var r,n=arguments.length,o=n<3?t:null===s?s=Object.getOwnPropertyDescriptor(t,i):s;if("object"==typeof Reflect&&"function"==typeof Reflect.decorate)o=Reflect.decorate(e,t,i,s);else for(var a=e.length-1;a>=0;a--)(r=e[a])&&(o=(n<3?r(o):n>3?r(t,i,o):r(t,i))||o);return n>3&&o&&Object.defineProperty(t,i,o),o},r=this&&this.__param||function(e,t){return function(i,s){t(i,s,e)}};Object.defineProperty(t,"__esModule",{value:!0}),t.CompositionHelper=void 0;const n=i(4725),o=i(2585),a=i(2584);let h=t.CompositionHelper=class{get isComposing(){return this._isComposing}constructor(e,t,i,s,r,n){this._textarea=e,this._compositionView=t,this._bufferService=i,this._optionsService=s,this._coreService=r,this._renderService=n,this._isComposing=!1,this._isSendingComposition=!1,this._compositionPosition={start:0,end:0},this._dataAlreadySent=""}compositionstart(){this._isComposing=!0,this._compositionPosition.start=this._textarea.value.length,this._compositionView.textContent="",this._dataAlreadySent="",this._compositionView.classList.add("active")}compositionupdate(e){this._compositionView.textContent=e.data,this.updateCompositionElements(),setTimeout((()=>{this._compositionPosition.end=this._textarea.value.length}),0)}compositionend(){this._finalizeComposition(!0)}keydown(e){if(this._isComposing||this._isSendingComposition){if(229===e.keyCode)return!1;if(16===e.keyCode||17===e.keyCode||18===e.keyCode)return!1;this._finalizeComposition(!1)}return 229!==e.keyCode||(this._handleAnyTextareaChanges(),!1)}_finalizeComposition(e){if(this._compositionView.classList.remove("active"),this._isComposing=!1,e){const e={start:this._compositionPosition.start,end:this._compositionPosition.end};this._isSendingComposition=!0,setTimeout((()=>{if(this._isSendingComposition){let t;this._isSendingComposition=!1,e.start+=this._dataAlreadySent.length,t=this._isComposing?this._textarea.value.substring(e.start,e.end):this._textarea.value.substring(e.start),t.length>0&&this._coreService.triggerDataEvent(t,!0)}}),0)}else{this._isSendingComposition=!1;const e=this._textarea.value.substring(this._compositionPosition.start,this._compositionPosition.end);this._coreService.triggerDataEvent(e,!0)}}_handleAnyTextareaChanges(){const e=this._textarea.value;setTimeout((()=>{if(!this._isComposing){const t=this._textarea.value,i=t.replace(e,"");this._dataAlreadySent=i,t.length>e.length?this._coreService.triggerDataEvent(i,!0):t.lengththis.updateCompositionElements(!0)),0)}}};t.CompositionHelper=h=s([r(2,o.IBufferService),r(3,o.IOptionsService),r(4,o.ICoreService),r(5,n.IRenderService)],h)},9806:(e,t)=>{function i(e,t,i){const s=i.getBoundingClientRect(),r=e.getComputedStyle(i),n=parseInt(r.getPropertyValue("padding-left")),o=parseInt(r.getPropertyValue("padding-top"));return[t.clientX-s.left-n,t.clientY-s.top-o]}Object.defineProperty(t,"__esModule",{value:!0}),t.getCoords=t.getCoordsRelativeToElement=void 0,t.getCoordsRelativeToElement=i,t.getCoords=function(e,t,s,r,n,o,a,h,c){if(!o)return;const l=i(e,t,s);return l?(l[0]=Math.ceil((l[0]+(c?a/2:0))/a),l[1]=Math.ceil(l[1]/h),l[0]=Math.min(Math.max(l[0],1),r+(c?1:0)),l[1]=Math.min(Math.max(l[1],1),n),l):void 0}},9504:(e,t,i)=>{Object.defineProperty(t,"__esModule",{value:!0}),t.moveToCellSequence=void 0;const s=i(2584);function r(e,t,i,s){const r=e-n(e,i),a=t-n(t,i),l=Math.abs(r-a)-function(e,t,i){let s=0;const r=e-n(e,i),a=t-n(t,i);for(let n=0;n=0&&et?"A":"B"}function a(e,t,i,s,r,n){let o=e,a=t,h="";for(;o!==i||a!==s;)o+=r?1:-1,r&&o>n.cols-1?(h+=n.buffer.translateBufferLineToString(a,!1,e,o),o=0,e=0,a++):!r&&o<0&&(h+=n.buffer.translateBufferLineToString(a,!1,0,e+1),o=n.cols-1,e=o,a--);return h+n.buffer.translateBufferLineToString(a,!1,e,o)}function h(e,t){const i=t?"O":"[";return s.C0.ESC+i+e}function c(e,t){e=Math.floor(e);let i="";for(let s=0;s0?s-n(s,o):t;const _=s,u=function(e,t,i,s,o,a){let h;return h=r(i,s,o,a).length>0?s-n(s,o):t,e=i&&he?"D":"C",c(Math.abs(o-e),h(d,s));d=l>t?"D":"C";const _=Math.abs(l-t);return c(function(e,t){return t.cols-e}(l>t?e:o,i)+(_-1)*i.cols+1+((l>t?o:e)-1),h(d,s))}},1296:function(e,t,i){var s=this&&this.__decorate||function(e,t,i,s){var r,n=arguments.length,o=n<3?t:null===s?s=Object.getOwnPropertyDescriptor(t,i):s;if("object"==typeof Reflect&&"function"==typeof Reflect.decorate)o=Reflect.decorate(e,t,i,s);else for(var a=e.length-1;a>=0;a--)(r=e[a])&&(o=(n<3?r(o):n>3?r(t,i,o):r(t,i))||o);return n>3&&o&&Object.defineProperty(t,i,o),o},r=this&&this.__param||function(e,t){return function(i,s){t(i,s,e)}};Object.defineProperty(t,"__esModule",{value:!0}),t.DomRenderer=void 0;const n=i(3787),o=i(2550),a=i(2223),h=i(6171),c=i(4725),l=i(8055),d=i(8460),_=i(844),u=i(2585),f="xterm-dom-renderer-owner-",v="xterm-rows",p="xterm-fg-",g="xterm-bg-",m="xterm-focus",S="xterm-selection";let C=1,b=t.DomRenderer=class extends _.Disposable{constructor(e,t,i,s,r,a,c,l,u,p){super(),this._element=e,this._screenElement=t,this._viewportElement=i,this._linkifier2=s,this._charSizeService=a,this._optionsService=c,this._bufferService=l,this._coreBrowserService=u,this._themeService=p,this._terminalClass=C++,this._rowElements=[],this.onRequestRedraw=this.register(new d.EventEmitter).event,this._rowContainer=document.createElement("div"),this._rowContainer.classList.add(v),this._rowContainer.style.lineHeight="normal",this._rowContainer.setAttribute("aria-hidden","true"),this._refreshRowElements(this._bufferService.cols,this._bufferService.rows),this._selectionContainer=document.createElement("div"),this._selectionContainer.classList.add(S),this._selectionContainer.setAttribute("aria-hidden","true"),this.dimensions=(0,h.createRenderDimensions)(),this._updateDimensions(),this.register(this._optionsService.onOptionChange((()=>this._handleOptionsChanged()))),this.register(this._themeService.onChangeColors((e=>this._injectCss(e)))),this._injectCss(this._themeService.colors),this._rowFactory=r.createInstance(n.DomRendererRowFactory,document),this._element.classList.add(f+this._terminalClass),this._screenElement.appendChild(this._rowContainer),this._screenElement.appendChild(this._selectionContainer),this.register(this._linkifier2.onShowLinkUnderline((e=>this._handleLinkHover(e)))),this.register(this._linkifier2.onHideLinkUnderline((e=>this._handleLinkLeave(e)))),this.register((0,_.toDisposable)((()=>{this._element.classList.remove(f+this._terminalClass),this._rowContainer.remove(),this._selectionContainer.remove(),this._widthCache.dispose(),this._themeStyleElement.remove(),this._dimensionsStyleElement.remove()}))),this._widthCache=new o.WidthCache(document),this._widthCache.setFont(this._optionsService.rawOptions.fontFamily,this._optionsService.rawOptions.fontSize,this._optionsService.rawOptions.fontWeight,this._optionsService.rawOptions.fontWeightBold),this._setDefaultSpacing()}_updateDimensions(){const e=this._coreBrowserService.dpr;this.dimensions.device.char.width=this._charSizeService.width*e,this.dimensions.device.char.height=Math.ceil(this._charSizeService.height*e),this.dimensions.device.cell.width=this.dimensions.device.char.width+Math.round(this._optionsService.rawOptions.letterSpacing),this.dimensions.device.cell.height=Math.floor(this.dimensions.device.char.height*this._optionsService.rawOptions.lineHeight),this.dimensions.device.char.left=0,this.dimensions.device.char.top=0,this.dimensions.device.canvas.width=this.dimensions.device.cell.width*this._bufferService.cols,this.dimensions.device.canvas.height=this.dimensions.device.cell.height*this._bufferService.rows,this.dimensions.css.canvas.width=Math.round(this.dimensions.device.canvas.width/e),this.dimensions.css.canvas.height=Math.round(this.dimensions.device.canvas.height/e),this.dimensions.css.cell.width=this.dimensions.css.canvas.width/this._bufferService.cols,this.dimensions.css.cell.height=this.dimensions.css.canvas.height/this._bufferService.rows;for(const e of this._rowElements)e.style.width=`${this.dimensions.css.canvas.width}px`,e.style.height=`${this.dimensions.css.cell.height}px`,e.style.lineHeight=`${this.dimensions.css.cell.height}px`,e.style.overflow="hidden";this._dimensionsStyleElement||(this._dimensionsStyleElement=document.createElement("style"),this._screenElement.appendChild(this._dimensionsStyleElement));const t=`${this._terminalSelector} .${v} span { display: inline-block; height: 100%; vertical-align: top;}`;this._dimensionsStyleElement.textContent=t,this._selectionContainer.style.height=this._viewportElement.style.height,this._screenElement.style.width=`${this.dimensions.css.canvas.width}px`,this._screenElement.style.height=`${this.dimensions.css.canvas.height}px`}_injectCss(e){this._themeStyleElement||(this._themeStyleElement=document.createElement("style"),this._screenElement.appendChild(this._themeStyleElement));let t=`${this._terminalSelector} .${v} { color: ${e.foreground.css}; font-family: ${this._optionsService.rawOptions.fontFamily}; font-size: ${this._optionsService.rawOptions.fontSize}px; font-kerning: none; white-space: pre}`;t+=`${this._terminalSelector} .${v} .xterm-dim { color: ${l.color.multiplyOpacity(e.foreground,.5).css};}`,t+=`${this._terminalSelector} span:not(.xterm-bold) { font-weight: ${this._optionsService.rawOptions.fontWeight};}${this._terminalSelector} span.xterm-bold { font-weight: ${this._optionsService.rawOptions.fontWeightBold};}${this._terminalSelector} span.xterm-italic { font-style: italic;}`,t+="@keyframes blink_box_shadow_"+this._terminalClass+" { 50% { border-bottom-style: hidden; }}",t+="@keyframes blink_block_"+this._terminalClass+" { 0% {"+` background-color: ${e.cursor.css};`+` color: ${e.cursorAccent.css}; } 50% { background-color: inherit;`+` color: ${e.cursor.css}; }}`,t+=`${this._terminalSelector} .${v}.${m} .xterm-cursor.xterm-cursor-blink:not(.xterm-cursor-block) { animation: blink_box_shadow_`+this._terminalClass+" 1s step-end infinite;}"+`${this._terminalSelector} .${v}.${m} .xterm-cursor.xterm-cursor-blink.xterm-cursor-block { animation: blink_block_`+this._terminalClass+" 1s step-end infinite;}"+`${this._terminalSelector} .${v} .xterm-cursor.xterm-cursor-block {`+` background-color: ${e.cursor.css};`+` color: ${e.cursorAccent.css};}`+`${this._terminalSelector} .${v} .xterm-cursor.xterm-cursor-outline {`+` outline: 1px solid ${e.cursor.css}; outline-offset: -1px;}`+`${this._terminalSelector} .${v} .xterm-cursor.xterm-cursor-bar {`+` box-shadow: ${this._optionsService.rawOptions.cursorWidth}px 0 0 ${e.cursor.css} inset;}`+`${this._terminalSelector} .${v} .xterm-cursor.xterm-cursor-underline {`+` border-bottom: 1px ${e.cursor.css}; border-bottom-style: solid; height: calc(100% - 1px);}`,t+=`${this._terminalSelector} .${S} { position: absolute; top: 0; left: 0; z-index: 1; pointer-events: none;}${this._terminalSelector}.focus .${S} div { position: absolute; background-color: ${e.selectionBackgroundOpaque.css};}${this._terminalSelector} .${S} div { position: absolute; background-color: ${e.selectionInactiveBackgroundOpaque.css};}`;for(const[i,s]of e.ansi.entries())t+=`${this._terminalSelector} .${p}${i} { color: ${s.css}; }${this._terminalSelector} .${p}${i}.xterm-dim { color: ${l.color.multiplyOpacity(s,.5).css}; }${this._terminalSelector} .${g}${i} { background-color: ${s.css}; }`;t+=`${this._terminalSelector} .${p}${a.INVERTED_DEFAULT_COLOR} { color: ${l.color.opaque(e.background).css}; }${this._terminalSelector} .${p}${a.INVERTED_DEFAULT_COLOR}.xterm-dim { color: ${l.color.multiplyOpacity(l.color.opaque(e.background),.5).css}; }${this._terminalSelector} .${g}${a.INVERTED_DEFAULT_COLOR} { background-color: ${e.foreground.css}; }`,this._themeStyleElement.textContent=t}_setDefaultSpacing(){const e=this.dimensions.css.cell.width-this._widthCache.get("W",!1,!1);this._rowContainer.style.letterSpacing=`${e}px`,this._rowFactory.defaultSpacing=e}handleDevicePixelRatioChange(){this._updateDimensions(),this._widthCache.clear(),this._setDefaultSpacing()}_refreshRowElements(e,t){for(let e=this._rowElements.length;e<=t;e++){const e=document.createElement("div");this._rowContainer.appendChild(e),this._rowElements.push(e)}for(;this._rowElements.length>t;)this._rowContainer.removeChild(this._rowElements.pop())}handleResize(e,t){this._refreshRowElements(e,t),this._updateDimensions()}handleCharSizeChanged(){this._updateDimensions(),this._widthCache.clear(),this._setDefaultSpacing()}handleBlur(){this._rowContainer.classList.remove(m)}handleFocus(){this._rowContainer.classList.add(m),this.renderRows(this._bufferService.buffer.y,this._bufferService.buffer.y)}handleSelectionChanged(e,t,i){if(this._selectionContainer.replaceChildren(),this._rowFactory.handleSelectionChanged(e,t,i),this.renderRows(0,this._bufferService.rows-1),!e||!t)return;const s=e[1]-this._bufferService.buffer.ydisp,r=t[1]-this._bufferService.buffer.ydisp,n=Math.max(s,0),o=Math.min(r,this._bufferService.rows-1);if(n>=this._bufferService.rows||o<0)return;const a=document.createDocumentFragment();if(i){const i=e[0]>t[0];a.appendChild(this._createSelectionElement(n,i?t[0]:e[0],i?e[0]:t[0],o-n+1))}else{const i=s===n?e[0]:0,h=n===r?t[0]:this._bufferService.cols;a.appendChild(this._createSelectionElement(n,i,h));const c=o-n-1;if(a.appendChild(this._createSelectionElement(n+1,0,this._bufferService.cols,c)),n!==o){const e=r===o?t[0]:this._bufferService.cols;a.appendChild(this._createSelectionElement(o,0,e))}}this._selectionContainer.appendChild(a)}_createSelectionElement(e,t,i,s=1){const r=document.createElement("div");return r.style.height=s*this.dimensions.css.cell.height+"px",r.style.top=e*this.dimensions.css.cell.height+"px",r.style.left=t*this.dimensions.css.cell.width+"px",r.style.width=this.dimensions.css.cell.width*(i-t)+"px",r}handleCursorMove(){}_handleOptionsChanged(){this._updateDimensions(),this._injectCss(this._themeService.colors),this._widthCache.setFont(this._optionsService.rawOptions.fontFamily,this._optionsService.rawOptions.fontSize,this._optionsService.rawOptions.fontWeight,this._optionsService.rawOptions.fontWeightBold),this._setDefaultSpacing()}clear(){for(const e of this._rowElements)e.replaceChildren()}renderRows(e,t){const i=this._bufferService.buffer,s=i.ybase+i.y,r=Math.min(i.x,this._bufferService.cols-1),n=this._optionsService.rawOptions.cursorBlink,o=this._optionsService.rawOptions.cursorStyle,a=this._optionsService.rawOptions.cursorInactiveStyle;for(let h=e;h<=t;h++){const e=h+i.ydisp,t=this._rowElements[h],c=i.lines.get(e);if(!t||!c)break;t.replaceChildren(...this._rowFactory.createRow(c,e,e===s,o,a,r,n,this.dimensions.css.cell.width,this._widthCache,-1,-1))}}get _terminalSelector(){return`.${f}${this._terminalClass}`}_handleLinkHover(e){this._setCellUnderline(e.x1,e.x2,e.y1,e.y2,e.cols,!0)}_handleLinkLeave(e){this._setCellUnderline(e.x1,e.x2,e.y1,e.y2,e.cols,!1)}_setCellUnderline(e,t,i,s,r,n){i<0&&(e=0),s<0&&(t=0);const o=this._bufferService.rows-1;i=Math.max(Math.min(i,o),0),s=Math.max(Math.min(s,o),0),r=Math.min(r,this._bufferService.cols);const a=this._bufferService.buffer,h=a.ybase+a.y,c=Math.min(a.x,r-1),l=this._optionsService.rawOptions.cursorBlink,d=this._optionsService.rawOptions.cursorStyle,_=this._optionsService.rawOptions.cursorInactiveStyle;for(let o=i;o<=s;++o){const u=o+a.ydisp,f=this._rowElements[o],v=a.lines.get(u);if(!f||!v)break;f.replaceChildren(...this._rowFactory.createRow(v,u,u===h,d,_,c,l,this.dimensions.css.cell.width,this._widthCache,n?o===i?e:0:-1,n?(o===s?t:r)-1:-1))}}};t.DomRenderer=b=s([r(4,u.IInstantiationService),r(5,c.ICharSizeService),r(6,u.IOptionsService),r(7,u.IBufferService),r(8,c.ICoreBrowserService),r(9,c.IThemeService)],b)},3787:function(e,t,i){var s=this&&this.__decorate||function(e,t,i,s){var r,n=arguments.length,o=n<3?t:null===s?s=Object.getOwnPropertyDescriptor(t,i):s;if("object"==typeof Reflect&&"function"==typeof Reflect.decorate)o=Reflect.decorate(e,t,i,s);else for(var a=e.length-1;a>=0;a--)(r=e[a])&&(o=(n<3?r(o):n>3?r(t,i,o):r(t,i))||o);return n>3&&o&&Object.defineProperty(t,i,o),o},r=this&&this.__param||function(e,t){return function(i,s){t(i,s,e)}};Object.defineProperty(t,"__esModule",{value:!0}),t.DomRendererRowFactory=void 0;const n=i(2223),o=i(643),a=i(511),h=i(2585),c=i(8055),l=i(4725),d=i(4269),_=i(6171),u=i(3734);let f=t.DomRendererRowFactory=class{constructor(e,t,i,s,r,n,o){this._document=e,this._characterJoinerService=t,this._optionsService=i,this._coreBrowserService=s,this._coreService=r,this._decorationService=n,this._themeService=o,this._workCell=new a.CellData,this._columnSelectMode=!1,this.defaultSpacing=0}handleSelectionChanged(e,t,i){this._selectionStart=e,this._selectionEnd=t,this._columnSelectMode=i}createRow(e,t,i,s,r,a,h,l,_,f,p){const g=[],m=this._characterJoinerService.getJoinedCharacters(t),S=this._themeService.colors;let C,b=e.getNoBgTrimmedLength();i&&b0&&M===m[0][0]){O=!0;const t=m.shift();I=new d.JoinedCellData(this._workCell,e.translateToString(!0,t[0],t[1]),t[1]-t[0]),P=t[1]-1,b=I.getWidth()}const H=this._isCellInSelection(M,t),F=i&&M===a,W=T&&M>=f&&M<=p;let U=!1;this._decorationService.forEachDecorationAtCell(M,t,void 0,(e=>{U=!0}));let N=I.getChars()||o.WHITESPACE_CELL_CHAR;if(" "===N&&(I.isUnderline()||I.isOverline())&&(N=" "),A=b*l-_.get(N,I.isBold(),I.isItalic()),C){if(y&&(H&&x||!H&&!x&&I.bg===E)&&(H&&x&&S.selectionForeground||I.fg===k)&&I.extended.ext===L&&W===D&&A===R&&!F&&!O&&!U){w+=N,y++;continue}y&&(C.textContent=w),C=this._document.createElement("span"),y=0,w=""}else C=this._document.createElement("span");if(E=I.bg,k=I.fg,L=I.extended.ext,D=W,R=A,x=H,O&&a>=M&&a<=P&&(a=M),!this._coreService.isCursorHidden&&F)if(B.push("xterm-cursor"),this._coreBrowserService.isFocused)h&&B.push("xterm-cursor-blink"),B.push("bar"===s?"xterm-cursor-bar":"underline"===s?"xterm-cursor-underline":"xterm-cursor-block");else if(r)switch(r){case"outline":B.push("xterm-cursor-outline");break;case"block":B.push("xterm-cursor-block");break;case"bar":B.push("xterm-cursor-bar");break;case"underline":B.push("xterm-cursor-underline")}if(I.isBold()&&B.push("xterm-bold"),I.isItalic()&&B.push("xterm-italic"),I.isDim()&&B.push("xterm-dim"),w=I.isInvisible()?o.WHITESPACE_CELL_CHAR:I.getChars()||o.WHITESPACE_CELL_CHAR,I.isUnderline()&&(B.push(`xterm-underline-${I.extended.underlineStyle}`)," "===w&&(w=" "),!I.isUnderlineColorDefault()))if(I.isUnderlineColorRGB())C.style.textDecorationColor=`rgb(${u.AttributeData.toColorRGB(I.getUnderlineColor()).join(",")})`;else{let e=I.getUnderlineColor();this._optionsService.rawOptions.drawBoldTextInBrightColors&&I.isBold()&&e<8&&(e+=8),C.style.textDecorationColor=S.ansi[e].css}I.isOverline()&&(B.push("xterm-overline")," "===w&&(w=" ")),I.isStrikethrough()&&B.push("xterm-strikethrough"),W&&(C.style.textDecoration="underline");let $=I.getFgColor(),j=I.getFgColorMode(),z=I.getBgColor(),K=I.getBgColorMode();const q=!!I.isInverse();if(q){const e=$;$=z,z=e;const t=j;j=K,K=t}let V,G,X,J=!1;switch(this._decorationService.forEachDecorationAtCell(M,t,void 0,(e=>{"top"!==e.options.layer&&J||(e.backgroundColorRGB&&(K=50331648,z=e.backgroundColorRGB.rgba>>8&16777215,V=e.backgroundColorRGB),e.foregroundColorRGB&&(j=50331648,$=e.foregroundColorRGB.rgba>>8&16777215,G=e.foregroundColorRGB),J="top"===e.options.layer)})),!J&&H&&(V=this._coreBrowserService.isFocused?S.selectionBackgroundOpaque:S.selectionInactiveBackgroundOpaque,z=V.rgba>>8&16777215,K=50331648,J=!0,S.selectionForeground&&(j=50331648,$=S.selectionForeground.rgba>>8&16777215,G=S.selectionForeground)),J&&B.push("xterm-decoration-top"),K){case 16777216:case 33554432:X=S.ansi[z],B.push(`xterm-bg-${z}`);break;case 50331648:X=c.rgba.toColor(z>>16,z>>8&255,255&z),this._addStyle(C,`background-color:#${v((z>>>0).toString(16),"0",6)}`);break;default:q?(X=S.foreground,B.push(`xterm-bg-${n.INVERTED_DEFAULT_COLOR}`)):X=S.background}switch(V||I.isDim()&&(V=c.color.multiplyOpacity(X,.5)),j){case 16777216:case 33554432:I.isBold()&&$<8&&this._optionsService.rawOptions.drawBoldTextInBrightColors&&($+=8),this._applyMinimumContrast(C,X,S.ansi[$],I,V,void 0)||B.push(`xterm-fg-${$}`);break;case 50331648:const e=c.rgba.toColor($>>16&255,$>>8&255,255&$);this._applyMinimumContrast(C,X,e,I,V,G)||this._addStyle(C,`color:#${v($.toString(16),"0",6)}`);break;default:this._applyMinimumContrast(C,X,S.foreground,I,V,void 0)||q&&B.push(`xterm-fg-${n.INVERTED_DEFAULT_COLOR}`)}B.length&&(C.className=B.join(" "),B.length=0),F||O||U?C.textContent=w:y++,A!==this.defaultSpacing&&(C.style.letterSpacing=`${A}px`),g.push(C),M=P}return C&&y&&(C.textContent=w),g}_applyMinimumContrast(e,t,i,s,r,n){if(1===this._optionsService.rawOptions.minimumContrastRatio||(0,_.excludeFromContrastRatioDemands)(s.getCode()))return!1;const o=this._getContrastCache(s);let a;if(r||n||(a=o.getColor(t.rgba,i.rgba)),void 0===a){const e=this._optionsService.rawOptions.minimumContrastRatio/(s.isDim()?2:1);a=c.color.ensureContrastRatio(r||t,n||i,e),o.setColor((r||t).rgba,(n||i).rgba,null!=a?a:null)}return!!a&&(this._addStyle(e,`color:${a.css}`),!0)}_getContrastCache(e){return e.isDim()?this._themeService.colors.halfContrastCache:this._themeService.colors.contrastCache}_addStyle(e,t){e.setAttribute("style",`${e.getAttribute("style")||""}${t};`)}_isCellInSelection(e,t){const i=this._selectionStart,s=this._selectionEnd;return!(!i||!s)&&(this._columnSelectMode?i[0]<=s[0]?e>=i[0]&&t>=i[1]&&e=i[1]&&e>=s[0]&&t<=s[1]:t>i[1]&&t=i[0]&&e=i[0])}};function v(e,t,i){for(;e.length{Object.defineProperty(t,"__esModule",{value:!0}),t.WidthCache=void 0,t.WidthCache=class{constructor(e){this._flat=new Float32Array(256),this._font="",this._fontSize=0,this._weight="normal",this._weightBold="bold",this._measureElements=[],this._container=e.createElement("div"),this._container.style.position="absolute",this._container.style.top="-50000px",this._container.style.width="50000px",this._container.style.whiteSpace="pre",this._container.style.fontKerning="none";const t=e.createElement("span"),i=e.createElement("span");i.style.fontWeight="bold";const s=e.createElement("span");s.style.fontStyle="italic";const r=e.createElement("span");r.style.fontWeight="bold",r.style.fontStyle="italic",this._measureElements=[t,i,s,r],this._container.appendChild(t),this._container.appendChild(i),this._container.appendChild(s),this._container.appendChild(r),e.body.appendChild(this._container),this.clear()}dispose(){this._container.remove(),this._measureElements.length=0,this._holey=void 0}clear(){this._flat.fill(-9999),this._holey=new Map}setFont(e,t,i,s){e===this._font&&t===this._fontSize&&i===this._weight&&s===this._weightBold||(this._font=e,this._fontSize=t,this._weight=i,this._weightBold=s,this._container.style.fontFamily=this._font,this._container.style.fontSize=`${this._fontSize}px`,this._measureElements[0].style.fontWeight=`${i}`,this._measureElements[1].style.fontWeight=`${s}`,this._measureElements[2].style.fontWeight=`${i}`,this._measureElements[3].style.fontWeight=`${s}`,this.clear())}get(e,t,i){let s=0;if(!t&&!i&&1===e.length&&(s=e.charCodeAt(0))<256)return-9999!==this._flat[s]?this._flat[s]:this._flat[s]=this._measure(e,0);let r=e;t&&(r+="B"),i&&(r+="I");let n=this._holey.get(r);if(void 0===n){let s=0;t&&(s|=1),i&&(s|=2),n=this._measure(e,s),this._holey.set(r,n)}return n}_measure(e,t){const i=this._measureElements[t];return i.textContent=e.repeat(32),i.offsetWidth/32}}},2223:(e,t,i)=>{Object.defineProperty(t,"__esModule",{value:!0}),t.TEXT_BASELINE=t.DIM_OPACITY=t.INVERTED_DEFAULT_COLOR=void 0;const s=i(6114);t.INVERTED_DEFAULT_COLOR=257,t.DIM_OPACITY=.5,t.TEXT_BASELINE=s.isFirefox||s.isLegacyEdge?"bottom":"ideographic"},6171:(e,t)=>{function i(e){return 57508<=e&&e<=57558}Object.defineProperty(t,"__esModule",{value:!0}),t.createRenderDimensions=t.excludeFromContrastRatioDemands=t.isRestrictedPowerlineGlyph=t.isPowerlineGlyph=t.throwIfFalsy=void 0,t.throwIfFalsy=function(e){if(!e)throw new Error("value must not be falsy");return e},t.isPowerlineGlyph=i,t.isRestrictedPowerlineGlyph=function(e){return 57520<=e&&e<=57527},t.excludeFromContrastRatioDemands=function(e){return i(e)||function(e){return 9472<=e&&e<=9631}(e)},t.createRenderDimensions=function(){return{css:{canvas:{width:0,height:0},cell:{width:0,height:0}},device:{canvas:{width:0,height:0},cell:{width:0,height:0},char:{width:0,height:0,left:0,top:0}}}}},456:(e,t)=>{Object.defineProperty(t,"__esModule",{value:!0}),t.SelectionModel=void 0,t.SelectionModel=class{constructor(e){this._bufferService=e,this.isSelectAllActive=!1,this.selectionStartLength=0}clearSelection(){this.selectionStart=void 0,this.selectionEnd=void 0,this.isSelectAllActive=!1,this.selectionStartLength=0}get finalSelectionStart(){return this.isSelectAllActive?[0,0]:this.selectionEnd&&this.selectionStart&&this.areSelectionValuesReversed()?this.selectionEnd:this.selectionStart}get finalSelectionEnd(){if(this.isSelectAllActive)return[this._bufferService.cols,this._bufferService.buffer.ybase+this._bufferService.rows-1];if(this.selectionStart){if(!this.selectionEnd||this.areSelectionValuesReversed()){const e=this.selectionStart[0]+this.selectionStartLength;return e>this._bufferService.cols?e%this._bufferService.cols==0?[this._bufferService.cols,this.selectionStart[1]+Math.floor(e/this._bufferService.cols)-1]:[e%this._bufferService.cols,this.selectionStart[1]+Math.floor(e/this._bufferService.cols)]:[e,this.selectionStart[1]]}if(this.selectionStartLength&&this.selectionEnd[1]===this.selectionStart[1]){const e=this.selectionStart[0]+this.selectionStartLength;return e>this._bufferService.cols?[e%this._bufferService.cols,this.selectionStart[1]+Math.floor(e/this._bufferService.cols)]:[Math.max(e,this.selectionEnd[0]),this.selectionEnd[1]]}return this.selectionEnd}}areSelectionValuesReversed(){const e=this.selectionStart,t=this.selectionEnd;return!(!e||!t)&&(e[1]>t[1]||e[1]===t[1]&&e[0]>t[0])}handleTrim(e){return this.selectionStart&&(this.selectionStart[1]-=e),this.selectionEnd&&(this.selectionEnd[1]-=e),this.selectionEnd&&this.selectionEnd[1]<0?(this.clearSelection(),!0):(this.selectionStart&&this.selectionStart[1]<0&&(this.selectionStart[1]=0),!1)}}},428:function(e,t,i){var s=this&&this.__decorate||function(e,t,i,s){var r,n=arguments.length,o=n<3?t:null===s?s=Object.getOwnPropertyDescriptor(t,i):s;if("object"==typeof Reflect&&"function"==typeof Reflect.decorate)o=Reflect.decorate(e,t,i,s);else for(var a=e.length-1;a>=0;a--)(r=e[a])&&(o=(n<3?r(o):n>3?r(t,i,o):r(t,i))||o);return n>3&&o&&Object.defineProperty(t,i,o),o},r=this&&this.__param||function(e,t){return function(i,s){t(i,s,e)}};Object.defineProperty(t,"__esModule",{value:!0}),t.CharSizeService=void 0;const n=i(2585),o=i(8460),a=i(844);let h=t.CharSizeService=class extends a.Disposable{get hasValidSize(){return this.width>0&&this.height>0}constructor(e,t,i){super(),this._optionsService=i,this.width=0,this.height=0,this._onCharSizeChange=this.register(new o.EventEmitter),this.onCharSizeChange=this._onCharSizeChange.event,this._measureStrategy=new c(e,t,this._optionsService),this.register(this._optionsService.onMultipleOptionChange(["fontFamily","fontSize"],(()=>this.measure())))}measure(){const e=this._measureStrategy.measure();e.width===this.width&&e.height===this.height||(this.width=e.width,this.height=e.height,this._onCharSizeChange.fire())}};t.CharSizeService=h=s([r(2,n.IOptionsService)],h);class c{constructor(e,t,i){this._document=e,this._parentElement=t,this._optionsService=i,this._result={width:0,height:0},this._measureElement=this._document.createElement("span"),this._measureElement.classList.add("xterm-char-measure-element"),this._measureElement.textContent="W".repeat(32),this._measureElement.setAttribute("aria-hidden","true"),this._measureElement.style.whiteSpace="pre",this._measureElement.style.fontKerning="none",this._parentElement.appendChild(this._measureElement)}measure(){this._measureElement.style.fontFamily=this._optionsService.rawOptions.fontFamily,this._measureElement.style.fontSize=`${this._optionsService.rawOptions.fontSize}px`;const e={height:Number(this._measureElement.offsetHeight),width:Number(this._measureElement.offsetWidth)};return 0!==e.width&&0!==e.height&&(this._result.width=e.width/32,this._result.height=Math.ceil(e.height)),this._result}}},4269:function(e,t,i){var s=this&&this.__decorate||function(e,t,i,s){var r,n=arguments.length,o=n<3?t:null===s?s=Object.getOwnPropertyDescriptor(t,i):s;if("object"==typeof Reflect&&"function"==typeof Reflect.decorate)o=Reflect.decorate(e,t,i,s);else for(var a=e.length-1;a>=0;a--)(r=e[a])&&(o=(n<3?r(o):n>3?r(t,i,o):r(t,i))||o);return n>3&&o&&Object.defineProperty(t,i,o),o},r=this&&this.__param||function(e,t){return function(i,s){t(i,s,e)}};Object.defineProperty(t,"__esModule",{value:!0}),t.CharacterJoinerService=t.JoinedCellData=void 0;const n=i(3734),o=i(643),a=i(511),h=i(2585);class c extends n.AttributeData{constructor(e,t,i){super(),this.content=0,this.combinedData="",this.fg=e.fg,this.bg=e.bg,this.combinedData=t,this._width=i}isCombined(){return 2097152}getWidth(){return this._width}getChars(){return this.combinedData}getCode(){return 2097151}setFromCharData(e){throw new Error("not implemented")}getAsCharData(){return[this.fg,this.getChars(),this.getWidth(),this.getCode()]}}t.JoinedCellData=c;let l=t.CharacterJoinerService=class e{constructor(e){this._bufferService=e,this._characterJoiners=[],this._nextCharacterJoinerId=0,this._workCell=new a.CellData}register(e){const t={id:this._nextCharacterJoinerId++,handler:e};return this._characterJoiners.push(t),t.id}deregister(e){for(let t=0;t1){const e=this._getJoinedRanges(s,a,n,t,r);for(let t=0;t1){const e=this._getJoinedRanges(s,a,n,t,r);for(let t=0;t{Object.defineProperty(t,"__esModule",{value:!0}),t.CoreBrowserService=void 0,t.CoreBrowserService=class{constructor(e,t){this._textarea=e,this.window=t,this._isFocused=!1,this._cachedIsFocused=void 0,this._textarea.addEventListener("focus",(()=>this._isFocused=!0)),this._textarea.addEventListener("blur",(()=>this._isFocused=!1))}get dpr(){return this.window.devicePixelRatio}get isFocused(){return void 0===this._cachedIsFocused&&(this._cachedIsFocused=this._isFocused&&this._textarea.ownerDocument.hasFocus(),queueMicrotask((()=>this._cachedIsFocused=void 0))),this._cachedIsFocused}}},8934:function(e,t,i){var s=this&&this.__decorate||function(e,t,i,s){var r,n=arguments.length,o=n<3?t:null===s?s=Object.getOwnPropertyDescriptor(t,i):s;if("object"==typeof Reflect&&"function"==typeof Reflect.decorate)o=Reflect.decorate(e,t,i,s);else for(var a=e.length-1;a>=0;a--)(r=e[a])&&(o=(n<3?r(o):n>3?r(t,i,o):r(t,i))||o);return n>3&&o&&Object.defineProperty(t,i,o),o},r=this&&this.__param||function(e,t){return function(i,s){t(i,s,e)}};Object.defineProperty(t,"__esModule",{value:!0}),t.MouseService=void 0;const n=i(4725),o=i(9806);let a=t.MouseService=class{constructor(e,t){this._renderService=e,this._charSizeService=t}getCoords(e,t,i,s,r){return(0,o.getCoords)(window,e,t,i,s,this._charSizeService.hasValidSize,this._renderService.dimensions.css.cell.width,this._renderService.dimensions.css.cell.height,r)}getMouseReportCoords(e,t){const i=(0,o.getCoordsRelativeToElement)(window,e,t);if(this._charSizeService.hasValidSize)return i[0]=Math.min(Math.max(i[0],0),this._renderService.dimensions.css.canvas.width-1),i[1]=Math.min(Math.max(i[1],0),this._renderService.dimensions.css.canvas.height-1),{col:Math.floor(i[0]/this._renderService.dimensions.css.cell.width),row:Math.floor(i[1]/this._renderService.dimensions.css.cell.height),x:Math.floor(i[0]),y:Math.floor(i[1])}}};t.MouseService=a=s([r(0,n.IRenderService),r(1,n.ICharSizeService)],a)},3230:function(e,t,i){var s=this&&this.__decorate||function(e,t,i,s){var r,n=arguments.length,o=n<3?t:null===s?s=Object.getOwnPropertyDescriptor(t,i):s;if("object"==typeof Reflect&&"function"==typeof Reflect.decorate)o=Reflect.decorate(e,t,i,s);else for(var a=e.length-1;a>=0;a--)(r=e[a])&&(o=(n<3?r(o):n>3?r(t,i,o):r(t,i))||o);return n>3&&o&&Object.defineProperty(t,i,o),o},r=this&&this.__param||function(e,t){return function(i,s){t(i,s,e)}};Object.defineProperty(t,"__esModule",{value:!0}),t.RenderService=void 0;const n=i(3656),o=i(6193),a=i(5596),h=i(4725),c=i(8460),l=i(844),d=i(7226),_=i(2585);let u=t.RenderService=class extends l.Disposable{get dimensions(){return this._renderer.value.dimensions}constructor(e,t,i,s,r,h,_,u){if(super(),this._rowCount=e,this._charSizeService=s,this._renderer=this.register(new l.MutableDisposable),this._pausedResizeTask=new d.DebouncedIdleTask,this._isPaused=!1,this._needsFullRefresh=!1,this._isNextRenderRedrawOnly=!0,this._needsSelectionRefresh=!1,this._canvasWidth=0,this._canvasHeight=0,this._selectionState={start:void 0,end:void 0,columnSelectMode:!1},this._onDimensionsChange=this.register(new c.EventEmitter),this.onDimensionsChange=this._onDimensionsChange.event,this._onRenderedViewportChange=this.register(new c.EventEmitter),this.onRenderedViewportChange=this._onRenderedViewportChange.event,this._onRender=this.register(new c.EventEmitter),this.onRender=this._onRender.event,this._onRefreshRequest=this.register(new c.EventEmitter),this.onRefreshRequest=this._onRefreshRequest.event,this._renderDebouncer=new o.RenderDebouncer(_.window,((e,t)=>this._renderRows(e,t))),this.register(this._renderDebouncer),this._screenDprMonitor=new a.ScreenDprMonitor(_.window),this._screenDprMonitor.setListener((()=>this.handleDevicePixelRatioChange())),this.register(this._screenDprMonitor),this.register(h.onResize((()=>this._fullRefresh()))),this.register(h.buffers.onBufferActivate((()=>{var e;return null===(e=this._renderer.value)||void 0===e?void 0:e.clear()}))),this.register(i.onOptionChange((()=>this._handleOptionsChanged()))),this.register(this._charSizeService.onCharSizeChange((()=>this.handleCharSizeChanged()))),this.register(r.onDecorationRegistered((()=>this._fullRefresh()))),this.register(r.onDecorationRemoved((()=>this._fullRefresh()))),this.register(i.onMultipleOptionChange(["customGlyphs","drawBoldTextInBrightColors","letterSpacing","lineHeight","fontFamily","fontSize","fontWeight","fontWeightBold","minimumContrastRatio"],(()=>{this.clear(),this.handleResize(h.cols,h.rows),this._fullRefresh()}))),this.register(i.onMultipleOptionChange(["cursorBlink","cursorStyle"],(()=>this.refreshRows(h.buffer.y,h.buffer.y,!0)))),this.register((0,n.addDisposableDomListener)(_.window,"resize",(()=>this.handleDevicePixelRatioChange()))),this.register(u.onChangeColors((()=>this._fullRefresh()))),"IntersectionObserver"in _.window){const e=new _.window.IntersectionObserver((e=>this._handleIntersectionChange(e[e.length-1])),{threshold:0});e.observe(t),this.register({dispose:()=>e.disconnect()})}}_handleIntersectionChange(e){this._isPaused=void 0===e.isIntersecting?0===e.intersectionRatio:!e.isIntersecting,this._isPaused||this._charSizeService.hasValidSize||this._charSizeService.measure(),!this._isPaused&&this._needsFullRefresh&&(this._pausedResizeTask.flush(),this.refreshRows(0,this._rowCount-1),this._needsFullRefresh=!1)}refreshRows(e,t,i=!1){this._isPaused?this._needsFullRefresh=!0:(i||(this._isNextRenderRedrawOnly=!1),this._renderDebouncer.refresh(e,t,this._rowCount))}_renderRows(e,t){this._renderer.value&&(e=Math.min(e,this._rowCount-1),t=Math.min(t,this._rowCount-1),this._renderer.value.renderRows(e,t),this._needsSelectionRefresh&&(this._renderer.value.handleSelectionChanged(this._selectionState.start,this._selectionState.end,this._selectionState.columnSelectMode),this._needsSelectionRefresh=!1),this._isNextRenderRedrawOnly||this._onRenderedViewportChange.fire({start:e,end:t}),this._onRender.fire({start:e,end:t}),this._isNextRenderRedrawOnly=!0)}resize(e,t){this._rowCount=t,this._fireOnCanvasResize()}_handleOptionsChanged(){this._renderer.value&&(this.refreshRows(0,this._rowCount-1),this._fireOnCanvasResize())}_fireOnCanvasResize(){this._renderer.value&&(this._renderer.value.dimensions.css.canvas.width===this._canvasWidth&&this._renderer.value.dimensions.css.canvas.height===this._canvasHeight||this._onDimensionsChange.fire(this._renderer.value.dimensions))}hasRenderer(){return!!this._renderer.value}setRenderer(e){this._renderer.value=e,this._renderer.value.onRequestRedraw((e=>this.refreshRows(e.start,e.end,!0))),this._needsSelectionRefresh=!0,this._fullRefresh()}addRefreshCallback(e){return this._renderDebouncer.addRefreshCallback(e)}_fullRefresh(){this._isPaused?this._needsFullRefresh=!0:this.refreshRows(0,this._rowCount-1)}clearTextureAtlas(){var e,t;this._renderer.value&&(null===(t=(e=this._renderer.value).clearTextureAtlas)||void 0===t||t.call(e),this._fullRefresh())}handleDevicePixelRatioChange(){this._charSizeService.measure(),this._renderer.value&&(this._renderer.value.handleDevicePixelRatioChange(),this.refreshRows(0,this._rowCount-1))}handleResize(e,t){this._renderer.value&&(this._isPaused?this._pausedResizeTask.set((()=>this._renderer.value.handleResize(e,t))):this._renderer.value.handleResize(e,t),this._fullRefresh())}handleCharSizeChanged(){var e;null===(e=this._renderer.value)||void 0===e||e.handleCharSizeChanged()}handleBlur(){var e;null===(e=this._renderer.value)||void 0===e||e.handleBlur()}handleFocus(){var e;null===(e=this._renderer.value)||void 0===e||e.handleFocus()}handleSelectionChanged(e,t,i){var s;this._selectionState.start=e,this._selectionState.end=t,this._selectionState.columnSelectMode=i,null===(s=this._renderer.value)||void 0===s||s.handleSelectionChanged(e,t,i)}handleCursorMove(){var e;null===(e=this._renderer.value)||void 0===e||e.handleCursorMove()}clear(){var e;null===(e=this._renderer.value)||void 0===e||e.clear()}};t.RenderService=u=s([r(2,_.IOptionsService),r(3,h.ICharSizeService),r(4,_.IDecorationService),r(5,_.IBufferService),r(6,h.ICoreBrowserService),r(7,h.IThemeService)],u)},9312:function(e,t,i){var s=this&&this.__decorate||function(e,t,i,s){var r,n=arguments.length,o=n<3?t:null===s?s=Object.getOwnPropertyDescriptor(t,i):s;if("object"==typeof Reflect&&"function"==typeof Reflect.decorate)o=Reflect.decorate(e,t,i,s);else for(var a=e.length-1;a>=0;a--)(r=e[a])&&(o=(n<3?r(o):n>3?r(t,i,o):r(t,i))||o);return n>3&&o&&Object.defineProperty(t,i,o),o},r=this&&this.__param||function(e,t){return function(i,s){t(i,s,e)}};Object.defineProperty(t,"__esModule",{value:!0}),t.SelectionService=void 0;const n=i(9806),o=i(9504),a=i(456),h=i(4725),c=i(8460),l=i(844),d=i(6114),_=i(4841),u=i(511),f=i(2585),v=String.fromCharCode(160),p=new RegExp(v,"g");let g=t.SelectionService=class extends l.Disposable{constructor(e,t,i,s,r,n,o,h,d){super(),this._element=e,this._screenElement=t,this._linkifier=i,this._bufferService=s,this._coreService=r,this._mouseService=n,this._optionsService=o,this._renderService=h,this._coreBrowserService=d,this._dragScrollAmount=0,this._enabled=!0,this._workCell=new u.CellData,this._mouseDownTimeStamp=0,this._oldHasSelection=!1,this._oldSelectionStart=void 0,this._oldSelectionEnd=void 0,this._onLinuxMouseSelection=this.register(new c.EventEmitter),this.onLinuxMouseSelection=this._onLinuxMouseSelection.event,this._onRedrawRequest=this.register(new c.EventEmitter),this.onRequestRedraw=this._onRedrawRequest.event,this._onSelectionChange=this.register(new c.EventEmitter),this.onSelectionChange=this._onSelectionChange.event,this._onRequestScrollLines=this.register(new c.EventEmitter),this.onRequestScrollLines=this._onRequestScrollLines.event,this._mouseMoveListener=e=>this._handleMouseMove(e),this._mouseUpListener=e=>this._handleMouseUp(e),this._coreService.onUserInput((()=>{this.hasSelection&&this.clearSelection()})),this._trimListener=this._bufferService.buffer.lines.onTrim((e=>this._handleTrim(e))),this.register(this._bufferService.buffers.onBufferActivate((e=>this._handleBufferActivate(e)))),this.enable(),this._model=new a.SelectionModel(this._bufferService),this._activeSelectionMode=0,this.register((0,l.toDisposable)((()=>{this._removeMouseDownListeners()})))}reset(){this.clearSelection()}disable(){this.clearSelection(),this._enabled=!1}enable(){this._enabled=!0}get selectionStart(){return this._model.finalSelectionStart}get selectionEnd(){return this._model.finalSelectionEnd}get hasSelection(){const e=this._model.finalSelectionStart,t=this._model.finalSelectionEnd;return!(!e||!t||e[0]===t[0]&&e[1]===t[1])}get selectionText(){const e=this._model.finalSelectionStart,t=this._model.finalSelectionEnd;if(!e||!t)return"";const i=this._bufferService.buffer,s=[];if(3===this._activeSelectionMode){if(e[0]===t[0])return"";const r=e[0]e.replace(p," "))).join(d.isWindows?"\r\n":"\n")}clearSelection(){this._model.clearSelection(),this._removeMouseDownListeners(),this.refresh(),this._onSelectionChange.fire()}refresh(e){this._refreshAnimationFrame||(this._refreshAnimationFrame=this._coreBrowserService.window.requestAnimationFrame((()=>this._refresh()))),d.isLinux&&e&&this.selectionText.length&&this._onLinuxMouseSelection.fire(this.selectionText)}_refresh(){this._refreshAnimationFrame=void 0,this._onRedrawRequest.fire({start:this._model.finalSelectionStart,end:this._model.finalSelectionEnd,columnSelectMode:3===this._activeSelectionMode})}_isClickInSelection(e){const t=this._getMouseBufferCoords(e),i=this._model.finalSelectionStart,s=this._model.finalSelectionEnd;return!!(i&&s&&t)&&this._areCoordsInSelection(t,i,s)}isCellInSelection(e,t){const i=this._model.finalSelectionStart,s=this._model.finalSelectionEnd;return!(!i||!s)&&this._areCoordsInSelection([e,t],i,s)}_areCoordsInSelection(e,t,i){return e[1]>t[1]&&e[1]=t[0]&&e[0]=t[0]}_selectWordAtCursor(e,t){var i,s;const r=null===(s=null===(i=this._linkifier.currentLink)||void 0===i?void 0:i.link)||void 0===s?void 0:s.range;if(r)return this._model.selectionStart=[r.start.x-1,r.start.y-1],this._model.selectionStartLength=(0,_.getRangeLength)(r,this._bufferService.cols),this._model.selectionEnd=void 0,!0;const n=this._getMouseBufferCoords(e);return!!n&&(this._selectWordAt(n,t),this._model.selectionEnd=void 0,!0)}selectAll(){this._model.isSelectAllActive=!0,this.refresh(),this._onSelectionChange.fire()}selectLines(e,t){this._model.clearSelection(),e=Math.max(e,0),t=Math.min(t,this._bufferService.buffer.lines.length-1),this._model.selectionStart=[0,e],this._model.selectionEnd=[this._bufferService.cols,t],this.refresh(),this._onSelectionChange.fire()}_handleTrim(e){this._model.handleTrim(e)&&this.refresh()}_getMouseBufferCoords(e){const t=this._mouseService.getCoords(e,this._screenElement,this._bufferService.cols,this._bufferService.rows,!0);if(t)return t[0]--,t[1]--,t[1]+=this._bufferService.buffer.ydisp,t}_getMouseEventScrollAmount(e){let t=(0,n.getCoordsRelativeToElement)(this._coreBrowserService.window,e,this._screenElement)[1];const i=this._renderService.dimensions.css.canvas.height;return t>=0&&t<=i?0:(t>i&&(t-=i),t=Math.min(Math.max(t,-50),50),t/=50,t/Math.abs(t)+Math.round(14*t))}shouldForceSelection(e){return d.isMac?e.altKey&&this._optionsService.rawOptions.macOptionClickForcesSelection:e.shiftKey}handleMouseDown(e){if(this._mouseDownTimeStamp=e.timeStamp,(2!==e.button||!this.hasSelection)&&0===e.button){if(!this._enabled){if(!this.shouldForceSelection(e))return;e.stopPropagation()}e.preventDefault(),this._dragScrollAmount=0,this._enabled&&e.shiftKey?this._handleIncrementalClick(e):1===e.detail?this._handleSingleClick(e):2===e.detail?this._handleDoubleClick(e):3===e.detail&&this._handleTripleClick(e),this._addMouseDownListeners(),this.refresh(!0)}}_addMouseDownListeners(){this._screenElement.ownerDocument&&(this._screenElement.ownerDocument.addEventListener("mousemove",this._mouseMoveListener),this._screenElement.ownerDocument.addEventListener("mouseup",this._mouseUpListener)),this._dragScrollIntervalTimer=this._coreBrowserService.window.setInterval((()=>this._dragScroll()),50)}_removeMouseDownListeners(){this._screenElement.ownerDocument&&(this._screenElement.ownerDocument.removeEventListener("mousemove",this._mouseMoveListener),this._screenElement.ownerDocument.removeEventListener("mouseup",this._mouseUpListener)),this._coreBrowserService.window.clearInterval(this._dragScrollIntervalTimer),this._dragScrollIntervalTimer=void 0}_handleIncrementalClick(e){this._model.selectionStart&&(this._model.selectionEnd=this._getMouseBufferCoords(e))}_handleSingleClick(e){if(this._model.selectionStartLength=0,this._model.isSelectAllActive=!1,this._activeSelectionMode=this.shouldColumnSelect(e)?3:0,this._model.selectionStart=this._getMouseBufferCoords(e),!this._model.selectionStart)return;this._model.selectionEnd=void 0;const t=this._bufferService.buffer.lines.get(this._model.selectionStart[1]);t&&t.length!==this._model.selectionStart[0]&&0===t.hasWidth(this._model.selectionStart[0])&&this._model.selectionStart[0]++}_handleDoubleClick(e){this._selectWordAtCursor(e,!0)&&(this._activeSelectionMode=1)}_handleTripleClick(e){const t=this._getMouseBufferCoords(e);t&&(this._activeSelectionMode=2,this._selectLineAt(t[1]))}shouldColumnSelect(e){return e.altKey&&!(d.isMac&&this._optionsService.rawOptions.macOptionClickForcesSelection)}_handleMouseMove(e){if(e.stopImmediatePropagation(),!this._model.selectionStart)return;const t=this._model.selectionEnd?[this._model.selectionEnd[0],this._model.selectionEnd[1]]:null;if(this._model.selectionEnd=this._getMouseBufferCoords(e),!this._model.selectionEnd)return void this.refresh(!0);2===this._activeSelectionMode?this._model.selectionEnd[1]0?this._model.selectionEnd[0]=this._bufferService.cols:this._dragScrollAmount<0&&(this._model.selectionEnd[0]=0));const i=this._bufferService.buffer;if(this._model.selectionEnd[1]0?(3!==this._activeSelectionMode&&(this._model.selectionEnd[0]=this._bufferService.cols),this._model.selectionEnd[1]=Math.min(e.ydisp+this._bufferService.rows,e.lines.length-1)):(3!==this._activeSelectionMode&&(this._model.selectionEnd[0]=0),this._model.selectionEnd[1]=e.ydisp),this.refresh()}}_handleMouseUp(e){const t=e.timeStamp-this._mouseDownTimeStamp;if(this._removeMouseDownListeners(),this.selectionText.length<=1&&t<500&&e.altKey&&this._optionsService.rawOptions.altClickMovesCursor){if(this._bufferService.buffer.ybase===this._bufferService.buffer.ydisp){const t=this._mouseService.getCoords(e,this._element,this._bufferService.cols,this._bufferService.rows,!1);if(t&&void 0!==t[0]&&void 0!==t[1]){const e=(0,o.moveToCellSequence)(t[0]-1,t[1]-1,this._bufferService,this._coreService.decPrivateModes.applicationCursorKeys);this._coreService.triggerDataEvent(e,!0)}}}else this._fireEventIfSelectionChanged()}_fireEventIfSelectionChanged(){const e=this._model.finalSelectionStart,t=this._model.finalSelectionEnd,i=!(!e||!t||e[0]===t[0]&&e[1]===t[1]);i?e&&t&&(this._oldSelectionStart&&this._oldSelectionEnd&&e[0]===this._oldSelectionStart[0]&&e[1]===this._oldSelectionStart[1]&&t[0]===this._oldSelectionEnd[0]&&t[1]===this._oldSelectionEnd[1]||this._fireOnSelectionChange(e,t,i)):this._oldHasSelection&&this._fireOnSelectionChange(e,t,i)}_fireOnSelectionChange(e,t,i){this._oldSelectionStart=e,this._oldSelectionEnd=t,this._oldHasSelection=i,this._onSelectionChange.fire()}_handleBufferActivate(e){this.clearSelection(),this._trimListener.dispose(),this._trimListener=e.activeBuffer.lines.onTrim((e=>this._handleTrim(e)))}_convertViewportColToCharacterIndex(e,t){let i=t;for(let s=0;t>=s;s++){const r=e.loadCell(s,this._workCell).getChars().length;0===this._workCell.getWidth()?i--:r>1&&t!==s&&(i+=r-1)}return i}setSelection(e,t,i){this._model.clearSelection(),this._removeMouseDownListeners(),this._model.selectionStart=[e,t],this._model.selectionStartLength=i,this.refresh(),this._fireEventIfSelectionChanged()}rightClickSelect(e){this._isClickInSelection(e)||(this._selectWordAtCursor(e,!1)&&this.refresh(!0),this._fireEventIfSelectionChanged())}_getWordAt(e,t,i=!0,s=!0){if(e[0]>=this._bufferService.cols)return;const r=this._bufferService.buffer,n=r.lines.get(e[1]);if(!n)return;const o=r.translateBufferLineToString(e[1],!1);let a=this._convertViewportColToCharacterIndex(n,e[0]),h=a;const c=e[0]-a;let l=0,d=0,_=0,u=0;if(" "===o.charAt(a)){for(;a>0&&" "===o.charAt(a-1);)a--;for(;h1&&(u+=s-1,h+=s-1);t>0&&a>0&&!this._isCharWordSeparator(n.loadCell(t-1,this._workCell));){n.loadCell(t-1,this._workCell);const e=this._workCell.getChars().length;0===this._workCell.getWidth()?(l++,t--):e>1&&(_+=e-1,a-=e-1),a--,t--}for(;i1&&(u+=e-1,h+=e-1),h++,i++}}h++;let f=a+c-l+_,v=Math.min(this._bufferService.cols,h-a+l+d-_-u);if(t||""!==o.slice(a,h).trim()){if(i&&0===f&&32!==n.getCodePoint(0)){const t=r.lines.get(e[1]-1);if(t&&n.isWrapped&&32!==t.getCodePoint(this._bufferService.cols-1)){const t=this._getWordAt([this._bufferService.cols-1,e[1]-1],!1,!0,!1);if(t){const e=this._bufferService.cols-t.start;f-=e,v+=e}}}if(s&&f+v===this._bufferService.cols&&32!==n.getCodePoint(this._bufferService.cols-1)){const t=r.lines.get(e[1]+1);if((null==t?void 0:t.isWrapped)&&32!==t.getCodePoint(0)){const t=this._getWordAt([0,e[1]+1],!1,!1,!0);t&&(v+=t.length)}}return{start:f,length:v}}}_selectWordAt(e,t){const i=this._getWordAt(e,t);if(i){for(;i.start<0;)i.start+=this._bufferService.cols,e[1]--;this._model.selectionStart=[i.start,e[1]],this._model.selectionStartLength=i.length}}_selectToWordAt(e){const t=this._getWordAt(e,!0);if(t){let i=e[1];for(;t.start<0;)t.start+=this._bufferService.cols,i--;if(!this._model.areSelectionValuesReversed())for(;t.start+t.length>this._bufferService.cols;)t.length-=this._bufferService.cols,i++;this._model.selectionEnd=[this._model.areSelectionValuesReversed()?t.start:t.start+t.length,i]}}_isCharWordSeparator(e){return 0!==e.getWidth()&&this._optionsService.rawOptions.wordSeparator.indexOf(e.getChars())>=0}_selectLineAt(e){const t=this._bufferService.buffer.getWrappedRangeForLine(e),i={start:{x:0,y:t.first},end:{x:this._bufferService.cols-1,y:t.last}};this._model.selectionStart=[0,t.first],this._model.selectionEnd=void 0,this._model.selectionStartLength=(0,_.getRangeLength)(i,this._bufferService.cols)}};t.SelectionService=g=s([r(3,f.IBufferService),r(4,f.ICoreService),r(5,h.IMouseService),r(6,f.IOptionsService),r(7,h.IRenderService),r(8,h.ICoreBrowserService)],g)},4725:(e,t,i)=>{Object.defineProperty(t,"__esModule",{value:!0}),t.IThemeService=t.ICharacterJoinerService=t.ISelectionService=t.IRenderService=t.IMouseService=t.ICoreBrowserService=t.ICharSizeService=void 0;const s=i(8343);t.ICharSizeService=(0,s.createDecorator)("CharSizeService"),t.ICoreBrowserService=(0,s.createDecorator)("CoreBrowserService"),t.IMouseService=(0,s.createDecorator)("MouseService"),t.IRenderService=(0,s.createDecorator)("RenderService"),t.ISelectionService=(0,s.createDecorator)("SelectionService"),t.ICharacterJoinerService=(0,s.createDecorator)("CharacterJoinerService"),t.IThemeService=(0,s.createDecorator)("ThemeService")},6731:function(e,t,i){var s=this&&this.__decorate||function(e,t,i,s){var r,n=arguments.length,o=n<3?t:null===s?s=Object.getOwnPropertyDescriptor(t,i):s;if("object"==typeof Reflect&&"function"==typeof Reflect.decorate)o=Reflect.decorate(e,t,i,s);else for(var a=e.length-1;a>=0;a--)(r=e[a])&&(o=(n<3?r(o):n>3?r(t,i,o):r(t,i))||o);return n>3&&o&&Object.defineProperty(t,i,o),o},r=this&&this.__param||function(e,t){return function(i,s){t(i,s,e)}};Object.defineProperty(t,"__esModule",{value:!0}),t.ThemeService=t.DEFAULT_ANSI_COLORS=void 0;const n=i(7239),o=i(8055),a=i(8460),h=i(844),c=i(2585),l=o.css.toColor("#ffffff"),d=o.css.toColor("#000000"),_=o.css.toColor("#ffffff"),u=o.css.toColor("#000000"),f={css:"rgba(255, 255, 255, 0.3)",rgba:4294967117};t.DEFAULT_ANSI_COLORS=Object.freeze((()=>{const e=[o.css.toColor("#2e3436"),o.css.toColor("#cc0000"),o.css.toColor("#4e9a06"),o.css.toColor("#c4a000"),o.css.toColor("#3465a4"),o.css.toColor("#75507b"),o.css.toColor("#06989a"),o.css.toColor("#d3d7cf"),o.css.toColor("#555753"),o.css.toColor("#ef2929"),o.css.toColor("#8ae234"),o.css.toColor("#fce94f"),o.css.toColor("#729fcf"),o.css.toColor("#ad7fa8"),o.css.toColor("#34e2e2"),o.css.toColor("#eeeeec")],t=[0,95,135,175,215,255];for(let i=0;i<216;i++){const s=t[i/36%6|0],r=t[i/6%6|0],n=t[i%6];e.push({css:o.channels.toCss(s,r,n),rgba:o.channels.toRgba(s,r,n)})}for(let t=0;t<24;t++){const i=8+10*t;e.push({css:o.channels.toCss(i,i,i),rgba:o.channels.toRgba(i,i,i)})}return e})());let v=t.ThemeService=class extends h.Disposable{get colors(){return this._colors}constructor(e){super(),this._optionsService=e,this._contrastCache=new n.ColorContrastCache,this._halfContrastCache=new n.ColorContrastCache,this._onChangeColors=this.register(new a.EventEmitter),this.onChangeColors=this._onChangeColors.event,this._colors={foreground:l,background:d,cursor:_,cursorAccent:u,selectionForeground:void 0,selectionBackgroundTransparent:f,selectionBackgroundOpaque:o.color.blend(d,f),selectionInactiveBackgroundTransparent:f,selectionInactiveBackgroundOpaque:o.color.blend(d,f),ansi:t.DEFAULT_ANSI_COLORS.slice(),contrastCache:this._contrastCache,halfContrastCache:this._halfContrastCache},this._updateRestoreColors(),this._setTheme(this._optionsService.rawOptions.theme),this.register(this._optionsService.onSpecificOptionChange("minimumContrastRatio",(()=>this._contrastCache.clear()))),this.register(this._optionsService.onSpecificOptionChange("theme",(()=>this._setTheme(this._optionsService.rawOptions.theme))))}_setTheme(e={}){const i=this._colors;if(i.foreground=p(e.foreground,l),i.background=p(e.background,d),i.cursor=p(e.cursor,_),i.cursorAccent=p(e.cursorAccent,u),i.selectionBackgroundTransparent=p(e.selectionBackground,f),i.selectionBackgroundOpaque=o.color.blend(i.background,i.selectionBackgroundTransparent),i.selectionInactiveBackgroundTransparent=p(e.selectionInactiveBackground,i.selectionBackgroundTransparent),i.selectionInactiveBackgroundOpaque=o.color.blend(i.background,i.selectionInactiveBackgroundTransparent),i.selectionForeground=e.selectionForeground?p(e.selectionForeground,o.NULL_COLOR):void 0,i.selectionForeground===o.NULL_COLOR&&(i.selectionForeground=void 0),o.color.isOpaque(i.selectionBackgroundTransparent)){const e=.3;i.selectionBackgroundTransparent=o.color.opacity(i.selectionBackgroundTransparent,e)}if(o.color.isOpaque(i.selectionInactiveBackgroundTransparent)){const e=.3;i.selectionInactiveBackgroundTransparent=o.color.opacity(i.selectionInactiveBackgroundTransparent,e)}if(i.ansi=t.DEFAULT_ANSI_COLORS.slice(),i.ansi[0]=p(e.black,t.DEFAULT_ANSI_COLORS[0]),i.ansi[1]=p(e.red,t.DEFAULT_ANSI_COLORS[1]),i.ansi[2]=p(e.green,t.DEFAULT_ANSI_COLORS[2]),i.ansi[3]=p(e.yellow,t.DEFAULT_ANSI_COLORS[3]),i.ansi[4]=p(e.blue,t.DEFAULT_ANSI_COLORS[4]),i.ansi[5]=p(e.magenta,t.DEFAULT_ANSI_COLORS[5]),i.ansi[6]=p(e.cyan,t.DEFAULT_ANSI_COLORS[6]),i.ansi[7]=p(e.white,t.DEFAULT_ANSI_COLORS[7]),i.ansi[8]=p(e.brightBlack,t.DEFAULT_ANSI_COLORS[8]),i.ansi[9]=p(e.brightRed,t.DEFAULT_ANSI_COLORS[9]),i.ansi[10]=p(e.brightGreen,t.DEFAULT_ANSI_COLORS[10]),i.ansi[11]=p(e.brightYellow,t.DEFAULT_ANSI_COLORS[11]),i.ansi[12]=p(e.brightBlue,t.DEFAULT_ANSI_COLORS[12]),i.ansi[13]=p(e.brightMagenta,t.DEFAULT_ANSI_COLORS[13]),i.ansi[14]=p(e.brightCyan,t.DEFAULT_ANSI_COLORS[14]),i.ansi[15]=p(e.brightWhite,t.DEFAULT_ANSI_COLORS[15]),e.extendedAnsi){const s=Math.min(i.ansi.length-16,e.extendedAnsi.length);for(let r=0;r{Object.defineProperty(t,"__esModule",{value:!0}),t.CircularList=void 0;const s=i(8460),r=i(844);class n extends r.Disposable{constructor(e){super(),this._maxLength=e,this.onDeleteEmitter=this.register(new s.EventEmitter),this.onDelete=this.onDeleteEmitter.event,this.onInsertEmitter=this.register(new s.EventEmitter),this.onInsert=this.onInsertEmitter.event,this.onTrimEmitter=this.register(new s.EventEmitter),this.onTrim=this.onTrimEmitter.event,this._array=new Array(this._maxLength),this._startIndex=0,this._length=0}get maxLength(){return this._maxLength}set maxLength(e){if(this._maxLength===e)return;const t=new Array(e);for(let i=0;ithis._length)for(let t=this._length;t=e;t--)this._array[this._getCyclicIndex(t+i.length)]=this._array[this._getCyclicIndex(t)];for(let t=0;tthis._maxLength){const e=this._length+i.length-this._maxLength;this._startIndex+=e,this._length=this._maxLength,this.onTrimEmitter.fire(e)}else this._length+=i.length}trimStart(e){e>this._length&&(e=this._length),this._startIndex+=e,this._length-=e,this.onTrimEmitter.fire(e)}shiftElements(e,t,i){if(!(t<=0)){if(e<0||e>=this._length)throw new Error("start argument out of range");if(e+i<0)throw new Error("Cannot shift elements in list beyond index 0");if(i>0){for(let s=t-1;s>=0;s--)this.set(e+s+i,this.get(e+s));const s=e+t+i-this._length;if(s>0)for(this._length+=s;this._length>this._maxLength;)this._length--,this._startIndex++,this.onTrimEmitter.fire(1)}else for(let s=0;s{Object.defineProperty(t,"__esModule",{value:!0}),t.clone=void 0,t.clone=function e(t,i=5){if("object"!=typeof t)return t;const s=Array.isArray(t)?[]:{};for(const r in t)s[r]=i<=1?t[r]:t[r]&&e(t[r],i-1);return s}},8055:(e,t,i)=>{Object.defineProperty(t,"__esModule",{value:!0}),t.contrastRatio=t.toPaddedHex=t.rgba=t.rgb=t.css=t.color=t.channels=t.NULL_COLOR=void 0;const s=i(6114);let r=0,n=0,o=0,a=0;var h,c,l,d,_;function u(e){const t=e.toString(16);return t.length<2?"0"+t:t}function f(e,t){return e>>0}}(h||(t.channels=h={})),function(e){function t(e,t){return a=Math.round(255*t),[r,n,o]=_.toChannels(e.rgba),{css:h.toCss(r,n,o,a),rgba:h.toRgba(r,n,o,a)}}e.blend=function(e,t){if(a=(255&t.rgba)/255,1===a)return{css:t.css,rgba:t.rgba};const i=t.rgba>>24&255,s=t.rgba>>16&255,c=t.rgba>>8&255,l=e.rgba>>24&255,d=e.rgba>>16&255,_=e.rgba>>8&255;return r=l+Math.round((i-l)*a),n=d+Math.round((s-d)*a),o=_+Math.round((c-_)*a),{css:h.toCss(r,n,o),rgba:h.toRgba(r,n,o)}},e.isOpaque=function(e){return 255==(255&e.rgba)},e.ensureContrastRatio=function(e,t,i){const s=_.ensureContrastRatio(e.rgba,t.rgba,i);if(s)return _.toColor(s>>24&255,s>>16&255,s>>8&255)},e.opaque=function(e){const t=(255|e.rgba)>>>0;return[r,n,o]=_.toChannels(t),{css:h.toCss(r,n,o),rgba:t}},e.opacity=t,e.multiplyOpacity=function(e,i){return a=255&e.rgba,t(e,a*i/255)},e.toColorRGB=function(e){return[e.rgba>>24&255,e.rgba>>16&255,e.rgba>>8&255]}}(c||(t.color=c={})),function(e){let t,i;if(!s.isNode){const e=document.createElement("canvas");e.width=1,e.height=1;const s=e.getContext("2d",{willReadFrequently:!0});s&&(t=s,t.globalCompositeOperation="copy",i=t.createLinearGradient(0,0,1,1))}e.toColor=function(e){if(e.match(/#[\da-f]{3,8}/i))switch(e.length){case 4:return r=parseInt(e.slice(1,2).repeat(2),16),n=parseInt(e.slice(2,3).repeat(2),16),o=parseInt(e.slice(3,4).repeat(2),16),_.toColor(r,n,o);case 5:return r=parseInt(e.slice(1,2).repeat(2),16),n=parseInt(e.slice(2,3).repeat(2),16),o=parseInt(e.slice(3,4).repeat(2),16),a=parseInt(e.slice(4,5).repeat(2),16),_.toColor(r,n,o,a);case 7:return{css:e,rgba:(parseInt(e.slice(1),16)<<8|255)>>>0};case 9:return{css:e,rgba:parseInt(e.slice(1),16)>>>0}}const s=e.match(/rgba?\(\s*(\d{1,3})\s*,\s*(\d{1,3})\s*,\s*(\d{1,3})\s*(,\s*(0|1|\d?\.(\d+))\s*)?\)/);if(s)return r=parseInt(s[1]),n=parseInt(s[2]),o=parseInt(s[3]),a=Math.round(255*(void 0===s[5]?1:parseFloat(s[5]))),_.toColor(r,n,o,a);if(!t||!i)throw new Error("css.toColor: Unsupported css format");if(t.fillStyle=i,t.fillStyle=e,"string"!=typeof t.fillStyle)throw new Error("css.toColor: Unsupported css format");if(t.fillRect(0,0,1,1),[r,n,o,a]=t.getImageData(0,0,1,1).data,255!==a)throw new Error("css.toColor: Unsupported css format");return{rgba:h.toRgba(r,n,o,a),css:e}}}(l||(t.css=l={})),function(e){function t(e,t,i){const s=e/255,r=t/255,n=i/255;return.2126*(s<=.03928?s/12.92:Math.pow((s+.055)/1.055,2.4))+.7152*(r<=.03928?r/12.92:Math.pow((r+.055)/1.055,2.4))+.0722*(n<=.03928?n/12.92:Math.pow((n+.055)/1.055,2.4))}e.relativeLuminance=function(e){return t(e>>16&255,e>>8&255,255&e)},e.relativeLuminance2=t}(d||(t.rgb=d={})),function(e){function t(e,t,i){const s=e>>24&255,r=e>>16&255,n=e>>8&255;let o=t>>24&255,a=t>>16&255,h=t>>8&255,c=f(d.relativeLuminance2(o,a,h),d.relativeLuminance2(s,r,n));for(;c0||a>0||h>0);)o-=Math.max(0,Math.ceil(.1*o)),a-=Math.max(0,Math.ceil(.1*a)),h-=Math.max(0,Math.ceil(.1*h)),c=f(d.relativeLuminance2(o,a,h),d.relativeLuminance2(s,r,n));return(o<<24|a<<16|h<<8|255)>>>0}function i(e,t,i){const s=e>>24&255,r=e>>16&255,n=e>>8&255;let o=t>>24&255,a=t>>16&255,h=t>>8&255,c=f(d.relativeLuminance2(o,a,h),d.relativeLuminance2(s,r,n));for(;c>>0}e.ensureContrastRatio=function(e,s,r){const n=d.relativeLuminance(e>>8),o=d.relativeLuminance(s>>8);if(f(n,o)>8));if(af(n,d.relativeLuminance(t>>8))?o:t}return o}const a=i(e,s,r),h=f(n,d.relativeLuminance(a>>8));if(hf(n,d.relativeLuminance(i>>8))?a:i}return a}},e.reduceLuminance=t,e.increaseLuminance=i,e.toChannels=function(e){return[e>>24&255,e>>16&255,e>>8&255,255&e]},e.toColor=function(e,t,i,s){return{css:h.toCss(e,t,i,s),rgba:h.toRgba(e,t,i,s)}}}(_||(t.rgba=_={})),t.toPaddedHex=u,t.contrastRatio=f},8969:(e,t,i)=>{Object.defineProperty(t,"__esModule",{value:!0}),t.CoreTerminal=void 0;const s=i(844),r=i(2585),n=i(4348),o=i(7866),a=i(744),h=i(7302),c=i(6975),l=i(8460),d=i(1753),_=i(1480),u=i(7994),f=i(9282),v=i(5435),p=i(5981),g=i(2660);let m=!1;class S extends s.Disposable{get onScroll(){return this._onScrollApi||(this._onScrollApi=this.register(new l.EventEmitter),this._onScroll.event((e=>{var t;null===(t=this._onScrollApi)||void 0===t||t.fire(e.position)}))),this._onScrollApi.event}get cols(){return this._bufferService.cols}get rows(){return this._bufferService.rows}get buffers(){return this._bufferService.buffers}get options(){return this.optionsService.options}set options(e){for(const t in e)this.optionsService.options[t]=e[t]}constructor(e){super(),this._windowsWrappingHeuristics=this.register(new s.MutableDisposable),this._onBinary=this.register(new l.EventEmitter),this.onBinary=this._onBinary.event,this._onData=this.register(new l.EventEmitter),this.onData=this._onData.event,this._onLineFeed=this.register(new l.EventEmitter),this.onLineFeed=this._onLineFeed.event,this._onResize=this.register(new l.EventEmitter),this.onResize=this._onResize.event,this._onWriteParsed=this.register(new l.EventEmitter),this.onWriteParsed=this._onWriteParsed.event,this._onScroll=this.register(new l.EventEmitter),this._instantiationService=new n.InstantiationService,this.optionsService=this.register(new h.OptionsService(e)),this._instantiationService.setService(r.IOptionsService,this.optionsService),this._bufferService=this.register(this._instantiationService.createInstance(a.BufferService)),this._instantiationService.setService(r.IBufferService,this._bufferService),this._logService=this.register(this._instantiationService.createInstance(o.LogService)),this._instantiationService.setService(r.ILogService,this._logService),this.coreService=this.register(this._instantiationService.createInstance(c.CoreService)),this._instantiationService.setService(r.ICoreService,this.coreService),this.coreMouseService=this.register(this._instantiationService.createInstance(d.CoreMouseService)),this._instantiationService.setService(r.ICoreMouseService,this.coreMouseService),this.unicodeService=this.register(this._instantiationService.createInstance(_.UnicodeService)),this._instantiationService.setService(r.IUnicodeService,this.unicodeService),this._charsetService=this._instantiationService.createInstance(u.CharsetService),this._instantiationService.setService(r.ICharsetService,this._charsetService),this._oscLinkService=this._instantiationService.createInstance(g.OscLinkService),this._instantiationService.setService(r.IOscLinkService,this._oscLinkService),this._inputHandler=this.register(new v.InputHandler(this._bufferService,this._charsetService,this.coreService,this._logService,this.optionsService,this._oscLinkService,this.coreMouseService,this.unicodeService)),this.register((0,l.forwardEvent)(this._inputHandler.onLineFeed,this._onLineFeed)),this.register(this._inputHandler),this.register((0,l.forwardEvent)(this._bufferService.onResize,this._onResize)),this.register((0,l.forwardEvent)(this.coreService.onData,this._onData)),this.register((0,l.forwardEvent)(this.coreService.onBinary,this._onBinary)),this.register(this.coreService.onRequestScrollToBottom((()=>this.scrollToBottom()))),this.register(this.coreService.onUserInput((()=>this._writeBuffer.handleUserInput()))),this.register(this.optionsService.onMultipleOptionChange(["windowsMode","windowsPty"],(()=>this._handleWindowsPtyOptionChange()))),this.register(this._bufferService.onScroll((e=>{this._onScroll.fire({position:this._bufferService.buffer.ydisp,source:0}),this._inputHandler.markRangeDirty(this._bufferService.buffer.scrollTop,this._bufferService.buffer.scrollBottom)}))),this.register(this._inputHandler.onScroll((e=>{this._onScroll.fire({position:this._bufferService.buffer.ydisp,source:0}),this._inputHandler.markRangeDirty(this._bufferService.buffer.scrollTop,this._bufferService.buffer.scrollBottom)}))),this._writeBuffer=this.register(new p.WriteBuffer(((e,t)=>this._inputHandler.parse(e,t)))),this.register((0,l.forwardEvent)(this._writeBuffer.onWriteParsed,this._onWriteParsed))}write(e,t){this._writeBuffer.write(e,t)}writeSync(e,t){this._logService.logLevel<=r.LogLevelEnum.WARN&&!m&&(this._logService.warn("writeSync is unreliable and will be removed soon."),m=!0),this._writeBuffer.writeSync(e,t)}resize(e,t){isNaN(e)||isNaN(t)||(e=Math.max(e,a.MINIMUM_COLS),t=Math.max(t,a.MINIMUM_ROWS),this._bufferService.resize(e,t))}scroll(e,t=!1){this._bufferService.scroll(e,t)}scrollLines(e,t,i){this._bufferService.scrollLines(e,t,i)}scrollPages(e){this.scrollLines(e*(this.rows-1))}scrollToTop(){this.scrollLines(-this._bufferService.buffer.ydisp)}scrollToBottom(){this.scrollLines(this._bufferService.buffer.ybase-this._bufferService.buffer.ydisp)}scrollToLine(e){const t=e-this._bufferService.buffer.ydisp;0!==t&&this.scrollLines(t)}registerEscHandler(e,t){return this._inputHandler.registerEscHandler(e,t)}registerDcsHandler(e,t){return this._inputHandler.registerDcsHandler(e,t)}registerCsiHandler(e,t){return this._inputHandler.registerCsiHandler(e,t)}registerOscHandler(e,t){return this._inputHandler.registerOscHandler(e,t)}_setup(){this._handleWindowsPtyOptionChange()}reset(){this._inputHandler.reset(),this._bufferService.reset(),this._charsetService.reset(),this.coreService.reset(),this.coreMouseService.reset()}_handleWindowsPtyOptionChange(){let e=!1;const t=this.optionsService.rawOptions.windowsPty;t&&void 0!==t.buildNumber&&void 0!==t.buildNumber?e=!!("conpty"===t.backend&&t.buildNumber<21376):this.optionsService.rawOptions.windowsMode&&(e=!0),e?this._enableWindowsWrappingHeuristics():this._windowsWrappingHeuristics.clear()}_enableWindowsWrappingHeuristics(){if(!this._windowsWrappingHeuristics.value){const e=[];e.push(this.onLineFeed(f.updateWindowsModeWrappedState.bind(null,this._bufferService))),e.push(this.registerCsiHandler({final:"H"},(()=>((0,f.updateWindowsModeWrappedState)(this._bufferService),!1)))),this._windowsWrappingHeuristics.value=(0,s.toDisposable)((()=>{for(const t of e)t.dispose()}))}}}t.CoreTerminal=S},8460:(e,t)=>{Object.defineProperty(t,"__esModule",{value:!0}),t.forwardEvent=t.EventEmitter=void 0,t.EventEmitter=class{constructor(){this._listeners=[],this._disposed=!1}get event(){return this._event||(this._event=e=>(this._listeners.push(e),{dispose:()=>{if(!this._disposed)for(let t=0;tt.fire(e)))}},5435:function(e,t,i){var s=this&&this.__decorate||function(e,t,i,s){var r,n=arguments.length,o=n<3?t:null===s?s=Object.getOwnPropertyDescriptor(t,i):s;if("object"==typeof Reflect&&"function"==typeof Reflect.decorate)o=Reflect.decorate(e,t,i,s);else for(var a=e.length-1;a>=0;a--)(r=e[a])&&(o=(n<3?r(o):n>3?r(t,i,o):r(t,i))||o);return n>3&&o&&Object.defineProperty(t,i,o),o},r=this&&this.__param||function(e,t){return function(i,s){t(i,s,e)}};Object.defineProperty(t,"__esModule",{value:!0}),t.InputHandler=t.WindowsOptionsReportType=void 0;const n=i(2584),o=i(7116),a=i(2015),h=i(844),c=i(482),l=i(8437),d=i(8460),_=i(643),u=i(511),f=i(3734),v=i(2585),p=i(6242),g=i(6351),m=i(5941),S={"(":0,")":1,"*":2,"+":3,"-":1,".":2},C=131072;function b(e,t){if(e>24)return t.setWinLines||!1;switch(e){case 1:return!!t.restoreWin;case 2:return!!t.minimizeWin;case 3:return!!t.setWinPosition;case 4:return!!t.setWinSizePixels;case 5:return!!t.raiseWin;case 6:return!!t.lowerWin;case 7:return!!t.refreshWin;case 8:return!!t.setWinSizeChars;case 9:return!!t.maximizeWin;case 10:return!!t.fullscreenWin;case 11:return!!t.getWinState;case 13:return!!t.getWinPosition;case 14:return!!t.getWinSizePixels;case 15:return!!t.getScreenSizePixels;case 16:return!!t.getCellSizePixels;case 18:return!!t.getWinSizeChars;case 19:return!!t.getScreenSizeChars;case 20:return!!t.getIconTitle;case 21:return!!t.getWinTitle;case 22:return!!t.pushTitle;case 23:return!!t.popTitle;case 24:return!!t.setWinLines}return!1}var y;!function(e){e[e.GET_WIN_SIZE_PIXELS=0]="GET_WIN_SIZE_PIXELS",e[e.GET_CELL_SIZE_PIXELS=1]="GET_CELL_SIZE_PIXELS"}(y||(t.WindowsOptionsReportType=y={}));let w=0;class E extends h.Disposable{getAttrData(){return this._curAttrData}constructor(e,t,i,s,r,h,_,f,v=new a.EscapeSequenceParser){super(),this._bufferService=e,this._charsetService=t,this._coreService=i,this._logService=s,this._optionsService=r,this._oscLinkService=h,this._coreMouseService=_,this._unicodeService=f,this._parser=v,this._parseBuffer=new Uint32Array(4096),this._stringDecoder=new c.StringToUtf32,this._utf8Decoder=new c.Utf8ToUtf32,this._workCell=new u.CellData,this._windowTitle="",this._iconName="",this._windowTitleStack=[],this._iconNameStack=[],this._curAttrData=l.DEFAULT_ATTR_DATA.clone(),this._eraseAttrDataInternal=l.DEFAULT_ATTR_DATA.clone(),this._onRequestBell=this.register(new d.EventEmitter),this.onRequestBell=this._onRequestBell.event,this._onRequestRefreshRows=this.register(new d.EventEmitter),this.onRequestRefreshRows=this._onRequestRefreshRows.event,this._onRequestReset=this.register(new d.EventEmitter),this.onRequestReset=this._onRequestReset.event,this._onRequestSendFocus=this.register(new d.EventEmitter),this.onRequestSendFocus=this._onRequestSendFocus.event,this._onRequestSyncScrollBar=this.register(new d.EventEmitter),this.onRequestSyncScrollBar=this._onRequestSyncScrollBar.event,this._onRequestWindowsOptionsReport=this.register(new d.EventEmitter),this.onRequestWindowsOptionsReport=this._onRequestWindowsOptionsReport.event,this._onA11yChar=this.register(new d.EventEmitter),this.onA11yChar=this._onA11yChar.event,this._onA11yTab=this.register(new d.EventEmitter),this.onA11yTab=this._onA11yTab.event,this._onCursorMove=this.register(new d.EventEmitter),this.onCursorMove=this._onCursorMove.event,this._onLineFeed=this.register(new d.EventEmitter),this.onLineFeed=this._onLineFeed.event,this._onScroll=this.register(new d.EventEmitter),this.onScroll=this._onScroll.event,this._onTitleChange=this.register(new d.EventEmitter),this.onTitleChange=this._onTitleChange.event,this._onColor=this.register(new d.EventEmitter),this.onColor=this._onColor.event,this._parseStack={paused:!1,cursorStartX:0,cursorStartY:0,decodedLength:0,position:0},this._specialColors=[256,257,258],this.register(this._parser),this._dirtyRowTracker=new k(this._bufferService),this._activeBuffer=this._bufferService.buffer,this.register(this._bufferService.buffers.onBufferActivate((e=>this._activeBuffer=e.activeBuffer))),this._parser.setCsiHandlerFallback(((e,t)=>{this._logService.debug("Unknown CSI code: ",{identifier:this._parser.identToString(e),params:t.toArray()})})),this._parser.setEscHandlerFallback((e=>{this._logService.debug("Unknown ESC code: ",{identifier:this._parser.identToString(e)})})),this._parser.setExecuteHandlerFallback((e=>{this._logService.debug("Unknown EXECUTE code: ",{code:e})})),this._parser.setOscHandlerFallback(((e,t,i)=>{this._logService.debug("Unknown OSC code: ",{identifier:e,action:t,data:i})})),this._parser.setDcsHandlerFallback(((e,t,i)=>{"HOOK"===t&&(i=i.toArray()),this._logService.debug("Unknown DCS code: ",{identifier:this._parser.identToString(e),action:t,payload:i})})),this._parser.setPrintHandler(((e,t,i)=>this.print(e,t,i))),this._parser.registerCsiHandler({final:"@"},(e=>this.insertChars(e))),this._parser.registerCsiHandler({intermediates:" ",final:"@"},(e=>this.scrollLeft(e))),this._parser.registerCsiHandler({final:"A"},(e=>this.cursorUp(e))),this._parser.registerCsiHandler({intermediates:" ",final:"A"},(e=>this.scrollRight(e))),this._parser.registerCsiHandler({final:"B"},(e=>this.cursorDown(e))),this._parser.registerCsiHandler({final:"C"},(e=>this.cursorForward(e))),this._parser.registerCsiHandler({final:"D"},(e=>this.cursorBackward(e))),this._parser.registerCsiHandler({final:"E"},(e=>this.cursorNextLine(e))),this._parser.registerCsiHandler({final:"F"},(e=>this.cursorPrecedingLine(e))),this._parser.registerCsiHandler({final:"G"},(e=>this.cursorCharAbsolute(e))),this._parser.registerCsiHandler({final:"H"},(e=>this.cursorPosition(e))),this._parser.registerCsiHandler({final:"I"},(e=>this.cursorForwardTab(e))),this._parser.registerCsiHandler({final:"J"},(e=>this.eraseInDisplay(e,!1))),this._parser.registerCsiHandler({prefix:"?",final:"J"},(e=>this.eraseInDisplay(e,!0))),this._parser.registerCsiHandler({final:"K"},(e=>this.eraseInLine(e,!1))),this._parser.registerCsiHandler({prefix:"?",final:"K"},(e=>this.eraseInLine(e,!0))),this._parser.registerCsiHandler({final:"L"},(e=>this.insertLines(e))),this._parser.registerCsiHandler({final:"M"},(e=>this.deleteLines(e))),this._parser.registerCsiHandler({final:"P"},(e=>this.deleteChars(e))),this._parser.registerCsiHandler({final:"S"},(e=>this.scrollUp(e))),this._parser.registerCsiHandler({final:"T"},(e=>this.scrollDown(e))),this._parser.registerCsiHandler({final:"X"},(e=>this.eraseChars(e))),this._parser.registerCsiHandler({final:"Z"},(e=>this.cursorBackwardTab(e))),this._parser.registerCsiHandler({final:"`"},(e=>this.charPosAbsolute(e))),this._parser.registerCsiHandler({final:"a"},(e=>this.hPositionRelative(e))),this._parser.registerCsiHandler({final:"b"},(e=>this.repeatPrecedingCharacter(e))),this._parser.registerCsiHandler({final:"c"},(e=>this.sendDeviceAttributesPrimary(e))),this._parser.registerCsiHandler({prefix:">",final:"c"},(e=>this.sendDeviceAttributesSecondary(e))),this._parser.registerCsiHandler({final:"d"},(e=>this.linePosAbsolute(e))),this._parser.registerCsiHandler({final:"e"},(e=>this.vPositionRelative(e))),this._parser.registerCsiHandler({final:"f"},(e=>this.hVPosition(e))),this._parser.registerCsiHandler({final:"g"},(e=>this.tabClear(e))),this._parser.registerCsiHandler({final:"h"},(e=>this.setMode(e))),this._parser.registerCsiHandler({prefix:"?",final:"h"},(e=>this.setModePrivate(e))),this._parser.registerCsiHandler({final:"l"},(e=>this.resetMode(e))),this._parser.registerCsiHandler({prefix:"?",final:"l"},(e=>this.resetModePrivate(e))),this._parser.registerCsiHandler({final:"m"},(e=>this.charAttributes(e))),this._parser.registerCsiHandler({final:"n"},(e=>this.deviceStatus(e))),this._parser.registerCsiHandler({prefix:"?",final:"n"},(e=>this.deviceStatusPrivate(e))),this._parser.registerCsiHandler({intermediates:"!",final:"p"},(e=>this.softReset(e))),this._parser.registerCsiHandler({intermediates:" ",final:"q"},(e=>this.setCursorStyle(e))),this._parser.registerCsiHandler({final:"r"},(e=>this.setScrollRegion(e))),this._parser.registerCsiHandler({final:"s"},(e=>this.saveCursor(e))),this._parser.registerCsiHandler({final:"t"},(e=>this.windowOptions(e))),this._parser.registerCsiHandler({final:"u"},(e=>this.restoreCursor(e))),this._parser.registerCsiHandler({intermediates:"'",final:"}"},(e=>this.insertColumns(e))),this._parser.registerCsiHandler({intermediates:"'",final:"~"},(e=>this.deleteColumns(e))),this._parser.registerCsiHandler({intermediates:'"',final:"q"},(e=>this.selectProtected(e))),this._parser.registerCsiHandler({intermediates:"$",final:"p"},(e=>this.requestMode(e,!0))),this._parser.registerCsiHandler({prefix:"?",intermediates:"$",final:"p"},(e=>this.requestMode(e,!1))),this._parser.setExecuteHandler(n.C0.BEL,(()=>this.bell())),this._parser.setExecuteHandler(n.C0.LF,(()=>this.lineFeed())),this._parser.setExecuteHandler(n.C0.VT,(()=>this.lineFeed())),this._parser.setExecuteHandler(n.C0.FF,(()=>this.lineFeed())),this._parser.setExecuteHandler(n.C0.CR,(()=>this.carriageReturn())),this._parser.setExecuteHandler(n.C0.BS,(()=>this.backspace())),this._parser.setExecuteHandler(n.C0.HT,(()=>this.tab())),this._parser.setExecuteHandler(n.C0.SO,(()=>this.shiftOut())),this._parser.setExecuteHandler(n.C0.SI,(()=>this.shiftIn())),this._parser.setExecuteHandler(n.C1.IND,(()=>this.index())),this._parser.setExecuteHandler(n.C1.NEL,(()=>this.nextLine())),this._parser.setExecuteHandler(n.C1.HTS,(()=>this.tabSet())),this._parser.registerOscHandler(0,new p.OscHandler((e=>(this.setTitle(e),this.setIconName(e),!0)))),this._parser.registerOscHandler(1,new p.OscHandler((e=>this.setIconName(e)))),this._parser.registerOscHandler(2,new p.OscHandler((e=>this.setTitle(e)))),this._parser.registerOscHandler(4,new p.OscHandler((e=>this.setOrReportIndexedColor(e)))),this._parser.registerOscHandler(8,new p.OscHandler((e=>this.setHyperlink(e)))),this._parser.registerOscHandler(10,new p.OscHandler((e=>this.setOrReportFgColor(e)))),this._parser.registerOscHandler(11,new p.OscHandler((e=>this.setOrReportBgColor(e)))),this._parser.registerOscHandler(12,new p.OscHandler((e=>this.setOrReportCursorColor(e)))),this._parser.registerOscHandler(104,new p.OscHandler((e=>this.restoreIndexedColor(e)))),this._parser.registerOscHandler(110,new p.OscHandler((e=>this.restoreFgColor(e)))),this._parser.registerOscHandler(111,new p.OscHandler((e=>this.restoreBgColor(e)))),this._parser.registerOscHandler(112,new p.OscHandler((e=>this.restoreCursorColor(e)))),this._parser.registerEscHandler({final:"7"},(()=>this.saveCursor())),this._parser.registerEscHandler({final:"8"},(()=>this.restoreCursor())),this._parser.registerEscHandler({final:"D"},(()=>this.index())),this._parser.registerEscHandler({final:"E"},(()=>this.nextLine())),this._parser.registerEscHandler({final:"H"},(()=>this.tabSet())),this._parser.registerEscHandler({final:"M"},(()=>this.reverseIndex())),this._parser.registerEscHandler({final:"="},(()=>this.keypadApplicationMode())),this._parser.registerEscHandler({final:">"},(()=>this.keypadNumericMode())),this._parser.registerEscHandler({final:"c"},(()=>this.fullReset())),this._parser.registerEscHandler({final:"n"},(()=>this.setgLevel(2))),this._parser.registerEscHandler({final:"o"},(()=>this.setgLevel(3))),this._parser.registerEscHandler({final:"|"},(()=>this.setgLevel(3))),this._parser.registerEscHandler({final:"}"},(()=>this.setgLevel(2))),this._parser.registerEscHandler({final:"~"},(()=>this.setgLevel(1))),this._parser.registerEscHandler({intermediates:"%",final:"@"},(()=>this.selectDefaultCharset())),this._parser.registerEscHandler({intermediates:"%",final:"G"},(()=>this.selectDefaultCharset()));for(const e in o.CHARSETS)this._parser.registerEscHandler({intermediates:"(",final:e},(()=>this.selectCharset("("+e))),this._parser.registerEscHandler({intermediates:")",final:e},(()=>this.selectCharset(")"+e))),this._parser.registerEscHandler({intermediates:"*",final:e},(()=>this.selectCharset("*"+e))),this._parser.registerEscHandler({intermediates:"+",final:e},(()=>this.selectCharset("+"+e))),this._parser.registerEscHandler({intermediates:"-",final:e},(()=>this.selectCharset("-"+e))),this._parser.registerEscHandler({intermediates:".",final:e},(()=>this.selectCharset("."+e))),this._parser.registerEscHandler({intermediates:"/",final:e},(()=>this.selectCharset("/"+e)));this._parser.registerEscHandler({intermediates:"#",final:"8"},(()=>this.screenAlignmentPattern())),this._parser.setErrorHandler((e=>(this._logService.error("Parsing error: ",e),e))),this._parser.registerDcsHandler({intermediates:"$",final:"q"},new g.DcsHandler(((e,t)=>this.requestStatusString(e,t))))}_preserveStack(e,t,i,s){this._parseStack.paused=!0,this._parseStack.cursorStartX=e,this._parseStack.cursorStartY=t,this._parseStack.decodedLength=i,this._parseStack.position=s}_logSlowResolvingAsync(e){this._logService.logLevel<=v.LogLevelEnum.WARN&&Promise.race([e,new Promise(((e,t)=>setTimeout((()=>t("#SLOW_TIMEOUT")),5e3)))]).catch((e=>{if("#SLOW_TIMEOUT"!==e)throw e;console.warn("async parser handler taking longer than 5000 ms")}))}_getCurrentLinkId(){return this._curAttrData.extended.urlId}parse(e,t){let i,s=this._activeBuffer.x,r=this._activeBuffer.y,n=0;const o=this._parseStack.paused;if(o){if(i=this._parser.parse(this._parseBuffer,this._parseStack.decodedLength,t))return this._logSlowResolvingAsync(i),i;s=this._parseStack.cursorStartX,r=this._parseStack.cursorStartY,this._parseStack.paused=!1,e.length>C&&(n=this._parseStack.position+C)}if(this._logService.logLevel<=v.LogLevelEnum.DEBUG&&this._logService.debug("parsing data"+("string"==typeof e?` "${e}"`:` "${Array.prototype.map.call(e,(e=>String.fromCharCode(e))).join("")}"`),"string"==typeof e?e.split("").map((e=>e.charCodeAt(0))):e),this._parseBuffer.lengthC)for(let t=n;t0&&2===u.getWidth(this._activeBuffer.x-1)&&u.setCellFromCodePoint(this._activeBuffer.x-1,0,1,d.fg,d.bg,d.extended);for(let f=t;f=a)if(h){for(;this._activeBuffer.x=this._bufferService.rows&&(this._activeBuffer.y=this._bufferService.rows-1),this._activeBuffer.lines.get(this._activeBuffer.ybase+this._activeBuffer.y).isWrapped=!0),u=this._activeBuffer.lines.get(this._activeBuffer.ybase+this._activeBuffer.y)}else if(this._activeBuffer.x=a-1,2===r)continue;if(l&&(u.insertCells(this._activeBuffer.x,r,this._activeBuffer.getNullCell(d),d),2===u.getWidth(a-1)&&u.setCellFromCodePoint(a-1,_.NULL_CELL_CODE,_.NULL_CELL_WIDTH,d.fg,d.bg,d.extended)),u.setCellFromCodePoint(this._activeBuffer.x++,s,r,d.fg,d.bg,d.extended),r>0)for(;--r;)u.setCellFromCodePoint(this._activeBuffer.x++,0,0,d.fg,d.bg,d.extended)}else u.getWidth(this._activeBuffer.x-1)?u.addCodepointToCell(this._activeBuffer.x-1,s):u.addCodepointToCell(this._activeBuffer.x-2,s)}i-t>0&&(u.loadCell(this._activeBuffer.x-1,this._workCell),2===this._workCell.getWidth()||this._workCell.getCode()>65535?this._parser.precedingCodepoint=0:this._workCell.isCombined()?this._parser.precedingCodepoint=this._workCell.getChars().charCodeAt(0):this._parser.precedingCodepoint=this._workCell.content),this._activeBuffer.x0&&0===u.getWidth(this._activeBuffer.x)&&!u.hasContent(this._activeBuffer.x)&&u.setCellFromCodePoint(this._activeBuffer.x,0,1,d.fg,d.bg,d.extended),this._dirtyRowTracker.markDirty(this._activeBuffer.y)}registerCsiHandler(e,t){return"t"!==e.final||e.prefix||e.intermediates?this._parser.registerCsiHandler(e,t):this._parser.registerCsiHandler(e,(e=>!b(e.params[0],this._optionsService.rawOptions.windowOptions)||t(e)))}registerDcsHandler(e,t){return this._parser.registerDcsHandler(e,new g.DcsHandler(t))}registerEscHandler(e,t){return this._parser.registerEscHandler(e,t)}registerOscHandler(e,t){return this._parser.registerOscHandler(e,new p.OscHandler(t))}bell(){return this._onRequestBell.fire(),!0}lineFeed(){return this._dirtyRowTracker.markDirty(this._activeBuffer.y),this._optionsService.rawOptions.convertEol&&(this._activeBuffer.x=0),this._activeBuffer.y++,this._activeBuffer.y===this._activeBuffer.scrollBottom+1?(this._activeBuffer.y--,this._bufferService.scroll(this._eraseAttrData())):this._activeBuffer.y>=this._bufferService.rows?this._activeBuffer.y=this._bufferService.rows-1:this._activeBuffer.lines.get(this._activeBuffer.ybase+this._activeBuffer.y).isWrapped=!1,this._activeBuffer.x>=this._bufferService.cols&&this._activeBuffer.x--,this._dirtyRowTracker.markDirty(this._activeBuffer.y),this._onLineFeed.fire(),!0}carriageReturn(){return this._activeBuffer.x=0,!0}backspace(){var e;if(!this._coreService.decPrivateModes.reverseWraparound)return this._restrictCursor(),this._activeBuffer.x>0&&this._activeBuffer.x--,!0;if(this._restrictCursor(this._bufferService.cols),this._activeBuffer.x>0)this._activeBuffer.x--;else if(0===this._activeBuffer.x&&this._activeBuffer.y>this._activeBuffer.scrollTop&&this._activeBuffer.y<=this._activeBuffer.scrollBottom&&(null===(e=this._activeBuffer.lines.get(this._activeBuffer.ybase+this._activeBuffer.y))||void 0===e?void 0:e.isWrapped)){this._activeBuffer.lines.get(this._activeBuffer.ybase+this._activeBuffer.y).isWrapped=!1,this._activeBuffer.y--,this._activeBuffer.x=this._bufferService.cols-1;const e=this._activeBuffer.lines.get(this._activeBuffer.ybase+this._activeBuffer.y);e.hasWidth(this._activeBuffer.x)&&!e.hasContent(this._activeBuffer.x)&&this._activeBuffer.x--}return this._restrictCursor(),!0}tab(){if(this._activeBuffer.x>=this._bufferService.cols)return!0;const e=this._activeBuffer.x;return this._activeBuffer.x=this._activeBuffer.nextStop(),this._optionsService.rawOptions.screenReaderMode&&this._onA11yTab.fire(this._activeBuffer.x-e),!0}shiftOut(){return this._charsetService.setgLevel(1),!0}shiftIn(){return this._charsetService.setgLevel(0),!0}_restrictCursor(e=this._bufferService.cols-1){this._activeBuffer.x=Math.min(e,Math.max(0,this._activeBuffer.x)),this._activeBuffer.y=this._coreService.decPrivateModes.origin?Math.min(this._activeBuffer.scrollBottom,Math.max(this._activeBuffer.scrollTop,this._activeBuffer.y)):Math.min(this._bufferService.rows-1,Math.max(0,this._activeBuffer.y)),this._dirtyRowTracker.markDirty(this._activeBuffer.y)}_setCursor(e,t){this._dirtyRowTracker.markDirty(this._activeBuffer.y),this._coreService.decPrivateModes.origin?(this._activeBuffer.x=e,this._activeBuffer.y=this._activeBuffer.scrollTop+t):(this._activeBuffer.x=e,this._activeBuffer.y=t),this._restrictCursor(),this._dirtyRowTracker.markDirty(this._activeBuffer.y)}_moveCursor(e,t){this._restrictCursor(),this._setCursor(this._activeBuffer.x+e,this._activeBuffer.y+t)}cursorUp(e){const t=this._activeBuffer.y-this._activeBuffer.scrollTop;return t>=0?this._moveCursor(0,-Math.min(t,e.params[0]||1)):this._moveCursor(0,-(e.params[0]||1)),!0}cursorDown(e){const t=this._activeBuffer.scrollBottom-this._activeBuffer.y;return t>=0?this._moveCursor(0,Math.min(t,e.params[0]||1)):this._moveCursor(0,e.params[0]||1),!0}cursorForward(e){return this._moveCursor(e.params[0]||1,0),!0}cursorBackward(e){return this._moveCursor(-(e.params[0]||1),0),!0}cursorNextLine(e){return this.cursorDown(e),this._activeBuffer.x=0,!0}cursorPrecedingLine(e){return this.cursorUp(e),this._activeBuffer.x=0,!0}cursorCharAbsolute(e){return this._setCursor((e.params[0]||1)-1,this._activeBuffer.y),!0}cursorPosition(e){return this._setCursor(e.length>=2?(e.params[1]||1)-1:0,(e.params[0]||1)-1),!0}charPosAbsolute(e){return this._setCursor((e.params[0]||1)-1,this._activeBuffer.y),!0}hPositionRelative(e){return this._moveCursor(e.params[0]||1,0),!0}linePosAbsolute(e){return this._setCursor(this._activeBuffer.x,(e.params[0]||1)-1),!0}vPositionRelative(e){return this._moveCursor(0,e.params[0]||1),!0}hVPosition(e){return this.cursorPosition(e),!0}tabClear(e){const t=e.params[0];return 0===t?delete this._activeBuffer.tabs[this._activeBuffer.x]:3===t&&(this._activeBuffer.tabs={}),!0}cursorForwardTab(e){if(this._activeBuffer.x>=this._bufferService.cols)return!0;let t=e.params[0]||1;for(;t--;)this._activeBuffer.x=this._activeBuffer.nextStop();return!0}cursorBackwardTab(e){if(this._activeBuffer.x>=this._bufferService.cols)return!0;let t=e.params[0]||1;for(;t--;)this._activeBuffer.x=this._activeBuffer.prevStop();return!0}selectProtected(e){const t=e.params[0];return 1===t&&(this._curAttrData.bg|=536870912),2!==t&&0!==t||(this._curAttrData.bg&=-536870913),!0}_eraseInBufferLine(e,t,i,s=!1,r=!1){const n=this._activeBuffer.lines.get(this._activeBuffer.ybase+e);n.replaceCells(t,i,this._activeBuffer.getNullCell(this._eraseAttrData()),this._eraseAttrData(),r),s&&(n.isWrapped=!1)}_resetBufferLine(e,t=!1){const i=this._activeBuffer.lines.get(this._activeBuffer.ybase+e);i&&(i.fill(this._activeBuffer.getNullCell(this._eraseAttrData()),t),this._bufferService.buffer.clearMarkers(this._activeBuffer.ybase+e),i.isWrapped=!1)}eraseInDisplay(e,t=!1){let i;switch(this._restrictCursor(this._bufferService.cols),e.params[0]){case 0:for(i=this._activeBuffer.y,this._dirtyRowTracker.markDirty(i),this._eraseInBufferLine(i++,this._activeBuffer.x,this._bufferService.cols,0===this._activeBuffer.x,t);i=this._bufferService.cols&&(this._activeBuffer.lines.get(i+1).isWrapped=!1);i--;)this._resetBufferLine(i,t);this._dirtyRowTracker.markDirty(0);break;case 2:for(i=this._bufferService.rows,this._dirtyRowTracker.markDirty(i-1);i--;)this._resetBufferLine(i,t);this._dirtyRowTracker.markDirty(0);break;case 3:const e=this._activeBuffer.lines.length-this._bufferService.rows;e>0&&(this._activeBuffer.lines.trimStart(e),this._activeBuffer.ybase=Math.max(this._activeBuffer.ybase-e,0),this._activeBuffer.ydisp=Math.max(this._activeBuffer.ydisp-e,0),this._onScroll.fire(0))}return!0}eraseInLine(e,t=!1){switch(this._restrictCursor(this._bufferService.cols),e.params[0]){case 0:this._eraseInBufferLine(this._activeBuffer.y,this._activeBuffer.x,this._bufferService.cols,0===this._activeBuffer.x,t);break;case 1:this._eraseInBufferLine(this._activeBuffer.y,0,this._activeBuffer.x+1,!1,t);break;case 2:this._eraseInBufferLine(this._activeBuffer.y,0,this._bufferService.cols,!0,t)}return this._dirtyRowTracker.markDirty(this._activeBuffer.y),!0}insertLines(e){this._restrictCursor();let t=e.params[0]||1;if(this._activeBuffer.y>this._activeBuffer.scrollBottom||this._activeBuffer.ythis._activeBuffer.scrollBottom||this._activeBuffer.ythis._activeBuffer.scrollBottom||this._activeBuffer.ythis._activeBuffer.scrollBottom||this._activeBuffer.ythis._activeBuffer.scrollBottom||this._activeBuffer.ythis._activeBuffer.scrollBottom||this._activeBuffer.y0||(this._is("xterm")||this._is("rxvt-unicode")||this._is("screen")?this._coreService.triggerDataEvent(n.C0.ESC+"[?1;2c"):this._is("linux")&&this._coreService.triggerDataEvent(n.C0.ESC+"[?6c")),!0}sendDeviceAttributesSecondary(e){return e.params[0]>0||(this._is("xterm")?this._coreService.triggerDataEvent(n.C0.ESC+"[>0;276;0c"):this._is("rxvt-unicode")?this._coreService.triggerDataEvent(n.C0.ESC+"[>85;95;0c"):this._is("linux")?this._coreService.triggerDataEvent(e.params[0]+"c"):this._is("screen")&&this._coreService.triggerDataEvent(n.C0.ESC+"[>83;40003;0c")),!0}_is(e){return 0===(this._optionsService.rawOptions.termName+"").indexOf(e)}setMode(e){for(let t=0;te?1:2,u=e.params[0];return f=u,v=t?2===u?4:4===u?_(o.modes.insertMode):12===u?3:20===u?_(d.convertEol):0:1===u?_(i.applicationCursorKeys):3===u?d.windowOptions.setWinLines?80===h?2:132===h?1:0:0:6===u?_(i.origin):7===u?_(i.wraparound):8===u?3:9===u?_("X10"===s):12===u?_(d.cursorBlink):25===u?_(!o.isCursorHidden):45===u?_(i.reverseWraparound):66===u?_(i.applicationKeypad):67===u?4:1e3===u?_("VT200"===s):1002===u?_("DRAG"===s):1003===u?_("ANY"===s):1004===u?_(i.sendFocus):1005===u?4:1006===u?_("SGR"===r):1015===u?4:1016===u?_("SGR_PIXELS"===r):1048===u?1:47===u||1047===u||1049===u?_(c===l):2004===u?_(i.bracketedPasteMode):0,o.triggerDataEvent(`${n.C0.ESC}[${t?"":"?"}${f};${v}$y`),!0;var f,v}_updateAttrColor(e,t,i,s,r){return 2===t?(e|=50331648,e&=-16777216,e|=f.AttributeData.fromColorRGB([i,s,r])):5===t&&(e&=-50331904,e|=33554432|255&i),e}_extractColor(e,t,i){const s=[0,0,-1,0,0,0];let r=0,n=0;do{if(s[n+r]=e.params[t+n],e.hasSubParams(t+n)){const i=e.getSubParams(t+n);let o=0;do{5===s[1]&&(r=1),s[n+o+1+r]=i[o]}while(++o=2||2===s[1]&&n+r>=5)break;s[1]&&(r=1)}while(++n+t5)&&(e=1),t.extended.underlineStyle=e,t.fg|=268435456,0===e&&(t.fg&=-268435457),t.updateExtended()}_processSGR0(e){e.fg=l.DEFAULT_ATTR_DATA.fg,e.bg=l.DEFAULT_ATTR_DATA.bg,e.extended=e.extended.clone(),e.extended.underlineStyle=0,e.extended.underlineColor&=-67108864,e.updateExtended()}charAttributes(e){if(1===e.length&&0===e.params[0])return this._processSGR0(this._curAttrData),!0;const t=e.length;let i;const s=this._curAttrData;for(let r=0;r=30&&i<=37?(s.fg&=-50331904,s.fg|=16777216|i-30):i>=40&&i<=47?(s.bg&=-50331904,s.bg|=16777216|i-40):i>=90&&i<=97?(s.fg&=-50331904,s.fg|=16777224|i-90):i>=100&&i<=107?(s.bg&=-50331904,s.bg|=16777224|i-100):0===i?this._processSGR0(s):1===i?s.fg|=134217728:3===i?s.bg|=67108864:4===i?(s.fg|=268435456,this._processUnderline(e.hasSubParams(r)?e.getSubParams(r)[0]:1,s)):5===i?s.fg|=536870912:7===i?s.fg|=67108864:8===i?s.fg|=1073741824:9===i?s.fg|=2147483648:2===i?s.bg|=134217728:21===i?this._processUnderline(2,s):22===i?(s.fg&=-134217729,s.bg&=-134217729):23===i?s.bg&=-67108865:24===i?(s.fg&=-268435457,this._processUnderline(0,s)):25===i?s.fg&=-536870913:27===i?s.fg&=-67108865:28===i?s.fg&=-1073741825:29===i?s.fg&=2147483647:39===i?(s.fg&=-67108864,s.fg|=16777215&l.DEFAULT_ATTR_DATA.fg):49===i?(s.bg&=-67108864,s.bg|=16777215&l.DEFAULT_ATTR_DATA.bg):38===i||48===i||58===i?r+=this._extractColor(e,r,s):53===i?s.bg|=1073741824:55===i?s.bg&=-1073741825:59===i?(s.extended=s.extended.clone(),s.extended.underlineColor=-1,s.updateExtended()):100===i?(s.fg&=-67108864,s.fg|=16777215&l.DEFAULT_ATTR_DATA.fg,s.bg&=-67108864,s.bg|=16777215&l.DEFAULT_ATTR_DATA.bg):this._logService.debug("Unknown SGR attribute: %d.",i);return!0}deviceStatus(e){switch(e.params[0]){case 5:this._coreService.triggerDataEvent(`${n.C0.ESC}[0n`);break;case 6:const e=this._activeBuffer.y+1,t=this._activeBuffer.x+1;this._coreService.triggerDataEvent(`${n.C0.ESC}[${e};${t}R`)}return!0}deviceStatusPrivate(e){if(6===e.params[0]){const e=this._activeBuffer.y+1,t=this._activeBuffer.x+1;this._coreService.triggerDataEvent(`${n.C0.ESC}[?${e};${t}R`)}return!0}softReset(e){return this._coreService.isCursorHidden=!1,this._onRequestSyncScrollBar.fire(),this._activeBuffer.scrollTop=0,this._activeBuffer.scrollBottom=this._bufferService.rows-1,this._curAttrData=l.DEFAULT_ATTR_DATA.clone(),this._coreService.reset(),this._charsetService.reset(),this._activeBuffer.savedX=0,this._activeBuffer.savedY=this._activeBuffer.ybase,this._activeBuffer.savedCurAttrData.fg=this._curAttrData.fg,this._activeBuffer.savedCurAttrData.bg=this._curAttrData.bg,this._activeBuffer.savedCharset=this._charsetService.charset,this._coreService.decPrivateModes.origin=!1,!0}setCursorStyle(e){const t=e.params[0]||1;switch(t){case 1:case 2:this._optionsService.options.cursorStyle="block";break;case 3:case 4:this._optionsService.options.cursorStyle="underline";break;case 5:case 6:this._optionsService.options.cursorStyle="bar"}const i=t%2==1;return this._optionsService.options.cursorBlink=i,!0}setScrollRegion(e){const t=e.params[0]||1;let i;return(e.length<2||(i=e.params[1])>this._bufferService.rows||0===i)&&(i=this._bufferService.rows),i>t&&(this._activeBuffer.scrollTop=t-1,this._activeBuffer.scrollBottom=i-1,this._setCursor(0,0)),!0}windowOptions(e){if(!b(e.params[0],this._optionsService.rawOptions.windowOptions))return!0;const t=e.length>1?e.params[1]:0;switch(e.params[0]){case 14:2!==t&&this._onRequestWindowsOptionsReport.fire(y.GET_WIN_SIZE_PIXELS);break;case 16:this._onRequestWindowsOptionsReport.fire(y.GET_CELL_SIZE_PIXELS);break;case 18:this._bufferService&&this._coreService.triggerDataEvent(`${n.C0.ESC}[8;${this._bufferService.rows};${this._bufferService.cols}t`);break;case 22:0!==t&&2!==t||(this._windowTitleStack.push(this._windowTitle),this._windowTitleStack.length>10&&this._windowTitleStack.shift()),0!==t&&1!==t||(this._iconNameStack.push(this._iconName),this._iconNameStack.length>10&&this._iconNameStack.shift());break;case 23:0!==t&&2!==t||this._windowTitleStack.length&&this.setTitle(this._windowTitleStack.pop()),0!==t&&1!==t||this._iconNameStack.length&&this.setIconName(this._iconNameStack.pop())}return!0}saveCursor(e){return this._activeBuffer.savedX=this._activeBuffer.x,this._activeBuffer.savedY=this._activeBuffer.ybase+this._activeBuffer.y,this._activeBuffer.savedCurAttrData.fg=this._curAttrData.fg,this._activeBuffer.savedCurAttrData.bg=this._curAttrData.bg,this._activeBuffer.savedCharset=this._charsetService.charset,!0}restoreCursor(e){return this._activeBuffer.x=this._activeBuffer.savedX||0,this._activeBuffer.y=Math.max(this._activeBuffer.savedY-this._activeBuffer.ybase,0),this._curAttrData.fg=this._activeBuffer.savedCurAttrData.fg,this._curAttrData.bg=this._activeBuffer.savedCurAttrData.bg,this._charsetService.charset=this._savedCharset,this._activeBuffer.savedCharset&&(this._charsetService.charset=this._activeBuffer.savedCharset),this._restrictCursor(),!0}setTitle(e){return this._windowTitle=e,this._onTitleChange.fire(e),!0}setIconName(e){return this._iconName=e,!0}setOrReportIndexedColor(e){const t=[],i=e.split(";");for(;i.length>1;){const e=i.shift(),s=i.shift();if(/^\d+$/.exec(e)){const i=parseInt(e);if(L(i))if("?"===s)t.push({type:0,index:i});else{const e=(0,m.parseColor)(s);e&&t.push({type:1,index:i,color:e})}}}return t.length&&this._onColor.fire(t),!0}setHyperlink(e){const t=e.split(";");return!(t.length<2)&&(t[1]?this._createHyperlink(t[0],t[1]):!t[0]&&this._finishHyperlink())}_createHyperlink(e,t){this._getCurrentLinkId()&&this._finishHyperlink();const i=e.split(":");let s;const r=i.findIndex((e=>e.startsWith("id=")));return-1!==r&&(s=i[r].slice(3)||void 0),this._curAttrData.extended=this._curAttrData.extended.clone(),this._curAttrData.extended.urlId=this._oscLinkService.registerLink({id:s,uri:t}),this._curAttrData.updateExtended(),!0}_finishHyperlink(){return this._curAttrData.extended=this._curAttrData.extended.clone(),this._curAttrData.extended.urlId=0,this._curAttrData.updateExtended(),!0}_setOrReportSpecialColor(e,t){const i=e.split(";");for(let e=0;e=this._specialColors.length);++e,++t)if("?"===i[e])this._onColor.fire([{type:0,index:this._specialColors[t]}]);else{const s=(0,m.parseColor)(i[e]);s&&this._onColor.fire([{type:1,index:this._specialColors[t],color:s}])}return!0}setOrReportFgColor(e){return this._setOrReportSpecialColor(e,0)}setOrReportBgColor(e){return this._setOrReportSpecialColor(e,1)}setOrReportCursorColor(e){return this._setOrReportSpecialColor(e,2)}restoreIndexedColor(e){if(!e)return this._onColor.fire([{type:2}]),!0;const t=[],i=e.split(";");for(let e=0;e=this._bufferService.rows&&(this._activeBuffer.y=this._bufferService.rows-1),this._restrictCursor(),!0}tabSet(){return this._activeBuffer.tabs[this._activeBuffer.x]=!0,!0}reverseIndex(){if(this._restrictCursor(),this._activeBuffer.y===this._activeBuffer.scrollTop){const e=this._activeBuffer.scrollBottom-this._activeBuffer.scrollTop;this._activeBuffer.lines.shiftElements(this._activeBuffer.ybase+this._activeBuffer.y,e,1),this._activeBuffer.lines.set(this._activeBuffer.ybase+this._activeBuffer.y,this._activeBuffer.getBlankLine(this._eraseAttrData())),this._dirtyRowTracker.markRangeDirty(this._activeBuffer.scrollTop,this._activeBuffer.scrollBottom)}else this._activeBuffer.y--,this._restrictCursor();return!0}fullReset(){return this._parser.reset(),this._onRequestReset.fire(),!0}reset(){this._curAttrData=l.DEFAULT_ATTR_DATA.clone(),this._eraseAttrDataInternal=l.DEFAULT_ATTR_DATA.clone()}_eraseAttrData(){return this._eraseAttrDataInternal.bg&=-67108864,this._eraseAttrDataInternal.bg|=67108863&this._curAttrData.bg,this._eraseAttrDataInternal}setgLevel(e){return this._charsetService.setgLevel(e),!0}screenAlignmentPattern(){const e=new u.CellData;e.content=1<<22|"E".charCodeAt(0),e.fg=this._curAttrData.fg,e.bg=this._curAttrData.bg,this._setCursor(0,0);for(let t=0;t(this._coreService.triggerDataEvent(`${n.C0.ESC}${e}${n.C0.ESC}\\`),!0))('"q'===e?`P1$r${this._curAttrData.isProtected()?1:0}"q`:'"p'===e?'P1$r61;1"p':"r"===e?`P1$r${i.scrollTop+1};${i.scrollBottom+1}r`:"m"===e?"P1$r0m":" q"===e?`P1$r${{block:2,underline:4,bar:6}[s.cursorStyle]-(s.cursorBlink?1:0)} q`:"P0$r")}markRangeDirty(e,t){this._dirtyRowTracker.markRangeDirty(e,t)}}t.InputHandler=E;let k=class{constructor(e){this._bufferService=e,this.clearRange()}clearRange(){this.start=this._bufferService.buffer.y,this.end=this._bufferService.buffer.y}markDirty(e){ethis.end&&(this.end=e)}markRangeDirty(e,t){e>t&&(w=e,e=t,t=w),ethis.end&&(this.end=t)}markAllDirty(){this.markRangeDirty(0,this._bufferService.rows-1)}};function L(e){return 0<=e&&e<256}k=s([r(0,v.IBufferService)],k)},844:(e,t)=>{function i(e){for(const t of e)t.dispose();e.length=0}Object.defineProperty(t,"__esModule",{value:!0}),t.getDisposeArrayDisposable=t.disposeArray=t.toDisposable=t.MutableDisposable=t.Disposable=void 0,t.Disposable=class{constructor(){this._disposables=[],this._isDisposed=!1}dispose(){this._isDisposed=!0;for(const e of this._disposables)e.dispose();this._disposables.length=0}register(e){return this._disposables.push(e),e}unregister(e){const t=this._disposables.indexOf(e);-1!==t&&this._disposables.splice(t,1)}},t.MutableDisposable=class{constructor(){this._isDisposed=!1}get value(){return this._isDisposed?void 0:this._value}set value(e){var t;this._isDisposed||e===this._value||(null===(t=this._value)||void 0===t||t.dispose(),this._value=e)}clear(){this.value=void 0}dispose(){var e;this._isDisposed=!0,null===(e=this._value)||void 0===e||e.dispose(),this._value=void 0}},t.toDisposable=function(e){return{dispose:e}},t.disposeArray=i,t.getDisposeArrayDisposable=function(e){return{dispose:()=>i(e)}}},1505:(e,t)=>{Object.defineProperty(t,"__esModule",{value:!0}),t.FourKeyMap=t.TwoKeyMap=void 0;class i{constructor(){this._data={}}set(e,t,i){this._data[e]||(this._data[e]={}),this._data[e][t]=i}get(e,t){return this._data[e]?this._data[e][t]:void 0}clear(){this._data={}}}t.TwoKeyMap=i,t.FourKeyMap=class{constructor(){this._data=new i}set(e,t,s,r,n){this._data.get(e,t)||this._data.set(e,t,new i),this._data.get(e,t).set(s,r,n)}get(e,t,i,s){var r;return null===(r=this._data.get(e,t))||void 0===r?void 0:r.get(i,s)}clear(){this._data.clear()}}},6114:(e,t)=>{Object.defineProperty(t,"__esModule",{value:!0}),t.isChromeOS=t.isLinux=t.isWindows=t.isIphone=t.isIpad=t.isMac=t.getSafariVersion=t.isSafari=t.isLegacyEdge=t.isFirefox=t.isNode=void 0,t.isNode="undefined"==typeof navigator;const i=t.isNode?"node":navigator.userAgent,s=t.isNode?"node":navigator.platform;t.isFirefox=i.includes("Firefox"),t.isLegacyEdge=i.includes("Edge"),t.isSafari=/^((?!chrome|android).)*safari/i.test(i),t.getSafariVersion=function(){if(!t.isSafari)return 0;const e=i.match(/Version\/(\d+)/);return null===e||e.length<2?0:parseInt(e[1])},t.isMac=["Macintosh","MacIntel","MacPPC","Mac68K"].includes(s),t.isIpad="iPad"===s,t.isIphone="iPhone"===s,t.isWindows=["Windows","Win16","Win32","WinCE"].includes(s),t.isLinux=s.indexOf("Linux")>=0,t.isChromeOS=/\bCrOS\b/.test(i)},6106:(e,t)=>{Object.defineProperty(t,"__esModule",{value:!0}),t.SortedList=void 0;let i=0;t.SortedList=class{constructor(e){this._getKey=e,this._array=[]}clear(){this._array.length=0}insert(e){0!==this._array.length?(i=this._search(this._getKey(e)),this._array.splice(i,0,e)):this._array.push(e)}delete(e){if(0===this._array.length)return!1;const t=this._getKey(e);if(void 0===t)return!1;if(i=this._search(t),-1===i)return!1;if(this._getKey(this._array[i])!==t)return!1;do{if(this._array[i]===e)return this._array.splice(i,1),!0}while(++i=this._array.length)&&this._getKey(this._array[i])===e))do{yield this._array[i]}while(++i=this._array.length)&&this._getKey(this._array[i])===e))do{t(this._array[i])}while(++i=t;){let s=t+i>>1;const r=this._getKey(this._array[s]);if(r>e)i=s-1;else{if(!(r0&&this._getKey(this._array[s-1])===e;)s--;return s}t=s+1}}return t}}},7226:(e,t,i)=>{Object.defineProperty(t,"__esModule",{value:!0}),t.DebouncedIdleTask=t.IdleTaskQueue=t.PriorityTaskQueue=void 0;const s=i(6114);class r{constructor(){this._tasks=[],this._i=0}enqueue(e){this._tasks.push(e),this._start()}flush(){for(;this._ir)return s-t<-20&&console.warn(`task queue exceeded allotted deadline by ${Math.abs(Math.round(s-t))}ms`),void this._start();s=r}this.clear()}}class n extends r{_requestCallback(e){return setTimeout((()=>e(this._createDeadline(16))))}_cancelCallback(e){clearTimeout(e)}_createDeadline(e){const t=Date.now()+e;return{timeRemaining:()=>Math.max(0,t-Date.now())}}}t.PriorityTaskQueue=n,t.IdleTaskQueue=!s.isNode&&"requestIdleCallback"in window?class extends r{_requestCallback(e){return requestIdleCallback(e)}_cancelCallback(e){cancelIdleCallback(e)}}:n,t.DebouncedIdleTask=class{constructor(){this._queue=new t.IdleTaskQueue}set(e){this._queue.clear(),this._queue.enqueue(e)}flush(){this._queue.flush()}}},9282:(e,t,i)=>{Object.defineProperty(t,"__esModule",{value:!0}),t.updateWindowsModeWrappedState=void 0;const s=i(643);t.updateWindowsModeWrappedState=function(e){const t=e.buffer.lines.get(e.buffer.ybase+e.buffer.y-1),i=null==t?void 0:t.get(e.cols-1),r=e.buffer.lines.get(e.buffer.ybase+e.buffer.y);r&&i&&(r.isWrapped=i[s.CHAR_DATA_CODE_INDEX]!==s.NULL_CELL_CODE&&i[s.CHAR_DATA_CODE_INDEX]!==s.WHITESPACE_CELL_CODE)}},3734:(e,t)=>{Object.defineProperty(t,"__esModule",{value:!0}),t.ExtendedAttrs=t.AttributeData=void 0;class i{constructor(){this.fg=0,this.bg=0,this.extended=new s}static toColorRGB(e){return[e>>>16&255,e>>>8&255,255&e]}static fromColorRGB(e){return(255&e[0])<<16|(255&e[1])<<8|255&e[2]}clone(){const e=new i;return e.fg=this.fg,e.bg=this.bg,e.extended=this.extended.clone(),e}isInverse(){return 67108864&this.fg}isBold(){return 134217728&this.fg}isUnderline(){return this.hasExtendedAttrs()&&0!==this.extended.underlineStyle?1:268435456&this.fg}isBlink(){return 536870912&this.fg}isInvisible(){return 1073741824&this.fg}isItalic(){return 67108864&this.bg}isDim(){return 134217728&this.bg}isStrikethrough(){return 2147483648&this.fg}isProtected(){return 536870912&this.bg}isOverline(){return 1073741824&this.bg}getFgColorMode(){return 50331648&this.fg}getBgColorMode(){return 50331648&this.bg}isFgRGB(){return 50331648==(50331648&this.fg)}isBgRGB(){return 50331648==(50331648&this.bg)}isFgPalette(){return 16777216==(50331648&this.fg)||33554432==(50331648&this.fg)}isBgPalette(){return 16777216==(50331648&this.bg)||33554432==(50331648&this.bg)}isFgDefault(){return 0==(50331648&this.fg)}isBgDefault(){return 0==(50331648&this.bg)}isAttributeDefault(){return 0===this.fg&&0===this.bg}getFgColor(){switch(50331648&this.fg){case 16777216:case 33554432:return 255&this.fg;case 50331648:return 16777215&this.fg;default:return-1}}getBgColor(){switch(50331648&this.bg){case 16777216:case 33554432:return 255&this.bg;case 50331648:return 16777215&this.bg;default:return-1}}hasExtendedAttrs(){return 268435456&this.bg}updateExtended(){this.extended.isEmpty()?this.bg&=-268435457:this.bg|=268435456}getUnderlineColor(){if(268435456&this.bg&&~this.extended.underlineColor)switch(50331648&this.extended.underlineColor){case 16777216:case 33554432:return 255&this.extended.underlineColor;case 50331648:return 16777215&this.extended.underlineColor;default:return this.getFgColor()}return this.getFgColor()}getUnderlineColorMode(){return 268435456&this.bg&&~this.extended.underlineColor?50331648&this.extended.underlineColor:this.getFgColorMode()}isUnderlineColorRGB(){return 268435456&this.bg&&~this.extended.underlineColor?50331648==(50331648&this.extended.underlineColor):this.isFgRGB()}isUnderlineColorPalette(){return 268435456&this.bg&&~this.extended.underlineColor?16777216==(50331648&this.extended.underlineColor)||33554432==(50331648&this.extended.underlineColor):this.isFgPalette()}isUnderlineColorDefault(){return 268435456&this.bg&&~this.extended.underlineColor?0==(50331648&this.extended.underlineColor):this.isFgDefault()}getUnderlineStyle(){return 268435456&this.fg?268435456&this.bg?this.extended.underlineStyle:1:0}}t.AttributeData=i;class s{get ext(){return this._urlId?-469762049&this._ext|this.underlineStyle<<26:this._ext}set ext(e){this._ext=e}get underlineStyle(){return this._urlId?5:(469762048&this._ext)>>26}set underlineStyle(e){this._ext&=-469762049,this._ext|=e<<26&469762048}get underlineColor(){return 67108863&this._ext}set underlineColor(e){this._ext&=-67108864,this._ext|=67108863&e}get urlId(){return this._urlId}set urlId(e){this._urlId=e}constructor(e=0,t=0){this._ext=0,this._urlId=0,this._ext=e,this._urlId=t}clone(){return new s(this._ext,this._urlId)}isEmpty(){return 0===this.underlineStyle&&0===this._urlId}}t.ExtendedAttrs=s},9092:(e,t,i)=>{Object.defineProperty(t,"__esModule",{value:!0}),t.Buffer=t.MAX_BUFFER_SIZE=void 0;const s=i(6349),r=i(7226),n=i(3734),o=i(8437),a=i(4634),h=i(511),c=i(643),l=i(4863),d=i(7116);t.MAX_BUFFER_SIZE=4294967295,t.Buffer=class{constructor(e,t,i){this._hasScrollback=e,this._optionsService=t,this._bufferService=i,this.ydisp=0,this.ybase=0,this.y=0,this.x=0,this.tabs={},this.savedY=0,this.savedX=0,this.savedCurAttrData=o.DEFAULT_ATTR_DATA.clone(),this.savedCharset=d.DEFAULT_CHARSET,this.markers=[],this._nullCell=h.CellData.fromCharData([0,c.NULL_CELL_CHAR,c.NULL_CELL_WIDTH,c.NULL_CELL_CODE]),this._whitespaceCell=h.CellData.fromCharData([0,c.WHITESPACE_CELL_CHAR,c.WHITESPACE_CELL_WIDTH,c.WHITESPACE_CELL_CODE]),this._isClearing=!1,this._memoryCleanupQueue=new r.IdleTaskQueue,this._memoryCleanupPosition=0,this._cols=this._bufferService.cols,this._rows=this._bufferService.rows,this.lines=new s.CircularList(this._getCorrectBufferLength(this._rows)),this.scrollTop=0,this.scrollBottom=this._rows-1,this.setupTabStops()}getNullCell(e){return e?(this._nullCell.fg=e.fg,this._nullCell.bg=e.bg,this._nullCell.extended=e.extended):(this._nullCell.fg=0,this._nullCell.bg=0,this._nullCell.extended=new n.ExtendedAttrs),this._nullCell}getWhitespaceCell(e){return e?(this._whitespaceCell.fg=e.fg,this._whitespaceCell.bg=e.bg,this._whitespaceCell.extended=e.extended):(this._whitespaceCell.fg=0,this._whitespaceCell.bg=0,this._whitespaceCell.extended=new n.ExtendedAttrs),this._whitespaceCell}getBlankLine(e,t){return new o.BufferLine(this._bufferService.cols,this.getNullCell(e),t)}get hasScrollback(){return this._hasScrollback&&this.lines.maxLength>this._rows}get isCursorInViewport(){const e=this.ybase+this.y-this.ydisp;return e>=0&&et.MAX_BUFFER_SIZE?t.MAX_BUFFER_SIZE:i}fillViewportRows(e){if(0===this.lines.length){void 0===e&&(e=o.DEFAULT_ATTR_DATA);let t=this._rows;for(;t--;)this.lines.push(this.getBlankLine(e))}}clear(){this.ydisp=0,this.ybase=0,this.y=0,this.x=0,this.lines=new s.CircularList(this._getCorrectBufferLength(this._rows)),this.scrollTop=0,this.scrollBottom=this._rows-1,this.setupTabStops()}resize(e,t){const i=this.getNullCell(o.DEFAULT_ATTR_DATA);let s=0;const r=this._getCorrectBufferLength(t);if(r>this.lines.maxLength&&(this.lines.maxLength=r),this.lines.length>0){if(this._cols0&&this.lines.length<=this.ybase+this.y+n+1?(this.ybase--,n++,this.ydisp>0&&this.ydisp--):this.lines.push(new o.BufferLine(e,i)));else for(let e=this._rows;e>t;e--)this.lines.length>t+this.ybase&&(this.lines.length>this.ybase+this.y+1?this.lines.pop():(this.ybase++,this.ydisp++));if(r0&&(this.lines.trimStart(e),this.ybase=Math.max(this.ybase-e,0),this.ydisp=Math.max(this.ydisp-e,0),this.savedY=Math.max(this.savedY-e,0)),this.lines.maxLength=r}this.x=Math.min(this.x,e-1),this.y=Math.min(this.y,t-1),n&&(this.y+=n),this.savedX=Math.min(this.savedX,e-1),this.scrollTop=0}if(this.scrollBottom=t-1,this._isReflowEnabled&&(this._reflow(e,t),this._cols>e))for(let t=0;t.1*this.lines.length&&(this._memoryCleanupPosition=0,this._memoryCleanupQueue.enqueue((()=>this._batchedMemoryCleanup())))}_batchedMemoryCleanup(){let e=!0;this._memoryCleanupPosition>=this.lines.length&&(this._memoryCleanupPosition=0,e=!1);let t=0;for(;this._memoryCleanupPosition100)return!0;return e}get _isReflowEnabled(){const e=this._optionsService.rawOptions.windowsPty;return e&&e.buildNumber?this._hasScrollback&&"conpty"===e.backend&&e.buildNumber>=21376:this._hasScrollback&&!this._optionsService.rawOptions.windowsMode}_reflow(e,t){this._cols!==e&&(e>this._cols?this._reflowLarger(e,t):this._reflowSmaller(e,t))}_reflowLarger(e,t){const i=(0,a.reflowLargerGetLinesToRemove)(this.lines,this._cols,e,this.ybase+this.y,this.getNullCell(o.DEFAULT_ATTR_DATA));if(i.length>0){const s=(0,a.reflowLargerCreateNewLayout)(this.lines,i);(0,a.reflowLargerApplyNewLayout)(this.lines,s.layout),this._reflowLargerAdjustViewport(e,t,s.countRemoved)}}_reflowLargerAdjustViewport(e,t,i){const s=this.getNullCell(o.DEFAULT_ATTR_DATA);let r=i;for(;r-- >0;)0===this.ybase?(this.y>0&&this.y--,this.lines.length=0;n--){let h=this.lines.get(n);if(!h||!h.isWrapped&&h.getTrimmedLength()<=e)continue;const c=[h];for(;h.isWrapped&&n>0;)h=this.lines.get(--n),c.unshift(h);const l=this.ybase+this.y;if(l>=n&&l0&&(s.push({start:n+c.length+r,newLines:v}),r+=v.length),c.push(...v);let p=_.length-1,g=_[p];0===g&&(p--,g=_[p]);let m=c.length-u-1,S=d;for(;m>=0;){const e=Math.min(S,g);if(void 0===c[p])break;if(c[p].copyCellsFrom(c[m],S-e,g-e,e,!0),g-=e,0===g&&(p--,g=_[p]),S-=e,0===S){m--;const e=Math.max(m,0);S=(0,a.getWrappedLineTrimmedLength)(c,e,this._cols)}}for(let t=0;t0;)0===this.ybase?this.y0){const e=[],t=[];for(let e=0;e=0;c--)if(a&&a.start>n+h){for(let e=a.newLines.length-1;e>=0;e--)this.lines.set(c--,a.newLines[e]);c++,e.push({index:n+1,amount:a.newLines.length}),h+=a.newLines.length,a=s[++o]}else this.lines.set(c,t[n--]);let c=0;for(let t=e.length-1;t>=0;t--)e[t].index+=c,this.lines.onInsertEmitter.fire(e[t]),c+=e[t].amount;const l=Math.max(0,i+r-this.lines.maxLength);l>0&&this.lines.onTrimEmitter.fire(l)}}translateBufferLineToString(e,t,i=0,s){const r=this.lines.get(e);return r?r.translateToString(t,i,s):""}getWrappedRangeForLine(e){let t=e,i=e;for(;t>0&&this.lines.get(t).isWrapped;)t--;for(;i+10;);return e>=this._cols?this._cols-1:e<0?0:e}nextStop(e){for(null==e&&(e=this.x);!this.tabs[++e]&&e=this._cols?this._cols-1:e<0?0:e}clearMarkers(e){this._isClearing=!0;for(let t=0;t{t.line-=e,t.line<0&&t.dispose()}))),t.register(this.lines.onInsert((e=>{t.line>=e.index&&(t.line+=e.amount)}))),t.register(this.lines.onDelete((e=>{t.line>=e.index&&t.linee.index&&(t.line-=e.amount)}))),t.register(t.onDispose((()=>this._removeMarker(t)))),t}_removeMarker(e){this._isClearing||this.markers.splice(this.markers.indexOf(e),1)}}},8437:(e,t,i)=>{Object.defineProperty(t,"__esModule",{value:!0}),t.BufferLine=t.DEFAULT_ATTR_DATA=void 0;const s=i(3734),r=i(511),n=i(643),o=i(482);t.DEFAULT_ATTR_DATA=Object.freeze(new s.AttributeData);let a=0;class h{constructor(e,t,i=!1){this.isWrapped=i,this._combined={},this._extendedAttrs={},this._data=new Uint32Array(3*e);const s=t||r.CellData.fromCharData([0,n.NULL_CELL_CHAR,n.NULL_CELL_WIDTH,n.NULL_CELL_CODE]);for(let t=0;t>22,2097152&t?this._combined[e].charCodeAt(this._combined[e].length-1):i]}set(e,t){this._data[3*e+1]=t[n.CHAR_DATA_ATTR_INDEX],t[n.CHAR_DATA_CHAR_INDEX].length>1?(this._combined[e]=t[1],this._data[3*e+0]=2097152|e|t[n.CHAR_DATA_WIDTH_INDEX]<<22):this._data[3*e+0]=t[n.CHAR_DATA_CHAR_INDEX].charCodeAt(0)|t[n.CHAR_DATA_WIDTH_INDEX]<<22}getWidth(e){return this._data[3*e+0]>>22}hasWidth(e){return 12582912&this._data[3*e+0]}getFg(e){return this._data[3*e+1]}getBg(e){return this._data[3*e+2]}hasContent(e){return 4194303&this._data[3*e+0]}getCodePoint(e){const t=this._data[3*e+0];return 2097152&t?this._combined[e].charCodeAt(this._combined[e].length-1):2097151&t}isCombined(e){return 2097152&this._data[3*e+0]}getString(e){const t=this._data[3*e+0];return 2097152&t?this._combined[e]:2097151&t?(0,o.stringFromCodePoint)(2097151&t):""}isProtected(e){return 536870912&this._data[3*e+2]}loadCell(e,t){return a=3*e,t.content=this._data[a+0],t.fg=this._data[a+1],t.bg=this._data[a+2],2097152&t.content&&(t.combinedData=this._combined[e]),268435456&t.bg&&(t.extended=this._extendedAttrs[e]),t}setCell(e,t){2097152&t.content&&(this._combined[e]=t.combinedData),268435456&t.bg&&(this._extendedAttrs[e]=t.extended),this._data[3*e+0]=t.content,this._data[3*e+1]=t.fg,this._data[3*e+2]=t.bg}setCellFromCodePoint(e,t,i,s,r,n){268435456&r&&(this._extendedAttrs[e]=n),this._data[3*e+0]=t|i<<22,this._data[3*e+1]=s,this._data[3*e+2]=r}addCodepointToCell(e,t){let i=this._data[3*e+0];2097152&i?this._combined[e]+=(0,o.stringFromCodePoint)(t):(2097151&i?(this._combined[e]=(0,o.stringFromCodePoint)(2097151&i)+(0,o.stringFromCodePoint)(t),i&=-2097152,i|=2097152):i=t|1<<22,this._data[3*e+0]=i)}insertCells(e,t,i,n){if((e%=this.length)&&2===this.getWidth(e-1)&&this.setCellFromCodePoint(e-1,0,1,(null==n?void 0:n.fg)||0,(null==n?void 0:n.bg)||0,(null==n?void 0:n.extended)||new s.ExtendedAttrs),t=0;--i)this.setCell(e+t+i,this.loadCell(e+i,s));for(let s=0;sthis.length){if(this._data.buffer.byteLength>=4*i)this._data=new Uint32Array(this._data.buffer,0,i);else{const e=new Uint32Array(i);e.set(this._data),this._data=e}for(let i=this.length;i=e&&delete this._combined[s]}const s=Object.keys(this._extendedAttrs);for(let t=0;t=e&&delete this._extendedAttrs[i]}}return this.length=e,4*i*2=0;--e)if(4194303&this._data[3*e+0])return e+(this._data[3*e+0]>>22);return 0}getNoBgTrimmedLength(){for(let e=this.length-1;e>=0;--e)if(4194303&this._data[3*e+0]||50331648&this._data[3*e+2])return e+(this._data[3*e+0]>>22);return 0}copyCellsFrom(e,t,i,s,r){const n=e._data;if(r)for(let r=s-1;r>=0;r--){for(let e=0;e<3;e++)this._data[3*(i+r)+e]=n[3*(t+r)+e];268435456&n[3*(t+r)+2]&&(this._extendedAttrs[i+r]=e._extendedAttrs[t+r])}else for(let r=0;r=t&&(this._combined[r-t+i]=e._combined[r])}}translateToString(e=!1,t=0,i=this.length){e&&(i=Math.min(i,this.getTrimmedLength()));let s="";for(;t>22||1}return s}}t.BufferLine=h},4841:(e,t)=>{Object.defineProperty(t,"__esModule",{value:!0}),t.getRangeLength=void 0,t.getRangeLength=function(e,t){if(e.start.y>e.end.y)throw new Error(`Buffer range end (${e.end.x}, ${e.end.y}) cannot be before start (${e.start.x}, ${e.start.y})`);return t*(e.end.y-e.start.y)+(e.end.x-e.start.x+1)}},4634:(e,t)=>{function i(e,t,i){if(t===e.length-1)return e[t].getTrimmedLength();const s=!e[t].hasContent(i-1)&&1===e[t].getWidth(i-1),r=2===e[t+1].getWidth(0);return s&&r?i-1:i}Object.defineProperty(t,"__esModule",{value:!0}),t.getWrappedLineTrimmedLength=t.reflowSmallerGetNewLineLengths=t.reflowLargerApplyNewLayout=t.reflowLargerCreateNewLayout=t.reflowLargerGetLinesToRemove=void 0,t.reflowLargerGetLinesToRemove=function(e,t,s,r,n){const o=[];for(let a=0;a=a&&r0&&(e>d||0===l[e].getTrimmedLength());e--)v++;v>0&&(o.push(a+l.length-v),o.push(v)),a+=l.length-1}return o},t.reflowLargerCreateNewLayout=function(e,t){const i=[];let s=0,r=t[s],n=0;for(let o=0;oi(e,r,t))).reduce(((e,t)=>e+t));let o=0,a=0,h=0;for(;hc&&(o-=c,a++);const l=2===e[a].getWidth(o-1);l&&o--;const d=l?s-1:s;r.push(d),h+=d}return r},t.getWrappedLineTrimmedLength=i},5295:(e,t,i)=>{Object.defineProperty(t,"__esModule",{value:!0}),t.BufferSet=void 0;const s=i(8460),r=i(844),n=i(9092);class o extends r.Disposable{constructor(e,t){super(),this._optionsService=e,this._bufferService=t,this._onBufferActivate=this.register(new s.EventEmitter),this.onBufferActivate=this._onBufferActivate.event,this.reset(),this.register(this._optionsService.onSpecificOptionChange("scrollback",(()=>this.resize(this._bufferService.cols,this._bufferService.rows)))),this.register(this._optionsService.onSpecificOptionChange("tabStopWidth",(()=>this.setupTabStops())))}reset(){this._normal=new n.Buffer(!0,this._optionsService,this._bufferService),this._normal.fillViewportRows(),this._alt=new n.Buffer(!1,this._optionsService,this._bufferService),this._activeBuffer=this._normal,this._onBufferActivate.fire({activeBuffer:this._normal,inactiveBuffer:this._alt}),this.setupTabStops()}get alt(){return this._alt}get active(){return this._activeBuffer}get normal(){return this._normal}activateNormalBuffer(){this._activeBuffer!==this._normal&&(this._normal.x=this._alt.x,this._normal.y=this._alt.y,this._alt.clearAllMarkers(),this._alt.clear(),this._activeBuffer=this._normal,this._onBufferActivate.fire({activeBuffer:this._normal,inactiveBuffer:this._alt}))}activateAltBuffer(e){this._activeBuffer!==this._alt&&(this._alt.fillViewportRows(e),this._alt.x=this._normal.x,this._alt.y=this._normal.y,this._activeBuffer=this._alt,this._onBufferActivate.fire({activeBuffer:this._alt,inactiveBuffer:this._normal}))}resize(e,t){this._normal.resize(e,t),this._alt.resize(e,t),this.setupTabStops(e)}setupTabStops(e){this._normal.setupTabStops(e),this._alt.setupTabStops(e)}}t.BufferSet=o},511:(e,t,i)=>{Object.defineProperty(t,"__esModule",{value:!0}),t.CellData=void 0;const s=i(482),r=i(643),n=i(3734);class o extends n.AttributeData{constructor(){super(...arguments),this.content=0,this.fg=0,this.bg=0,this.extended=new n.ExtendedAttrs,this.combinedData=""}static fromCharData(e){const t=new o;return t.setFromCharData(e),t}isCombined(){return 2097152&this.content}getWidth(){return this.content>>22}getChars(){return 2097152&this.content?this.combinedData:2097151&this.content?(0,s.stringFromCodePoint)(2097151&this.content):""}getCode(){return this.isCombined()?this.combinedData.charCodeAt(this.combinedData.length-1):2097151&this.content}setFromCharData(e){this.fg=e[r.CHAR_DATA_ATTR_INDEX],this.bg=0;let t=!1;if(e[r.CHAR_DATA_CHAR_INDEX].length>2)t=!0;else if(2===e[r.CHAR_DATA_CHAR_INDEX].length){const i=e[r.CHAR_DATA_CHAR_INDEX].charCodeAt(0);if(55296<=i&&i<=56319){const s=e[r.CHAR_DATA_CHAR_INDEX].charCodeAt(1);56320<=s&&s<=57343?this.content=1024*(i-55296)+s-56320+65536|e[r.CHAR_DATA_WIDTH_INDEX]<<22:t=!0}else t=!0}else this.content=e[r.CHAR_DATA_CHAR_INDEX].charCodeAt(0)|e[r.CHAR_DATA_WIDTH_INDEX]<<22;t&&(this.combinedData=e[r.CHAR_DATA_CHAR_INDEX],this.content=2097152|e[r.CHAR_DATA_WIDTH_INDEX]<<22)}getAsCharData(){return[this.fg,this.getChars(),this.getWidth(),this.getCode()]}}t.CellData=o},643:(e,t)=>{Object.defineProperty(t,"__esModule",{value:!0}),t.WHITESPACE_CELL_CODE=t.WHITESPACE_CELL_WIDTH=t.WHITESPACE_CELL_CHAR=t.NULL_CELL_CODE=t.NULL_CELL_WIDTH=t.NULL_CELL_CHAR=t.CHAR_DATA_CODE_INDEX=t.CHAR_DATA_WIDTH_INDEX=t.CHAR_DATA_CHAR_INDEX=t.CHAR_DATA_ATTR_INDEX=t.DEFAULT_EXT=t.DEFAULT_ATTR=t.DEFAULT_COLOR=void 0,t.DEFAULT_COLOR=0,t.DEFAULT_ATTR=256|t.DEFAULT_COLOR<<9,t.DEFAULT_EXT=0,t.CHAR_DATA_ATTR_INDEX=0,t.CHAR_DATA_CHAR_INDEX=1,t.CHAR_DATA_WIDTH_INDEX=2,t.CHAR_DATA_CODE_INDEX=3,t.NULL_CELL_CHAR="",t.NULL_CELL_WIDTH=1,t.NULL_CELL_CODE=0,t.WHITESPACE_CELL_CHAR=" ",t.WHITESPACE_CELL_WIDTH=1,t.WHITESPACE_CELL_CODE=32},4863:(e,t,i)=>{Object.defineProperty(t,"__esModule",{value:!0}),t.Marker=void 0;const s=i(8460),r=i(844);class n{get id(){return this._id}constructor(e){this.line=e,this.isDisposed=!1,this._disposables=[],this._id=n._nextId++,this._onDispose=this.register(new s.EventEmitter),this.onDispose=this._onDispose.event}dispose(){this.isDisposed||(this.isDisposed=!0,this.line=-1,this._onDispose.fire(),(0,r.disposeArray)(this._disposables),this._disposables.length=0)}register(e){return this._disposables.push(e),e}}t.Marker=n,n._nextId=1},7116:(e,t)=>{Object.defineProperty(t,"__esModule",{value:!0}),t.DEFAULT_CHARSET=t.CHARSETS=void 0,t.CHARSETS={},t.DEFAULT_CHARSET=t.CHARSETS.B,t.CHARSETS[0]={"`":"◆",a:"▒",b:"␉",c:"␌",d:"␍",e:"␊",f:"°",g:"±",h:"␤",i:"␋",j:"┘",k:"┐",l:"┌",m:"└",n:"┼",o:"⎺",p:"⎻",q:"─",r:"⎼",s:"⎽",t:"├",u:"┤",v:"┴",w:"┬",x:"│",y:"≤",z:"≥","{":"π","|":"≠","}":"£","~":"·"},t.CHARSETS.A={"#":"£"},t.CHARSETS.B=void 0,t.CHARSETS[4]={"#":"£","@":"¾","[":"ij","\\":"½","]":"|","{":"¨","|":"f","}":"¼","~":"´"},t.CHARSETS.C=t.CHARSETS[5]={"[":"Ä","\\":"Ö","]":"Å","^":"Ü","`":"é","{":"ä","|":"ö","}":"å","~":"ü"},t.CHARSETS.R={"#":"£","@":"à","[":"°","\\":"ç","]":"§","{":"é","|":"ù","}":"è","~":"¨"},t.CHARSETS.Q={"@":"à","[":"â","\\":"ç","]":"ê","^":"î","`":"ô","{":"é","|":"ù","}":"è","~":"û"},t.CHARSETS.K={"@":"§","[":"Ä","\\":"Ö","]":"Ü","{":"ä","|":"ö","}":"ü","~":"ß"},t.CHARSETS.Y={"#":"£","@":"§","[":"°","\\":"ç","]":"é","`":"ù","{":"à","|":"ò","}":"è","~":"ì"},t.CHARSETS.E=t.CHARSETS[6]={"@":"Ä","[":"Æ","\\":"Ø","]":"Å","^":"Ü","`":"ä","{":"æ","|":"ø","}":"å","~":"ü"},t.CHARSETS.Z={"#":"£","@":"§","[":"¡","\\":"Ñ","]":"¿","{":"°","|":"ñ","}":"ç"},t.CHARSETS.H=t.CHARSETS[7]={"@":"É","[":"Ä","\\":"Ö","]":"Å","^":"Ü","`":"é","{":"ä","|":"ö","}":"å","~":"ü"},t.CHARSETS["="]={"#":"ù","@":"à","[":"é","\\":"ç","]":"ê","^":"î",_:"è","`":"ô","{":"ä","|":"ö","}":"ü","~":"û"}},2584:(e,t)=>{var i,s,r;Object.defineProperty(t,"__esModule",{value:!0}),t.C1_ESCAPED=t.C1=t.C0=void 0,function(e){e.NUL="\0",e.SOH="",e.STX="",e.ETX="",e.EOT="",e.ENQ="",e.ACK="",e.BEL="",e.BS="\b",e.HT="\t",e.LF="\n",e.VT="\v",e.FF="\f",e.CR="\r",e.SO="",e.SI="",e.DLE="",e.DC1="",e.DC2="",e.DC3="",e.DC4="",e.NAK="",e.SYN="",e.ETB="",e.CAN="",e.EM="",e.SUB="",e.ESC="",e.FS="",e.GS="",e.RS="",e.US="",e.SP=" ",e.DEL=""}(i||(t.C0=i={})),function(e){e.PAD="€",e.HOP="",e.BPH="‚",e.NBH="ƒ",e.IND="„",e.NEL="…",e.SSA="†",e.ESA="‡",e.HTS="ˆ",e.HTJ="‰",e.VTS="Š",e.PLD="‹",e.PLU="Œ",e.RI="",e.SS2="Ž",e.SS3="",e.DCS="",e.PU1="‘",e.PU2="’",e.STS="“",e.CCH="”",e.MW="•",e.SPA="–",e.EPA="—",e.SOS="˜",e.SGCI="™",e.SCI="š",e.CSI="›",e.ST="œ",e.OSC="",e.PM="ž",e.APC="Ÿ"}(s||(t.C1=s={})),function(e){e.ST=`${i.ESC}\\`}(r||(t.C1_ESCAPED=r={}))},7399:(e,t,i)=>{Object.defineProperty(t,"__esModule",{value:!0}),t.evaluateKeyboardEvent=void 0;const s=i(2584),r={48:["0",")"],49:["1","!"],50:["2","@"],51:["3","#"],52:["4","$"],53:["5","%"],54:["6","^"],55:["7","&"],56:["8","*"],57:["9","("],186:[";",":"],187:["=","+"],188:[",","<"],189:["-","_"],190:[".",">"],191:["/","?"],192:["`","~"],219:["[","{"],220:["\\","|"],221:["]","}"],222:["'",'"']};t.evaluateKeyboardEvent=function(e,t,i,n){const o={type:0,cancel:!1,key:void 0},a=(e.shiftKey?1:0)|(e.altKey?2:0)|(e.ctrlKey?4:0)|(e.metaKey?8:0);switch(e.keyCode){case 0:"UIKeyInputUpArrow"===e.key?o.key=t?s.C0.ESC+"OA":s.C0.ESC+"[A":"UIKeyInputLeftArrow"===e.key?o.key=t?s.C0.ESC+"OD":s.C0.ESC+"[D":"UIKeyInputRightArrow"===e.key?o.key=t?s.C0.ESC+"OC":s.C0.ESC+"[C":"UIKeyInputDownArrow"===e.key&&(o.key=t?s.C0.ESC+"OB":s.C0.ESC+"[B");break;case 8:if(e.altKey){o.key=s.C0.ESC+s.C0.DEL;break}o.key=s.C0.DEL;break;case 9:if(e.shiftKey){o.key=s.C0.ESC+"[Z";break}o.key=s.C0.HT,o.cancel=!0;break;case 13:o.key=e.altKey?s.C0.ESC+s.C0.CR:s.C0.CR,o.cancel=!0;break;case 27:o.key=s.C0.ESC,e.altKey&&(o.key=s.C0.ESC+s.C0.ESC),o.cancel=!0;break;case 37:if(e.metaKey)break;a?(o.key=s.C0.ESC+"[1;"+(a+1)+"D",o.key===s.C0.ESC+"[1;3D"&&(o.key=s.C0.ESC+(i?"b":"[1;5D"))):o.key=t?s.C0.ESC+"OD":s.C0.ESC+"[D";break;case 39:if(e.metaKey)break;a?(o.key=s.C0.ESC+"[1;"+(a+1)+"C",o.key===s.C0.ESC+"[1;3C"&&(o.key=s.C0.ESC+(i?"f":"[1;5C"))):o.key=t?s.C0.ESC+"OC":s.C0.ESC+"[C";break;case 38:if(e.metaKey)break;a?(o.key=s.C0.ESC+"[1;"+(a+1)+"A",i||o.key!==s.C0.ESC+"[1;3A"||(o.key=s.C0.ESC+"[1;5A")):o.key=t?s.C0.ESC+"OA":s.C0.ESC+"[A";break;case 40:if(e.metaKey)break;a?(o.key=s.C0.ESC+"[1;"+(a+1)+"B",i||o.key!==s.C0.ESC+"[1;3B"||(o.key=s.C0.ESC+"[1;5B")):o.key=t?s.C0.ESC+"OB":s.C0.ESC+"[B";break;case 45:e.shiftKey||e.ctrlKey||(o.key=s.C0.ESC+"[2~");break;case 46:o.key=a?s.C0.ESC+"[3;"+(a+1)+"~":s.C0.ESC+"[3~";break;case 36:o.key=a?s.C0.ESC+"[1;"+(a+1)+"H":t?s.C0.ESC+"OH":s.C0.ESC+"[H";break;case 35:o.key=a?s.C0.ESC+"[1;"+(a+1)+"F":t?s.C0.ESC+"OF":s.C0.ESC+"[F";break;case 33:e.shiftKey?o.type=2:e.ctrlKey?o.key=s.C0.ESC+"[5;"+(a+1)+"~":o.key=s.C0.ESC+"[5~";break;case 34:e.shiftKey?o.type=3:e.ctrlKey?o.key=s.C0.ESC+"[6;"+(a+1)+"~":o.key=s.C0.ESC+"[6~";break;case 112:o.key=a?s.C0.ESC+"[1;"+(a+1)+"P":s.C0.ESC+"OP";break;case 113:o.key=a?s.C0.ESC+"[1;"+(a+1)+"Q":s.C0.ESC+"OQ";break;case 114:o.key=a?s.C0.ESC+"[1;"+(a+1)+"R":s.C0.ESC+"OR";break;case 115:o.key=a?s.C0.ESC+"[1;"+(a+1)+"S":s.C0.ESC+"OS";break;case 116:o.key=a?s.C0.ESC+"[15;"+(a+1)+"~":s.C0.ESC+"[15~";break;case 117:o.key=a?s.C0.ESC+"[17;"+(a+1)+"~":s.C0.ESC+"[17~";break;case 118:o.key=a?s.C0.ESC+"[18;"+(a+1)+"~":s.C0.ESC+"[18~";break;case 119:o.key=a?s.C0.ESC+"[19;"+(a+1)+"~":s.C0.ESC+"[19~";break;case 120:o.key=a?s.C0.ESC+"[20;"+(a+1)+"~":s.C0.ESC+"[20~";break;case 121:o.key=a?s.C0.ESC+"[21;"+(a+1)+"~":s.C0.ESC+"[21~";break;case 122:o.key=a?s.C0.ESC+"[23;"+(a+1)+"~":s.C0.ESC+"[23~";break;case 123:o.key=a?s.C0.ESC+"[24;"+(a+1)+"~":s.C0.ESC+"[24~";break;default:if(!e.ctrlKey||e.shiftKey||e.altKey||e.metaKey)if(i&&!n||!e.altKey||e.metaKey)!i||e.altKey||e.ctrlKey||e.shiftKey||!e.metaKey?e.key&&!e.ctrlKey&&!e.altKey&&!e.metaKey&&e.keyCode>=48&&1===e.key.length?o.key=e.key:e.key&&e.ctrlKey&&("_"===e.key&&(o.key=s.C0.US),"@"===e.key&&(o.key=s.C0.NUL)):65===e.keyCode&&(o.type=1);else{const t=r[e.keyCode],i=null==t?void 0:t[e.shiftKey?1:0];if(i)o.key=s.C0.ESC+i;else if(e.keyCode>=65&&e.keyCode<=90){const t=e.ctrlKey?e.keyCode-64:e.keyCode+32;let i=String.fromCharCode(t);e.shiftKey&&(i=i.toUpperCase()),o.key=s.C0.ESC+i}else if(32===e.keyCode)o.key=s.C0.ESC+(e.ctrlKey?s.C0.NUL:" ");else if("Dead"===e.key&&e.code.startsWith("Key")){let t=e.code.slice(3,4);e.shiftKey||(t=t.toLowerCase()),o.key=s.C0.ESC+t,o.cancel=!0}}else e.keyCode>=65&&e.keyCode<=90?o.key=String.fromCharCode(e.keyCode-64):32===e.keyCode?o.key=s.C0.NUL:e.keyCode>=51&&e.keyCode<=55?o.key=String.fromCharCode(e.keyCode-51+27):56===e.keyCode?o.key=s.C0.DEL:219===e.keyCode?o.key=s.C0.ESC:220===e.keyCode?o.key=s.C0.FS:221===e.keyCode&&(o.key=s.C0.GS)}return o}},482:(e,t)=>{Object.defineProperty(t,"__esModule",{value:!0}),t.Utf8ToUtf32=t.StringToUtf32=t.utf32ToString=t.stringFromCodePoint=void 0,t.stringFromCodePoint=function(e){return e>65535?(e-=65536,String.fromCharCode(55296+(e>>10))+String.fromCharCode(e%1024+56320)):String.fromCharCode(e)},t.utf32ToString=function(e,t=0,i=e.length){let s="";for(let r=t;r65535?(t-=65536,s+=String.fromCharCode(55296+(t>>10))+String.fromCharCode(t%1024+56320)):s+=String.fromCharCode(t)}return s},t.StringToUtf32=class{constructor(){this._interim=0}clear(){this._interim=0}decode(e,t){const i=e.length;if(!i)return 0;let s=0,r=0;if(this._interim){const i=e.charCodeAt(r++);56320<=i&&i<=57343?t[s++]=1024*(this._interim-55296)+i-56320+65536:(t[s++]=this._interim,t[s++]=i),this._interim=0}for(let n=r;n=i)return this._interim=r,s;const o=e.charCodeAt(n);56320<=o&&o<=57343?t[s++]=1024*(r-55296)+o-56320+65536:(t[s++]=r,t[s++]=o)}else 65279!==r&&(t[s++]=r)}return s}},t.Utf8ToUtf32=class{constructor(){this.interim=new Uint8Array(3)}clear(){this.interim.fill(0)}decode(e,t){const i=e.length;if(!i)return 0;let s,r,n,o,a=0,h=0,c=0;if(this.interim[0]){let s=!1,r=this.interim[0];r&=192==(224&r)?31:224==(240&r)?15:7;let n,o=0;for(;(n=63&this.interim[++o])&&o<4;)r<<=6,r|=n;const h=192==(224&this.interim[0])?2:224==(240&this.interim[0])?3:4,l=h-o;for(;c=i)return 0;if(n=e[c++],128!=(192&n)){c--,s=!0;break}this.interim[o++]=n,r<<=6,r|=63&n}s||(2===h?r<128?c--:t[a++]=r:3===h?r<2048||r>=55296&&r<=57343||65279===r||(t[a++]=r):r<65536||r>1114111||(t[a++]=r)),this.interim.fill(0)}const l=i-4;let d=c;for(;d=i)return this.interim[0]=s,a;if(r=e[d++],128!=(192&r)){d--;continue}if(h=(31&s)<<6|63&r,h<128){d--;continue}t[a++]=h}else if(224==(240&s)){if(d>=i)return this.interim[0]=s,a;if(r=e[d++],128!=(192&r)){d--;continue}if(d>=i)return this.interim[0]=s,this.interim[1]=r,a;if(n=e[d++],128!=(192&n)){d--;continue}if(h=(15&s)<<12|(63&r)<<6|63&n,h<2048||h>=55296&&h<=57343||65279===h)continue;t[a++]=h}else if(240==(248&s)){if(d>=i)return this.interim[0]=s,a;if(r=e[d++],128!=(192&r)){d--;continue}if(d>=i)return this.interim[0]=s,this.interim[1]=r,a;if(n=e[d++],128!=(192&n)){d--;continue}if(d>=i)return this.interim[0]=s,this.interim[1]=r,this.interim[2]=n,a;if(o=e[d++],128!=(192&o)){d--;continue}if(h=(7&s)<<18|(63&r)<<12|(63&n)<<6|63&o,h<65536||h>1114111)continue;t[a++]=h}}return a}}},225:(e,t)=>{Object.defineProperty(t,"__esModule",{value:!0}),t.UnicodeV6=void 0;const i=[[768,879],[1155,1158],[1160,1161],[1425,1469],[1471,1471],[1473,1474],[1476,1477],[1479,1479],[1536,1539],[1552,1557],[1611,1630],[1648,1648],[1750,1764],[1767,1768],[1770,1773],[1807,1807],[1809,1809],[1840,1866],[1958,1968],[2027,2035],[2305,2306],[2364,2364],[2369,2376],[2381,2381],[2385,2388],[2402,2403],[2433,2433],[2492,2492],[2497,2500],[2509,2509],[2530,2531],[2561,2562],[2620,2620],[2625,2626],[2631,2632],[2635,2637],[2672,2673],[2689,2690],[2748,2748],[2753,2757],[2759,2760],[2765,2765],[2786,2787],[2817,2817],[2876,2876],[2879,2879],[2881,2883],[2893,2893],[2902,2902],[2946,2946],[3008,3008],[3021,3021],[3134,3136],[3142,3144],[3146,3149],[3157,3158],[3260,3260],[3263,3263],[3270,3270],[3276,3277],[3298,3299],[3393,3395],[3405,3405],[3530,3530],[3538,3540],[3542,3542],[3633,3633],[3636,3642],[3655,3662],[3761,3761],[3764,3769],[3771,3772],[3784,3789],[3864,3865],[3893,3893],[3895,3895],[3897,3897],[3953,3966],[3968,3972],[3974,3975],[3984,3991],[3993,4028],[4038,4038],[4141,4144],[4146,4146],[4150,4151],[4153,4153],[4184,4185],[4448,4607],[4959,4959],[5906,5908],[5938,5940],[5970,5971],[6002,6003],[6068,6069],[6071,6077],[6086,6086],[6089,6099],[6109,6109],[6155,6157],[6313,6313],[6432,6434],[6439,6440],[6450,6450],[6457,6459],[6679,6680],[6912,6915],[6964,6964],[6966,6970],[6972,6972],[6978,6978],[7019,7027],[7616,7626],[7678,7679],[8203,8207],[8234,8238],[8288,8291],[8298,8303],[8400,8431],[12330,12335],[12441,12442],[43014,43014],[43019,43019],[43045,43046],[64286,64286],[65024,65039],[65056,65059],[65279,65279],[65529,65531]],s=[[68097,68099],[68101,68102],[68108,68111],[68152,68154],[68159,68159],[119143,119145],[119155,119170],[119173,119179],[119210,119213],[119362,119364],[917505,917505],[917536,917631],[917760,917999]];let r;t.UnicodeV6=class{constructor(){if(this.version="6",!r){r=new Uint8Array(65536),r.fill(1),r[0]=0,r.fill(0,1,32),r.fill(0,127,160),r.fill(2,4352,4448),r[9001]=2,r[9002]=2,r.fill(2,11904,42192),r[12351]=1,r.fill(2,44032,55204),r.fill(2,63744,64256),r.fill(2,65040,65050),r.fill(2,65072,65136),r.fill(2,65280,65377),r.fill(2,65504,65511);for(let e=0;et[r][1])return!1;for(;r>=s;)if(i=s+r>>1,e>t[i][1])s=i+1;else{if(!(e=131072&&e<=196605||e>=196608&&e<=262141?2:1}}},5981:(e,t,i)=>{Object.defineProperty(t,"__esModule",{value:!0}),t.WriteBuffer=void 0;const s=i(8460),r=i(844);class n extends r.Disposable{constructor(e){super(),this._action=e,this._writeBuffer=[],this._callbacks=[],this._pendingData=0,this._bufferOffset=0,this._isSyncWriting=!1,this._syncCalls=0,this._didUserInput=!1,this._onWriteParsed=this.register(new s.EventEmitter),this.onWriteParsed=this._onWriteParsed.event}handleUserInput(){this._didUserInput=!0}writeSync(e,t){if(void 0!==t&&this._syncCalls>t)return void(this._syncCalls=0);if(this._pendingData+=e.length,this._writeBuffer.push(e),this._callbacks.push(void 0),this._syncCalls++,this._isSyncWriting)return;let i;for(this._isSyncWriting=!0;i=this._writeBuffer.shift();){this._action(i);const e=this._callbacks.shift();e&&e()}this._pendingData=0,this._bufferOffset=2147483647,this._isSyncWriting=!1,this._syncCalls=0}write(e,t){if(this._pendingData>5e7)throw new Error("write data discarded, use flow control to avoid losing data");if(!this._writeBuffer.length){if(this._bufferOffset=0,this._didUserInput)return this._didUserInput=!1,this._pendingData+=e.length,this._writeBuffer.push(e),this._callbacks.push(t),void this._innerWrite();setTimeout((()=>this._innerWrite()))}this._pendingData+=e.length,this._writeBuffer.push(e),this._callbacks.push(t)}_innerWrite(e=0,t=!0){const i=e||Date.now();for(;this._writeBuffer.length>this._bufferOffset;){const e=this._writeBuffer[this._bufferOffset],s=this._action(e,t);if(s){const e=e=>Date.now()-i>=12?setTimeout((()=>this._innerWrite(0,e))):this._innerWrite(i,e);return void s.catch((e=>(queueMicrotask((()=>{throw e})),Promise.resolve(!1)))).then(e)}const r=this._callbacks[this._bufferOffset];if(r&&r(),this._bufferOffset++,this._pendingData-=e.length,Date.now()-i>=12)break}this._writeBuffer.length>this._bufferOffset?(this._bufferOffset>50&&(this._writeBuffer=this._writeBuffer.slice(this._bufferOffset),this._callbacks=this._callbacks.slice(this._bufferOffset),this._bufferOffset=0),setTimeout((()=>this._innerWrite()))):(this._writeBuffer.length=0,this._callbacks.length=0,this._pendingData=0,this._bufferOffset=0),this._onWriteParsed.fire()}}t.WriteBuffer=n},5941:(e,t)=>{Object.defineProperty(t,"__esModule",{value:!0}),t.toRgbString=t.parseColor=void 0;const i=/^([\da-f])\/([\da-f])\/([\da-f])$|^([\da-f]{2})\/([\da-f]{2})\/([\da-f]{2})$|^([\da-f]{3})\/([\da-f]{3})\/([\da-f]{3})$|^([\da-f]{4})\/([\da-f]{4})\/([\da-f]{4})$/,s=/^[\da-f]+$/;function r(e,t){const i=e.toString(16),s=i.length<2?"0"+i:i;switch(t){case 4:return i[0];case 8:return s;case 12:return(s+s).slice(0,3);default:return s+s}}t.parseColor=function(e){if(!e)return;let t=e.toLowerCase();if(0===t.indexOf("rgb:")){t=t.slice(4);const e=i.exec(t);if(e){const t=e[1]?15:e[4]?255:e[7]?4095:65535;return[Math.round(parseInt(e[1]||e[4]||e[7]||e[10],16)/t*255),Math.round(parseInt(e[2]||e[5]||e[8]||e[11],16)/t*255),Math.round(parseInt(e[3]||e[6]||e[9]||e[12],16)/t*255)]}}else if(0===t.indexOf("#")&&(t=t.slice(1),s.exec(t)&&[3,6,9,12].includes(t.length))){const e=t.length/3,i=[0,0,0];for(let s=0;s<3;++s){const r=parseInt(t.slice(e*s,e*s+e),16);i[s]=1===e?r<<4:2===e?r:3===e?r>>4:r>>8}return i}},t.toRgbString=function(e,t=16){const[i,s,n]=e;return`rgb:${r(i,t)}/${r(s,t)}/${r(n,t)}`}},5770:(e,t)=>{Object.defineProperty(t,"__esModule",{value:!0}),t.PAYLOAD_LIMIT=void 0,t.PAYLOAD_LIMIT=1e7},6351:(e,t,i)=>{Object.defineProperty(t,"__esModule",{value:!0}),t.DcsHandler=t.DcsParser=void 0;const s=i(482),r=i(8742),n=i(5770),o=[];t.DcsParser=class{constructor(){this._handlers=Object.create(null),this._active=o,this._ident=0,this._handlerFb=()=>{},this._stack={paused:!1,loopPosition:0,fallThrough:!1}}dispose(){this._handlers=Object.create(null),this._handlerFb=()=>{},this._active=o}registerHandler(e,t){void 0===this._handlers[e]&&(this._handlers[e]=[]);const i=this._handlers[e];return i.push(t),{dispose:()=>{const e=i.indexOf(t);-1!==e&&i.splice(e,1)}}}clearHandler(e){this._handlers[e]&&delete this._handlers[e]}setHandlerFallback(e){this._handlerFb=e}reset(){if(this._active.length)for(let e=this._stack.paused?this._stack.loopPosition-1:this._active.length-1;e>=0;--e)this._active[e].unhook(!1);this._stack.paused=!1,this._active=o,this._ident=0}hook(e,t){if(this.reset(),this._ident=e,this._active=this._handlers[e]||o,this._active.length)for(let e=this._active.length-1;e>=0;e--)this._active[e].hook(t);else this._handlerFb(this._ident,"HOOK",t)}put(e,t,i){if(this._active.length)for(let s=this._active.length-1;s>=0;s--)this._active[s].put(e,t,i);else this._handlerFb(this._ident,"PUT",(0,s.utf32ToString)(e,t,i))}unhook(e,t=!0){if(this._active.length){let i=!1,s=this._active.length-1,r=!1;if(this._stack.paused&&(s=this._stack.loopPosition-1,i=t,r=this._stack.fallThrough,this._stack.paused=!1),!r&&!1===i){for(;s>=0&&(i=this._active[s].unhook(e),!0!==i);s--)if(i instanceof Promise)return this._stack.paused=!0,this._stack.loopPosition=s,this._stack.fallThrough=!1,i;s--}for(;s>=0;s--)if(i=this._active[s].unhook(!1),i instanceof Promise)return this._stack.paused=!0,this._stack.loopPosition=s,this._stack.fallThrough=!0,i}else this._handlerFb(this._ident,"UNHOOK",e);this._active=o,this._ident=0}};const a=new r.Params;a.addParam(0),t.DcsHandler=class{constructor(e){this._handler=e,this._data="",this._params=a,this._hitLimit=!1}hook(e){this._params=e.length>1||e.params[0]?e.clone():a,this._data="",this._hitLimit=!1}put(e,t,i){this._hitLimit||(this._data+=(0,s.utf32ToString)(e,t,i),this._data.length>n.PAYLOAD_LIMIT&&(this._data="",this._hitLimit=!0))}unhook(e){let t=!1;if(this._hitLimit)t=!1;else if(e&&(t=this._handler(this._data,this._params),t instanceof Promise))return t.then((e=>(this._params=a,this._data="",this._hitLimit=!1,e)));return this._params=a,this._data="",this._hitLimit=!1,t}}},2015:(e,t,i)=>{Object.defineProperty(t,"__esModule",{value:!0}),t.EscapeSequenceParser=t.VT500_TRANSITION_TABLE=t.TransitionTable=void 0;const s=i(844),r=i(8742),n=i(6242),o=i(6351);class a{constructor(e){this.table=new Uint8Array(e)}setDefault(e,t){this.table.fill(e<<4|t)}add(e,t,i,s){this.table[t<<8|e]=i<<4|s}addMany(e,t,i,s){for(let r=0;rt)),i=(e,i)=>t.slice(e,i),s=i(32,127),r=i(0,24);r.push(25),r.push.apply(r,i(28,32));const n=i(0,14);let o;for(o in e.setDefault(1,0),e.addMany(s,0,2,0),n)e.addMany([24,26,153,154],o,3,0),e.addMany(i(128,144),o,3,0),e.addMany(i(144,152),o,3,0),e.add(156,o,0,0),e.add(27,o,11,1),e.add(157,o,4,8),e.addMany([152,158,159],o,0,7),e.add(155,o,11,3),e.add(144,o,11,9);return e.addMany(r,0,3,0),e.addMany(r,1,3,1),e.add(127,1,0,1),e.addMany(r,8,0,8),e.addMany(r,3,3,3),e.add(127,3,0,3),e.addMany(r,4,3,4),e.add(127,4,0,4),e.addMany(r,6,3,6),e.addMany(r,5,3,5),e.add(127,5,0,5),e.addMany(r,2,3,2),e.add(127,2,0,2),e.add(93,1,4,8),e.addMany(s,8,5,8),e.add(127,8,5,8),e.addMany([156,27,24,26,7],8,6,0),e.addMany(i(28,32),8,0,8),e.addMany([88,94,95],1,0,7),e.addMany(s,7,0,7),e.addMany(r,7,0,7),e.add(156,7,0,0),e.add(127,7,0,7),e.add(91,1,11,3),e.addMany(i(64,127),3,7,0),e.addMany(i(48,60),3,8,4),e.addMany([60,61,62,63],3,9,4),e.addMany(i(48,60),4,8,4),e.addMany(i(64,127),4,7,0),e.addMany([60,61,62,63],4,0,6),e.addMany(i(32,64),6,0,6),e.add(127,6,0,6),e.addMany(i(64,127),6,0,0),e.addMany(i(32,48),3,9,5),e.addMany(i(32,48),5,9,5),e.addMany(i(48,64),5,0,6),e.addMany(i(64,127),5,7,0),e.addMany(i(32,48),4,9,5),e.addMany(i(32,48),1,9,2),e.addMany(i(32,48),2,9,2),e.addMany(i(48,127),2,10,0),e.addMany(i(48,80),1,10,0),e.addMany(i(81,88),1,10,0),e.addMany([89,90,92],1,10,0),e.addMany(i(96,127),1,10,0),e.add(80,1,11,9),e.addMany(r,9,0,9),e.add(127,9,0,9),e.addMany(i(28,32),9,0,9),e.addMany(i(32,48),9,9,12),e.addMany(i(48,60),9,8,10),e.addMany([60,61,62,63],9,9,10),e.addMany(r,11,0,11),e.addMany(i(32,128),11,0,11),e.addMany(i(28,32),11,0,11),e.addMany(r,10,0,10),e.add(127,10,0,10),e.addMany(i(28,32),10,0,10),e.addMany(i(48,60),10,8,10),e.addMany([60,61,62,63],10,0,11),e.addMany(i(32,48),10,9,12),e.addMany(r,12,0,12),e.add(127,12,0,12),e.addMany(i(28,32),12,0,12),e.addMany(i(32,48),12,9,12),e.addMany(i(48,64),12,0,11),e.addMany(i(64,127),12,12,13),e.addMany(i(64,127),10,12,13),e.addMany(i(64,127),9,12,13),e.addMany(r,13,13,13),e.addMany(s,13,13,13),e.add(127,13,0,13),e.addMany([27,156,24,26],13,14,0),e.add(h,0,2,0),e.add(h,8,5,8),e.add(h,6,0,6),e.add(h,11,0,11),e.add(h,13,13,13),e}();class c extends s.Disposable{constructor(e=t.VT500_TRANSITION_TABLE){super(),this._transitions=e,this._parseStack={state:0,handlers:[],handlerPos:0,transition:0,chunkPos:0},this.initialState=0,this.currentState=this.initialState,this._params=new r.Params,this._params.addParam(0),this._collect=0,this.precedingCodepoint=0,this._printHandlerFb=(e,t,i)=>{},this._executeHandlerFb=e=>{},this._csiHandlerFb=(e,t)=>{},this._escHandlerFb=e=>{},this._errorHandlerFb=e=>e,this._printHandler=this._printHandlerFb,this._executeHandlers=Object.create(null),this._csiHandlers=Object.create(null),this._escHandlers=Object.create(null),this.register((0,s.toDisposable)((()=>{this._csiHandlers=Object.create(null),this._executeHandlers=Object.create(null),this._escHandlers=Object.create(null)}))),this._oscParser=this.register(new n.OscParser),this._dcsParser=this.register(new o.DcsParser),this._errorHandler=this._errorHandlerFb,this.registerEscHandler({final:"\\"},(()=>!0))}_identifier(e,t=[64,126]){let i=0;if(e.prefix){if(e.prefix.length>1)throw new Error("only one byte as prefix supported");if(i=e.prefix.charCodeAt(0),i&&60>i||i>63)throw new Error("prefix must be in range 0x3c .. 0x3f")}if(e.intermediates){if(e.intermediates.length>2)throw new Error("only two bytes as intermediates are supported");for(let t=0;ts||s>47)throw new Error("intermediate must be in range 0x20 .. 0x2f");i<<=8,i|=s}}if(1!==e.final.length)throw new Error("final must be a single byte");const s=e.final.charCodeAt(0);if(t[0]>s||s>t[1])throw new Error(`final must be in range ${t[0]} .. ${t[1]}`);return i<<=8,i|=s,i}identToString(e){const t=[];for(;e;)t.push(String.fromCharCode(255&e)),e>>=8;return t.reverse().join("")}setPrintHandler(e){this._printHandler=e}clearPrintHandler(){this._printHandler=this._printHandlerFb}registerEscHandler(e,t){const i=this._identifier(e,[48,126]);void 0===this._escHandlers[i]&&(this._escHandlers[i]=[]);const s=this._escHandlers[i];return s.push(t),{dispose:()=>{const e=s.indexOf(t);-1!==e&&s.splice(e,1)}}}clearEscHandler(e){this._escHandlers[this._identifier(e,[48,126])]&&delete this._escHandlers[this._identifier(e,[48,126])]}setEscHandlerFallback(e){this._escHandlerFb=e}setExecuteHandler(e,t){this._executeHandlers[e.charCodeAt(0)]=t}clearExecuteHandler(e){this._executeHandlers[e.charCodeAt(0)]&&delete this._executeHandlers[e.charCodeAt(0)]}setExecuteHandlerFallback(e){this._executeHandlerFb=e}registerCsiHandler(e,t){const i=this._identifier(e);void 0===this._csiHandlers[i]&&(this._csiHandlers[i]=[]);const s=this._csiHandlers[i];return s.push(t),{dispose:()=>{const e=s.indexOf(t);-1!==e&&s.splice(e,1)}}}clearCsiHandler(e){this._csiHandlers[this._identifier(e)]&&delete this._csiHandlers[this._identifier(e)]}setCsiHandlerFallback(e){this._csiHandlerFb=e}registerDcsHandler(e,t){return this._dcsParser.registerHandler(this._identifier(e),t)}clearDcsHandler(e){this._dcsParser.clearHandler(this._identifier(e))}setDcsHandlerFallback(e){this._dcsParser.setHandlerFallback(e)}registerOscHandler(e,t){return this._oscParser.registerHandler(e,t)}clearOscHandler(e){this._oscParser.clearHandler(e)}setOscHandlerFallback(e){this._oscParser.setHandlerFallback(e)}setErrorHandler(e){this._errorHandler=e}clearErrorHandler(){this._errorHandler=this._errorHandlerFb}reset(){this.currentState=this.initialState,this._oscParser.reset(),this._dcsParser.reset(),this._params.reset(),this._params.addParam(0),this._collect=0,this.precedingCodepoint=0,0!==this._parseStack.state&&(this._parseStack.state=2,this._parseStack.handlers=[])}_preserveStack(e,t,i,s,r){this._parseStack.state=e,this._parseStack.handlers=t,this._parseStack.handlerPos=i,this._parseStack.transition=s,this._parseStack.chunkPos=r}parse(e,t,i){let s,r=0,n=0,o=0;if(this._parseStack.state)if(2===this._parseStack.state)this._parseStack.state=0,o=this._parseStack.chunkPos+1;else{if(void 0===i||1===this._parseStack.state)throw this._parseStack.state=1,new Error("improper continuation due to previous async handler, giving up parsing");const t=this._parseStack.handlers;let n=this._parseStack.handlerPos-1;switch(this._parseStack.state){case 3:if(!1===i&&n>-1)for(;n>=0&&(s=t[n](this._params),!0!==s);n--)if(s instanceof Promise)return this._parseStack.handlerPos=n,s;this._parseStack.handlers=[];break;case 4:if(!1===i&&n>-1)for(;n>=0&&(s=t[n](),!0!==s);n--)if(s instanceof Promise)return this._parseStack.handlerPos=n,s;this._parseStack.handlers=[];break;case 6:if(r=e[this._parseStack.chunkPos],s=this._dcsParser.unhook(24!==r&&26!==r,i),s)return s;27===r&&(this._parseStack.transition|=1),this._params.reset(),this._params.addParam(0),this._collect=0;break;case 5:if(r=e[this._parseStack.chunkPos],s=this._oscParser.end(24!==r&&26!==r,i),s)return s;27===r&&(this._parseStack.transition|=1),this._params.reset(),this._params.addParam(0),this._collect=0}this._parseStack.state=0,o=this._parseStack.chunkPos+1,this.precedingCodepoint=0,this.currentState=15&this._parseStack.transition}for(let i=o;i>4){case 2:for(let s=i+1;;++s){if(s>=t||(r=e[s])<32||r>126&&r=t||(r=e[s])<32||r>126&&r=t||(r=e[s])<32||r>126&&r=t||(r=e[s])<32||r>126&&r=0&&(s=o[a](this._params),!0!==s);a--)if(s instanceof Promise)return this._preserveStack(3,o,a,n,i),s;a<0&&this._csiHandlerFb(this._collect<<8|r,this._params),this.precedingCodepoint=0;break;case 8:do{switch(r){case 59:this._params.addParam(0);break;case 58:this._params.addSubParam(-1);break;default:this._params.addDigit(r-48)}}while(++i47&&r<60);i--;break;case 9:this._collect<<=8,this._collect|=r;break;case 10:const c=this._escHandlers[this._collect<<8|r];let l=c?c.length-1:-1;for(;l>=0&&(s=c[l](),!0!==s);l--)if(s instanceof Promise)return this._preserveStack(4,c,l,n,i),s;l<0&&this._escHandlerFb(this._collect<<8|r),this.precedingCodepoint=0;break;case 11:this._params.reset(),this._params.addParam(0),this._collect=0;break;case 12:this._dcsParser.hook(this._collect<<8|r,this._params);break;case 13:for(let s=i+1;;++s)if(s>=t||24===(r=e[s])||26===r||27===r||r>127&&r=t||(r=e[s])<32||r>127&&r{Object.defineProperty(t,"__esModule",{value:!0}),t.OscHandler=t.OscParser=void 0;const s=i(5770),r=i(482),n=[];t.OscParser=class{constructor(){this._state=0,this._active=n,this._id=-1,this._handlers=Object.create(null),this._handlerFb=()=>{},this._stack={paused:!1,loopPosition:0,fallThrough:!1}}registerHandler(e,t){void 0===this._handlers[e]&&(this._handlers[e]=[]);const i=this._handlers[e];return i.push(t),{dispose:()=>{const e=i.indexOf(t);-1!==e&&i.splice(e,1)}}}clearHandler(e){this._handlers[e]&&delete this._handlers[e]}setHandlerFallback(e){this._handlerFb=e}dispose(){this._handlers=Object.create(null),this._handlerFb=()=>{},this._active=n}reset(){if(2===this._state)for(let e=this._stack.paused?this._stack.loopPosition-1:this._active.length-1;e>=0;--e)this._active[e].end(!1);this._stack.paused=!1,this._active=n,this._id=-1,this._state=0}_start(){if(this._active=this._handlers[this._id]||n,this._active.length)for(let e=this._active.length-1;e>=0;e--)this._active[e].start();else this._handlerFb(this._id,"START")}_put(e,t,i){if(this._active.length)for(let s=this._active.length-1;s>=0;s--)this._active[s].put(e,t,i);else this._handlerFb(this._id,"PUT",(0,r.utf32ToString)(e,t,i))}start(){this.reset(),this._state=1}put(e,t,i){if(3!==this._state){if(1===this._state)for(;t0&&this._put(e,t,i)}}end(e,t=!0){if(0!==this._state){if(3!==this._state)if(1===this._state&&this._start(),this._active.length){let i=!1,s=this._active.length-1,r=!1;if(this._stack.paused&&(s=this._stack.loopPosition-1,i=t,r=this._stack.fallThrough,this._stack.paused=!1),!r&&!1===i){for(;s>=0&&(i=this._active[s].end(e),!0!==i);s--)if(i instanceof Promise)return this._stack.paused=!0,this._stack.loopPosition=s,this._stack.fallThrough=!1,i;s--}for(;s>=0;s--)if(i=this._active[s].end(!1),i instanceof Promise)return this._stack.paused=!0,this._stack.loopPosition=s,this._stack.fallThrough=!0,i}else this._handlerFb(this._id,"END",e);this._active=n,this._id=-1,this._state=0}}},t.OscHandler=class{constructor(e){this._handler=e,this._data="",this._hitLimit=!1}start(){this._data="",this._hitLimit=!1}put(e,t,i){this._hitLimit||(this._data+=(0,r.utf32ToString)(e,t,i),this._data.length>s.PAYLOAD_LIMIT&&(this._data="",this._hitLimit=!0))}end(e){let t=!1;if(this._hitLimit)t=!1;else if(e&&(t=this._handler(this._data),t instanceof Promise))return t.then((e=>(this._data="",this._hitLimit=!1,e)));return this._data="",this._hitLimit=!1,t}}},8742:(e,t)=>{Object.defineProperty(t,"__esModule",{value:!0}),t.Params=void 0;const i=2147483647;class s{static fromArray(e){const t=new s;if(!e.length)return t;for(let i=Array.isArray(e[0])?1:0;i256)throw new Error("maxSubParamsLength must not be greater than 256");this.params=new Int32Array(e),this.length=0,this._subParams=new Int32Array(t),this._subParamsLength=0,this._subParamsIdx=new Uint16Array(e),this._rejectDigits=!1,this._rejectSubDigits=!1,this._digitIsSub=!1}clone(){const e=new s(this.maxLength,this.maxSubParamsLength);return e.params.set(this.params),e.length=this.length,e._subParams.set(this._subParams),e._subParamsLength=this._subParamsLength,e._subParamsIdx.set(this._subParamsIdx),e._rejectDigits=this._rejectDigits,e._rejectSubDigits=this._rejectSubDigits,e._digitIsSub=this._digitIsSub,e}toArray(){const e=[];for(let t=0;t>8,s=255&this._subParamsIdx[t];s-i>0&&e.push(Array.prototype.slice.call(this._subParams,i,s))}return e}reset(){this.length=0,this._subParamsLength=0,this._rejectDigits=!1,this._rejectSubDigits=!1,this._digitIsSub=!1}addParam(e){if(this._digitIsSub=!1,this.length>=this.maxLength)this._rejectDigits=!0;else{if(e<-1)throw new Error("values lesser than -1 are not allowed");this._subParamsIdx[this.length]=this._subParamsLength<<8|this._subParamsLength,this.params[this.length++]=e>i?i:e}}addSubParam(e){if(this._digitIsSub=!0,this.length)if(this._rejectDigits||this._subParamsLength>=this.maxSubParamsLength)this._rejectSubDigits=!0;else{if(e<-1)throw new Error("values lesser than -1 are not allowed");this._subParams[this._subParamsLength++]=e>i?i:e,this._subParamsIdx[this.length-1]++}}hasSubParams(e){return(255&this._subParamsIdx[e])-(this._subParamsIdx[e]>>8)>0}getSubParams(e){const t=this._subParamsIdx[e]>>8,i=255&this._subParamsIdx[e];return i-t>0?this._subParams.subarray(t,i):null}getSubParamsAll(){const e={};for(let t=0;t>8,s=255&this._subParamsIdx[t];s-i>0&&(e[t]=this._subParams.slice(i,s))}return e}addDigit(e){let t;if(this._rejectDigits||!(t=this._digitIsSub?this._subParamsLength:this.length)||this._digitIsSub&&this._rejectSubDigits)return;const s=this._digitIsSub?this._subParams:this.params,r=s[t-1];s[t-1]=~r?Math.min(10*r+e,i):e}}t.Params=s},5741:(e,t)=>{Object.defineProperty(t,"__esModule",{value:!0}),t.AddonManager=void 0,t.AddonManager=class{constructor(){this._addons=[]}dispose(){for(let e=this._addons.length-1;e>=0;e--)this._addons[e].instance.dispose()}loadAddon(e,t){const i={instance:t,dispose:t.dispose,isDisposed:!1};this._addons.push(i),t.dispose=()=>this._wrappedAddonDispose(i),t.activate(e)}_wrappedAddonDispose(e){if(e.isDisposed)return;let t=-1;for(let i=0;i{Object.defineProperty(t,"__esModule",{value:!0}),t.BufferApiView=void 0;const s=i(3785),r=i(511);t.BufferApiView=class{constructor(e,t){this._buffer=e,this.type=t}init(e){return this._buffer=e,this}get cursorY(){return this._buffer.y}get cursorX(){return this._buffer.x}get viewportY(){return this._buffer.ydisp}get baseY(){return this._buffer.ybase}get length(){return this._buffer.lines.length}getLine(e){const t=this._buffer.lines.get(e);if(t)return new s.BufferLineApiView(t)}getNullCell(){return new r.CellData}}},3785:(e,t,i)=>{Object.defineProperty(t,"__esModule",{value:!0}),t.BufferLineApiView=void 0;const s=i(511);t.BufferLineApiView=class{constructor(e){this._line=e}get isWrapped(){return this._line.isWrapped}get length(){return this._line.length}getCell(e,t){if(!(e<0||e>=this._line.length))return t?(this._line.loadCell(e,t),t):this._line.loadCell(e,new s.CellData)}translateToString(e,t,i){return this._line.translateToString(e,t,i)}}},8285:(e,t,i)=>{Object.defineProperty(t,"__esModule",{value:!0}),t.BufferNamespaceApi=void 0;const s=i(8771),r=i(8460),n=i(844);class o extends n.Disposable{constructor(e){super(),this._core=e,this._onBufferChange=this.register(new r.EventEmitter),this.onBufferChange=this._onBufferChange.event,this._normal=new s.BufferApiView(this._core.buffers.normal,"normal"),this._alternate=new s.BufferApiView(this._core.buffers.alt,"alternate"),this._core.buffers.onBufferActivate((()=>this._onBufferChange.fire(this.active)))}get active(){if(this._core.buffers.active===this._core.buffers.normal)return this.normal;if(this._core.buffers.active===this._core.buffers.alt)return this.alternate;throw new Error("Active buffer is neither normal nor alternate")}get normal(){return this._normal.init(this._core.buffers.normal)}get alternate(){return this._alternate.init(this._core.buffers.alt)}}t.BufferNamespaceApi=o},7975:(e,t)=>{Object.defineProperty(t,"__esModule",{value:!0}),t.ParserApi=void 0,t.ParserApi=class{constructor(e){this._core=e}registerCsiHandler(e,t){return this._core.registerCsiHandler(e,(e=>t(e.toArray())))}addCsiHandler(e,t){return this.registerCsiHandler(e,t)}registerDcsHandler(e,t){return this._core.registerDcsHandler(e,((e,i)=>t(e,i.toArray())))}addDcsHandler(e,t){return this.registerDcsHandler(e,t)}registerEscHandler(e,t){return this._core.registerEscHandler(e,t)}addEscHandler(e,t){return this.registerEscHandler(e,t)}registerOscHandler(e,t){return this._core.registerOscHandler(e,t)}addOscHandler(e,t){return this.registerOscHandler(e,t)}}},7090:(e,t)=>{Object.defineProperty(t,"__esModule",{value:!0}),t.UnicodeApi=void 0,t.UnicodeApi=class{constructor(e){this._core=e}register(e){this._core.unicodeService.register(e)}get versions(){return this._core.unicodeService.versions}get activeVersion(){return this._core.unicodeService.activeVersion}set activeVersion(e){this._core.unicodeService.activeVersion=e}}},744:function(e,t,i){var s=this&&this.__decorate||function(e,t,i,s){var r,n=arguments.length,o=n<3?t:null===s?s=Object.getOwnPropertyDescriptor(t,i):s;if("object"==typeof Reflect&&"function"==typeof Reflect.decorate)o=Reflect.decorate(e,t,i,s);else for(var a=e.length-1;a>=0;a--)(r=e[a])&&(o=(n<3?r(o):n>3?r(t,i,o):r(t,i))||o);return n>3&&o&&Object.defineProperty(t,i,o),o},r=this&&this.__param||function(e,t){return function(i,s){t(i,s,e)}};Object.defineProperty(t,"__esModule",{value:!0}),t.BufferService=t.MINIMUM_ROWS=t.MINIMUM_COLS=void 0;const n=i(8460),o=i(844),a=i(5295),h=i(2585);t.MINIMUM_COLS=2,t.MINIMUM_ROWS=1;let c=t.BufferService=class extends o.Disposable{get buffer(){return this.buffers.active}constructor(e){super(),this.isUserScrolling=!1,this._onResize=this.register(new n.EventEmitter),this.onResize=this._onResize.event,this._onScroll=this.register(new n.EventEmitter),this.onScroll=this._onScroll.event,this.cols=Math.max(e.rawOptions.cols||0,t.MINIMUM_COLS),this.rows=Math.max(e.rawOptions.rows||0,t.MINIMUM_ROWS),this.buffers=this.register(new a.BufferSet(e,this))}resize(e,t){this.cols=e,this.rows=t,this.buffers.resize(e,t),this._onResize.fire({cols:e,rows:t})}reset(){this.buffers.reset(),this.isUserScrolling=!1}scroll(e,t=!1){const i=this.buffer;let s;s=this._cachedBlankLine,s&&s.length===this.cols&&s.getFg(0)===e.fg&&s.getBg(0)===e.bg||(s=i.getBlankLine(e,t),this._cachedBlankLine=s),s.isWrapped=t;const r=i.ybase+i.scrollTop,n=i.ybase+i.scrollBottom;if(0===i.scrollTop){const e=i.lines.isFull;n===i.lines.length-1?e?i.lines.recycle().copyFrom(s):i.lines.push(s.clone()):i.lines.splice(n+1,0,s.clone()),e?this.isUserScrolling&&(i.ydisp=Math.max(i.ydisp-1,0)):(i.ybase++,this.isUserScrolling||i.ydisp++)}else{const e=n-r+1;i.lines.shiftElements(r+1,e-1,-1),i.lines.set(n,s.clone())}this.isUserScrolling||(i.ydisp=i.ybase),this._onScroll.fire(i.ydisp)}scrollLines(e,t,i){const s=this.buffer;if(e<0){if(0===s.ydisp)return;this.isUserScrolling=!0}else e+s.ydisp>=s.ybase&&(this.isUserScrolling=!1);const r=s.ydisp;s.ydisp=Math.max(Math.min(s.ydisp+e,s.ybase),0),r!==s.ydisp&&(t||this._onScroll.fire(s.ydisp))}};t.BufferService=c=s([r(0,h.IOptionsService)],c)},7994:(e,t)=>{Object.defineProperty(t,"__esModule",{value:!0}),t.CharsetService=void 0,t.CharsetService=class{constructor(){this.glevel=0,this._charsets=[]}reset(){this.charset=void 0,this._charsets=[],this.glevel=0}setgLevel(e){this.glevel=e,this.charset=this._charsets[e]}setgCharset(e,t){this._charsets[e]=t,this.glevel===e&&(this.charset=t)}}},1753:function(e,t,i){var s=this&&this.__decorate||function(e,t,i,s){var r,n=arguments.length,o=n<3?t:null===s?s=Object.getOwnPropertyDescriptor(t,i):s;if("object"==typeof Reflect&&"function"==typeof Reflect.decorate)o=Reflect.decorate(e,t,i,s);else for(var a=e.length-1;a>=0;a--)(r=e[a])&&(o=(n<3?r(o):n>3?r(t,i,o):r(t,i))||o);return n>3&&o&&Object.defineProperty(t,i,o),o},r=this&&this.__param||function(e,t){return function(i,s){t(i,s,e)}};Object.defineProperty(t,"__esModule",{value:!0}),t.CoreMouseService=void 0;const n=i(2585),o=i(8460),a=i(844),h={NONE:{events:0,restrict:()=>!1},X10:{events:1,restrict:e=>4!==e.button&&1===e.action&&(e.ctrl=!1,e.alt=!1,e.shift=!1,!0)},VT200:{events:19,restrict:e=>32!==e.action},DRAG:{events:23,restrict:e=>32!==e.action||3!==e.button},ANY:{events:31,restrict:e=>!0}};function c(e,t){let i=(e.ctrl?16:0)|(e.shift?4:0)|(e.alt?8:0);return 4===e.button?(i|=64,i|=e.action):(i|=3&e.button,4&e.button&&(i|=64),8&e.button&&(i|=128),32===e.action?i|=32:0!==e.action||t||(i|=3)),i}const l=String.fromCharCode,d={DEFAULT:e=>{const t=[c(e,!1)+32,e.col+32,e.row+32];return t[0]>255||t[1]>255||t[2]>255?"":`${l(t[0])}${l(t[1])}${l(t[2])}`},SGR:e=>{const t=0===e.action&&4!==e.button?"m":"M";return`[<${c(e,!0)};${e.col};${e.row}${t}`},SGR_PIXELS:e=>{const t=0===e.action&&4!==e.button?"m":"M";return`[<${c(e,!0)};${e.x};${e.y}${t}`}};let _=t.CoreMouseService=class extends a.Disposable{constructor(e,t){super(),this._bufferService=e,this._coreService=t,this._protocols={},this._encodings={},this._activeProtocol="",this._activeEncoding="",this._lastEvent=null,this._onProtocolChange=this.register(new o.EventEmitter),this.onProtocolChange=this._onProtocolChange.event;for(const e of Object.keys(h))this.addProtocol(e,h[e]);for(const e of Object.keys(d))this.addEncoding(e,d[e]);this.reset()}addProtocol(e,t){this._protocols[e]=t}addEncoding(e,t){this._encodings[e]=t}get activeProtocol(){return this._activeProtocol}get areMouseEventsActive(){return 0!==this._protocols[this._activeProtocol].events}set activeProtocol(e){if(!this._protocols[e])throw new Error(`unknown protocol "${e}"`);this._activeProtocol=e,this._onProtocolChange.fire(this._protocols[e].events)}get activeEncoding(){return this._activeEncoding}set activeEncoding(e){if(!this._encodings[e])throw new Error(`unknown encoding "${e}"`);this._activeEncoding=e}reset(){this.activeProtocol="NONE",this.activeEncoding="DEFAULT",this._lastEvent=null}triggerMouseEvent(e){if(e.col<0||e.col>=this._bufferService.cols||e.row<0||e.row>=this._bufferService.rows)return!1;if(4===e.button&&32===e.action)return!1;if(3===e.button&&32!==e.action)return!1;if(4!==e.button&&(2===e.action||3===e.action))return!1;if(e.col++,e.row++,32===e.action&&this._lastEvent&&this._equalEvents(this._lastEvent,e,"SGR_PIXELS"===this._activeEncoding))return!1;if(!this._protocols[this._activeProtocol].restrict(e))return!1;const t=this._encodings[this._activeEncoding](e);return t&&("DEFAULT"===this._activeEncoding?this._coreService.triggerBinaryEvent(t):this._coreService.triggerDataEvent(t,!0)),this._lastEvent=e,!0}explainEvents(e){return{down:!!(1&e),up:!!(2&e),drag:!!(4&e),move:!!(8&e),wheel:!!(16&e)}}_equalEvents(e,t,i){if(i){if(e.x!==t.x)return!1;if(e.y!==t.y)return!1}else{if(e.col!==t.col)return!1;if(e.row!==t.row)return!1}return e.button===t.button&&e.action===t.action&&e.ctrl===t.ctrl&&e.alt===t.alt&&e.shift===t.shift}};t.CoreMouseService=_=s([r(0,n.IBufferService),r(1,n.ICoreService)],_)},6975:function(e,t,i){var s=this&&this.__decorate||function(e,t,i,s){var r,n=arguments.length,o=n<3?t:null===s?s=Object.getOwnPropertyDescriptor(t,i):s;if("object"==typeof Reflect&&"function"==typeof Reflect.decorate)o=Reflect.decorate(e,t,i,s);else for(var a=e.length-1;a>=0;a--)(r=e[a])&&(o=(n<3?r(o):n>3?r(t,i,o):r(t,i))||o);return n>3&&o&&Object.defineProperty(t,i,o),o},r=this&&this.__param||function(e,t){return function(i,s){t(i,s,e)}};Object.defineProperty(t,"__esModule",{value:!0}),t.CoreService=void 0;const n=i(1439),o=i(8460),a=i(844),h=i(2585),c=Object.freeze({insertMode:!1}),l=Object.freeze({applicationCursorKeys:!1,applicationKeypad:!1,bracketedPasteMode:!1,origin:!1,reverseWraparound:!1,sendFocus:!1,wraparound:!0});let d=t.CoreService=class extends a.Disposable{constructor(e,t,i){super(),this._bufferService=e,this._logService=t,this._optionsService=i,this.isCursorInitialized=!1,this.isCursorHidden=!1,this._onData=this.register(new o.EventEmitter),this.onData=this._onData.event,this._onUserInput=this.register(new o.EventEmitter),this.onUserInput=this._onUserInput.event,this._onBinary=this.register(new o.EventEmitter),this.onBinary=this._onBinary.event,this._onRequestScrollToBottom=this.register(new o.EventEmitter),this.onRequestScrollToBottom=this._onRequestScrollToBottom.event,this.modes=(0,n.clone)(c),this.decPrivateModes=(0,n.clone)(l)}reset(){this.modes=(0,n.clone)(c),this.decPrivateModes=(0,n.clone)(l)}triggerDataEvent(e,t=!1){if(this._optionsService.rawOptions.disableStdin)return;const i=this._bufferService.buffer;t&&this._optionsService.rawOptions.scrollOnUserInput&&i.ybase!==i.ydisp&&this._onRequestScrollToBottom.fire(),t&&this._onUserInput.fire(),this._logService.debug(`sending data "${e}"`,(()=>e.split("").map((e=>e.charCodeAt(0))))),this._onData.fire(e)}triggerBinaryEvent(e){this._optionsService.rawOptions.disableStdin||(this._logService.debug(`sending binary "${e}"`,(()=>e.split("").map((e=>e.charCodeAt(0))))),this._onBinary.fire(e))}};t.CoreService=d=s([r(0,h.IBufferService),r(1,h.ILogService),r(2,h.IOptionsService)],d)},9074:(e,t,i)=>{Object.defineProperty(t,"__esModule",{value:!0}),t.DecorationService=void 0;const s=i(8055),r=i(8460),n=i(844),o=i(6106);let a=0,h=0;class c extends n.Disposable{get decorations(){return this._decorations.values()}constructor(){super(),this._decorations=new o.SortedList((e=>null==e?void 0:e.marker.line)),this._onDecorationRegistered=this.register(new r.EventEmitter),this.onDecorationRegistered=this._onDecorationRegistered.event,this._onDecorationRemoved=this.register(new r.EventEmitter),this.onDecorationRemoved=this._onDecorationRemoved.event,this.register((0,n.toDisposable)((()=>this.reset())))}registerDecoration(e){if(e.marker.isDisposed)return;const t=new l(e);if(t){const e=t.marker.onDispose((()=>t.dispose()));t.onDispose((()=>{t&&(this._decorations.delete(t)&&this._onDecorationRemoved.fire(t),e.dispose())})),this._decorations.insert(t),this._onDecorationRegistered.fire(t)}return t}reset(){for(const e of this._decorations.values())e.dispose();this._decorations.clear()}*getDecorationsAtCell(e,t,i){var s,r,n;let o=0,a=0;for(const h of this._decorations.getKeyIterator(t))o=null!==(s=h.options.x)&&void 0!==s?s:0,a=o+(null!==(r=h.options.width)&&void 0!==r?r:1),e>=o&&e{var r,n,o;a=null!==(r=t.options.x)&&void 0!==r?r:0,h=a+(null!==(n=t.options.width)&&void 0!==n?n:1),e>=a&&e{Object.defineProperty(t,"__esModule",{value:!0}),t.InstantiationService=t.ServiceCollection=void 0;const s=i(2585),r=i(8343);class n{constructor(...e){this._entries=new Map;for(const[t,i]of e)this.set(t,i)}set(e,t){const i=this._entries.get(e);return this._entries.set(e,t),i}forEach(e){for(const[t,i]of this._entries.entries())e(t,i)}has(e){return this._entries.has(e)}get(e){return this._entries.get(e)}}t.ServiceCollection=n,t.InstantiationService=class{constructor(){this._services=new n,this._services.set(s.IInstantiationService,this)}setService(e,t){this._services.set(e,t)}getService(e){return this._services.get(e)}createInstance(e,...t){const i=(0,r.getServiceDependencies)(e).sort(((e,t)=>e.index-t.index)),s=[];for(const t of i){const i=this._services.get(t.id);if(!i)throw new Error(`[createInstance] ${e.name} depends on UNKNOWN service ${t.id}.`);s.push(i)}const n=i.length>0?i[0].index:t.length;if(t.length!==n)throw new Error(`[createInstance] First service dependency of ${e.name} at position ${n+1} conflicts with ${t.length} static arguments`);return new e(...[...t,...s])}}},7866:function(e,t,i){var s=this&&this.__decorate||function(e,t,i,s){var r,n=arguments.length,o=n<3?t:null===s?s=Object.getOwnPropertyDescriptor(t,i):s;if("object"==typeof Reflect&&"function"==typeof Reflect.decorate)o=Reflect.decorate(e,t,i,s);else for(var a=e.length-1;a>=0;a--)(r=e[a])&&(o=(n<3?r(o):n>3?r(t,i,o):r(t,i))||o);return n>3&&o&&Object.defineProperty(t,i,o),o},r=this&&this.__param||function(e,t){return function(i,s){t(i,s,e)}};Object.defineProperty(t,"__esModule",{value:!0}),t.traceCall=t.setTraceLogger=t.LogService=void 0;const n=i(844),o=i(2585),a={trace:o.LogLevelEnum.TRACE,debug:o.LogLevelEnum.DEBUG,info:o.LogLevelEnum.INFO,warn:o.LogLevelEnum.WARN,error:o.LogLevelEnum.ERROR,off:o.LogLevelEnum.OFF};let h,c=t.LogService=class extends n.Disposable{get logLevel(){return this._logLevel}constructor(e){super(),this._optionsService=e,this._logLevel=o.LogLevelEnum.OFF,this._updateLogLevel(),this.register(this._optionsService.onSpecificOptionChange("logLevel",(()=>this._updateLogLevel()))),h=this}_updateLogLevel(){this._logLevel=a[this._optionsService.rawOptions.logLevel]}_evalLazyOptionalParams(e){for(let t=0;tJSON.stringify(e))).join(", ")})`);const t=s.apply(this,e);return h.trace(`GlyphRenderer#${s.name} return`,t),t}}},7302:(e,t,i)=>{Object.defineProperty(t,"__esModule",{value:!0}),t.OptionsService=t.DEFAULT_OPTIONS=void 0;const s=i(8460),r=i(844),n=i(6114);t.DEFAULT_OPTIONS={cols:80,rows:24,cursorBlink:!1,cursorStyle:"block",cursorWidth:1,cursorInactiveStyle:"outline",customGlyphs:!0,drawBoldTextInBrightColors:!0,fastScrollModifier:"alt",fastScrollSensitivity:5,fontFamily:"courier-new, courier, monospace",fontSize:15,fontWeight:"normal",fontWeightBold:"bold",ignoreBracketedPasteMode:!1,lineHeight:1,letterSpacing:0,linkHandler:null,logLevel:"info",logger:null,scrollback:1e3,scrollOnUserInput:!0,scrollSensitivity:1,screenReaderMode:!1,smoothScrollDuration:0,macOptionIsMeta:!1,macOptionClickForcesSelection:!1,minimumContrastRatio:1,disableStdin:!1,allowProposedApi:!1,allowTransparency:!1,tabStopWidth:8,theme:{},rightClickSelectsWord:n.isMac,windowOptions:{},windowsMode:!1,windowsPty:{},wordSeparator:" ()[]{}',\"`",altClickMovesCursor:!0,convertEol:!1,termName:"xterm",cancelEvents:!1,overviewRulerWidth:0};const o=["normal","bold","100","200","300","400","500","600","700","800","900"];class a extends r.Disposable{constructor(e){super(),this._onOptionChange=this.register(new s.EventEmitter),this.onOptionChange=this._onOptionChange.event;const i=Object.assign({},t.DEFAULT_OPTIONS);for(const t in e)if(t in i)try{const s=e[t];i[t]=this._sanitizeAndValidateOption(t,s)}catch(e){console.error(e)}this.rawOptions=i,this.options=Object.assign({},i),this._setupOptions()}onSpecificOptionChange(e,t){return this.onOptionChange((i=>{i===e&&t(this.rawOptions[e])}))}onMultipleOptionChange(e,t){return this.onOptionChange((i=>{-1!==e.indexOf(i)&&t()}))}_setupOptions(){const e=e=>{if(!(e in t.DEFAULT_OPTIONS))throw new Error(`No option with key "${e}"`);return this.rawOptions[e]},i=(e,i)=>{if(!(e in t.DEFAULT_OPTIONS))throw new Error(`No option with key "${e}"`);i=this._sanitizeAndValidateOption(e,i),this.rawOptions[e]!==i&&(this.rawOptions[e]=i,this._onOptionChange.fire(e))};for(const t in this.rawOptions){const s={get:e.bind(this,t),set:i.bind(this,t)};Object.defineProperty(this.options,t,s)}}_sanitizeAndValidateOption(e,i){switch(e){case"cursorStyle":if(i||(i=t.DEFAULT_OPTIONS[e]),!function(e){return"block"===e||"underline"===e||"bar"===e}(i))throw new Error(`"${i}" is not a valid value for ${e}`);break;case"wordSeparator":i||(i=t.DEFAULT_OPTIONS[e]);break;case"fontWeight":case"fontWeightBold":if("number"==typeof i&&1<=i&&i<=1e3)break;i=o.includes(i)?i:t.DEFAULT_OPTIONS[e];break;case"cursorWidth":i=Math.floor(i);case"lineHeight":case"tabStopWidth":if(i<1)throw new Error(`${e} cannot be less than 1, value: ${i}`);break;case"minimumContrastRatio":i=Math.max(1,Math.min(21,Math.round(10*i)/10));break;case"scrollback":if((i=Math.min(i,4294967295))<0)throw new Error(`${e} cannot be less than 0, value: ${i}`);break;case"fastScrollSensitivity":case"scrollSensitivity":if(i<=0)throw new Error(`${e} cannot be less than or equal to 0, value: ${i}`);break;case"rows":case"cols":if(!i&&0!==i)throw new Error(`${e} must be numeric, value: ${i}`);break;case"windowsPty":i=null!=i?i:{}}return i}}t.OptionsService=a},2660:function(e,t,i){var s=this&&this.__decorate||function(e,t,i,s){var r,n=arguments.length,o=n<3?t:null===s?s=Object.getOwnPropertyDescriptor(t,i):s;if("object"==typeof Reflect&&"function"==typeof Reflect.decorate)o=Reflect.decorate(e,t,i,s);else for(var a=e.length-1;a>=0;a--)(r=e[a])&&(o=(n<3?r(o):n>3?r(t,i,o):r(t,i))||o);return n>3&&o&&Object.defineProperty(t,i,o),o},r=this&&this.__param||function(e,t){return function(i,s){t(i,s,e)}};Object.defineProperty(t,"__esModule",{value:!0}),t.OscLinkService=void 0;const n=i(2585);let o=t.OscLinkService=class{constructor(e){this._bufferService=e,this._nextId=1,this._entriesWithId=new Map,this._dataByLinkId=new Map}registerLink(e){const t=this._bufferService.buffer;if(void 0===e.id){const i=t.addMarker(t.ybase+t.y),s={data:e,id:this._nextId++,lines:[i]};return i.onDispose((()=>this._removeMarkerFromLink(s,i))),this._dataByLinkId.set(s.id,s),s.id}const i=e,s=this._getEntryIdKey(i),r=this._entriesWithId.get(s);if(r)return this.addLineToLink(r.id,t.ybase+t.y),r.id;const n=t.addMarker(t.ybase+t.y),o={id:this._nextId++,key:this._getEntryIdKey(i),data:i,lines:[n]};return n.onDispose((()=>this._removeMarkerFromLink(o,n))),this._entriesWithId.set(o.key,o),this._dataByLinkId.set(o.id,o),o.id}addLineToLink(e,t){const i=this._dataByLinkId.get(e);if(i&&i.lines.every((e=>e.line!==t))){const e=this._bufferService.buffer.addMarker(t);i.lines.push(e),e.onDispose((()=>this._removeMarkerFromLink(i,e)))}}getLinkData(e){var t;return null===(t=this._dataByLinkId.get(e))||void 0===t?void 0:t.data}_getEntryIdKey(e){return`${e.id};;${e.uri}`}_removeMarkerFromLink(e,t){const i=e.lines.indexOf(t);-1!==i&&(e.lines.splice(i,1),0===e.lines.length&&(void 0!==e.data.id&&this._entriesWithId.delete(e.key),this._dataByLinkId.delete(e.id)))}};t.OscLinkService=o=s([r(0,n.IBufferService)],o)},8343:(e,t)=>{Object.defineProperty(t,"__esModule",{value:!0}),t.createDecorator=t.getServiceDependencies=t.serviceRegistry=void 0;const i="di$target",s="di$dependencies";t.serviceRegistry=new Map,t.getServiceDependencies=function(e){return e[s]||[]},t.createDecorator=function(e){if(t.serviceRegistry.has(e))return t.serviceRegistry.get(e);const r=function(e,t,n){if(3!==arguments.length)throw new Error("@IServiceName-decorator can only be used to decorate a parameter");!function(e,t,r){t[i]===t?t[s].push({id:e,index:r}):(t[s]=[{id:e,index:r}],t[i]=t)}(r,e,n)};return r.toString=()=>e,t.serviceRegistry.set(e,r),r}},2585:(e,t,i)=>{Object.defineProperty(t,"__esModule",{value:!0}),t.IDecorationService=t.IUnicodeService=t.IOscLinkService=t.IOptionsService=t.ILogService=t.LogLevelEnum=t.IInstantiationService=t.ICharsetService=t.ICoreService=t.ICoreMouseService=t.IBufferService=void 0;const s=i(8343);var r;t.IBufferService=(0,s.createDecorator)("BufferService"),t.ICoreMouseService=(0,s.createDecorator)("CoreMouseService"),t.ICoreService=(0,s.createDecorator)("CoreService"),t.ICharsetService=(0,s.createDecorator)("CharsetService"),t.IInstantiationService=(0,s.createDecorator)("InstantiationService"),function(e){e[e.TRACE=0]="TRACE",e[e.DEBUG=1]="DEBUG",e[e.INFO=2]="INFO",e[e.WARN=3]="WARN",e[e.ERROR=4]="ERROR",e[e.OFF=5]="OFF"}(r||(t.LogLevelEnum=r={})),t.ILogService=(0,s.createDecorator)("LogService"),t.IOptionsService=(0,s.createDecorator)("OptionsService"),t.IOscLinkService=(0,s.createDecorator)("OscLinkService"),t.IUnicodeService=(0,s.createDecorator)("UnicodeService"),t.IDecorationService=(0,s.createDecorator)("DecorationService")},1480:(e,t,i)=>{Object.defineProperty(t,"__esModule",{value:!0}),t.UnicodeService=void 0;const s=i(8460),r=i(225);t.UnicodeService=class{constructor(){this._providers=Object.create(null),this._active="",this._onChange=new s.EventEmitter,this.onChange=this._onChange.event;const e=new r.UnicodeV6;this.register(e),this._active=e.version,this._activeProvider=e}dispose(){this._onChange.dispose()}get versions(){return Object.keys(this._providers)}get activeVersion(){return this._active}set activeVersion(e){if(!this._providers[e])throw new Error(`unknown Unicode version "${e}"`);this._active=e,this._activeProvider=this._providers[e],this._onChange.fire(e)}register(e){this._providers[e.version]=e}wcwidth(e){return this._activeProvider.wcwidth(e)}getStringCellWidth(e){let t=0;const i=e.length;for(let s=0;s=i)return t+this.wcwidth(r);const n=e.charCodeAt(s);56320<=n&&n<=57343?r=1024*(r-55296)+n-56320+65536:t+=this.wcwidth(n)}t+=this.wcwidth(r)}return t}}}},t={};function i(s){var r=t[s];if(void 0!==r)return r.exports;var n=t[s]={exports:{}};return e[s].call(n.exports,n,n.exports,i),n.exports}var s={};return(()=>{var e=s;Object.defineProperty(e,"__esModule",{value:!0}),e.Terminal=void 0;const t=i(9042),r=i(3236),n=i(844),o=i(5741),a=i(8285),h=i(7975),c=i(7090),l=["cols","rows"];class d extends n.Disposable{constructor(e){super(),this._core=this.register(new r.Terminal(e)),this._addonManager=this.register(new o.AddonManager),this._publicOptions=Object.assign({},this._core.options);const t=e=>this._core.options[e],i=(e,t)=>{this._checkReadonlyOptions(e),this._core.options[e]=t};for(const e in this._core.options){const s={get:t.bind(this,e),set:i.bind(this,e)};Object.defineProperty(this._publicOptions,e,s)}}_checkReadonlyOptions(e){if(l.includes(e))throw new Error(`Option "${e}" can only be set in the constructor`)}_checkProposedApi(){if(!this._core.optionsService.rawOptions.allowProposedApi)throw new Error("You must set the allowProposedApi option to true to use proposed API")}get onBell(){return this._core.onBell}get onBinary(){return this._core.onBinary}get onCursorMove(){return this._core.onCursorMove}get onData(){return this._core.onData}get onKey(){return this._core.onKey}get onLineFeed(){return this._core.onLineFeed}get onRender(){return this._core.onRender}get onResize(){return this._core.onResize}get onScroll(){return this._core.onScroll}get onSelectionChange(){return this._core.onSelectionChange}get onTitleChange(){return this._core.onTitleChange}get onWriteParsed(){return this._core.onWriteParsed}get element(){return this._core.element}get parser(){return this._parser||(this._parser=new h.ParserApi(this._core)),this._parser}get unicode(){return this._checkProposedApi(),new c.UnicodeApi(this._core)}get textarea(){return this._core.textarea}get rows(){return this._core.rows}get cols(){return this._core.cols}get buffer(){return this._buffer||(this._buffer=this.register(new a.BufferNamespaceApi(this._core))),this._buffer}get markers(){return this._checkProposedApi(),this._core.markers}get modes(){const e=this._core.coreService.decPrivateModes;let t="none";switch(this._core.coreMouseService.activeProtocol){case"X10":t="x10";break;case"VT200":t="vt200";break;case"DRAG":t="drag";break;case"ANY":t="any"}return{applicationCursorKeysMode:e.applicationCursorKeys,applicationKeypadMode:e.applicationKeypad,bracketedPasteMode:e.bracketedPasteMode,insertMode:this._core.coreService.modes.insertMode,mouseTrackingMode:t,originMode:e.origin,reverseWraparoundMode:e.reverseWraparound,sendFocusMode:e.sendFocus,wraparoundMode:e.wraparound}}get options(){return this._publicOptions}set options(e){for(const t in e)this._publicOptions[t]=e[t]}blur(){this._core.blur()}focus(){this._core.focus()}resize(e,t){this._verifyIntegers(e,t),this._core.resize(e,t)}open(e){this._core.open(e)}attachCustomKeyEventHandler(e){this._core.attachCustomKeyEventHandler(e)}registerLinkProvider(e){return this._core.registerLinkProvider(e)}registerCharacterJoiner(e){return this._checkProposedApi(),this._core.registerCharacterJoiner(e)}deregisterCharacterJoiner(e){this._checkProposedApi(),this._core.deregisterCharacterJoiner(e)}registerMarker(e=0){return this._verifyIntegers(e),this._core.registerMarker(e)}registerDecoration(e){var t,i,s;return this._checkProposedApi(),this._verifyPositiveIntegers(null!==(t=e.x)&&void 0!==t?t:0,null!==(i=e.width)&&void 0!==i?i:0,null!==(s=e.height)&&void 0!==s?s:0),this._core.registerDecoration(e)}hasSelection(){return this._core.hasSelection()}select(e,t,i){this._verifyIntegers(e,t,i),this._core.select(e,t,i)}getSelection(){return this._core.getSelection()}getSelectionPosition(){return this._core.getSelectionPosition()}clearSelection(){this._core.clearSelection()}selectAll(){this._core.selectAll()}selectLines(e,t){this._verifyIntegers(e,t),this._core.selectLines(e,t)}dispose(){super.dispose()}scrollLines(e){this._verifyIntegers(e),this._core.scrollLines(e)}scrollPages(e){this._verifyIntegers(e),this._core.scrollPages(e)}scrollToTop(){this._core.scrollToTop()}scrollToBottom(){this._core.scrollToBottom()}scrollToLine(e){this._verifyIntegers(e),this._core.scrollToLine(e)}clear(){this._core.clear()}write(e,t){this._core.write(e,t)}writeln(e,t){this._core.write(e),this._core.write("\r\n",t)}paste(e){this._core.paste(e)}refresh(e,t){this._verifyIntegers(e,t),this._core.refresh(e,t)}reset(){this._core.reset()}clearTextureAtlas(){this._core.clearTextureAtlas()}loadAddon(e){this._addonManager.loadAddon(this,e)}static get strings(){return t}_verifyIntegers(...e){for(const t of e)if(t===1/0||isNaN(t)||t%1!=0)throw new Error("This API only accepts integers")}_verifyPositiveIntegers(...e){for(const t of e)if(t&&(t===1/0||isNaN(t)||t%1!=0||t<0))throw new Error("This API only accepts positive integers")}}e.Terminal=d})(),s})())); +//# sourceMappingURL=xterm.js.map \ No newline at end of file diff --git a/02_ruway/shuma/shuma-shell-llimphi/Cargo.toml b/02_ruway/shuma/shuma-shell-llimphi/Cargo.toml index b2fcbf8..c2fc536 100644 --- a/02_ruway/shuma/shuma-shell-llimphi/Cargo.toml +++ b/02_ruway/shuma/shuma-shell-llimphi/Cargo.toml @@ -13,6 +13,7 @@ name = "shuma-shell-llimphi" path = "src/main.rs" [dependencies] +bitacora = { workspace = true } shuma-module = { path = "../sandbox/shuma-module" } shuma-module-commandbar = { path = "../sandbox/shuma-module-commandbar" } shuma-module-launcher = { path = "../sandbox/shuma-module-launcher" } @@ -21,26 +22,93 @@ matilda-core = { path = "../baremetal/matilda-core" } shuma-module-minga = { path = "../sandbox/shuma-module-minga" } minga-core = { workspace = true } shuma-module-shell = { path = "../sandbox/shuma-module-shell" } +shuma-config = { path = "../sandbox/shuma-config" } shuma-module-canvas = { path = "../sandbox/shuma-module-canvas" } +shuma-module-agente = { path = "../sandbox/shuma-module-agente" } +shuma-agente = { path = "../sandbox/shuma-agente" } +# Cadena forense (#2): cada acción de IA que el humano aprueba se registra como +# Estado autor=Ia en el hilo del device (vía el daemon willay). +willay-checkpoint = { workspace = true } +willay-emit = { workspace = true } +# Centro de eventos federado: la marquesina narra las notificaciones (y demás +# eventos) del índice willay en el input en reposo, alternativa a los popups. +willay-core = { workspace = true } +blake3 = { workspace = true } +# Voz manos-libres: captura de micrófono + lazo VAD→STT→máquina. La feature +# `microfono` (default-on) trae cpal; el host la corre con la config de wawa-panel. +rimay-voz-host = { workspace = true } +rimay-voz = { workspace = true } +shuma-agente-host = { path = "../sandbox/shuma-agente-host" } shuma-intent = { path = "../sandbox/shuma-intent" } shuma-sysmon = { path = "../sandbox/shuma-sysmon" } pata-host = { workspace = true } llimphi-ui = { workspace = true } +# Modo dock: correr shuma como barra wlr-layer-shell anclada a un borde. +llimphi-layer = { workspace = true } llimphi-theme = { workspace = true } -llimphi-widget-tabs = { workspace = true } +llimphi-image = { workspace = true } +# Fondo procedural propio de shuma: el patrón animado "párpados" (el mismo del +# compositor) generado a un buffer RGBA, pero mucho más lento y tenue para que +# viva en el fondo sin competir con el texto. Sin imágenes ni shaders. +mirada-procedural = { workspace = true } +# Rail de dientes (el último widget): las tabs del shell (shell/lienzo/matilda) y +# el toggle de monitores viven como dientes en una franja vertical a la derecha, +# reemplazando la vieja tira horizontal de tabs. +llimphi-widget-dock-rail = { workspace = true } +# Sidebar unificado (rail + panel + cabezal + buscador + control): los DOS +# sidebars del chasis (sesiones a la izquierda, herramientas a la derecha) lo +# usan, en vez de montar cada uno su chrome paralelo. +llimphi-widget-rag-sidebar = { workspace = true } llimphi-widget-splitter = { workspace = true } +llimphi-widget-scroll = { workspace = true } +# Árbol de paneles BSP (tiling tipo zellij) del canvas de cada sesión. +llimphi-widget-panes = { workspace = true } llimphi-widget-stat-card = { workspace = true } +# Modernización visual: skeleton shimmer mientras el Explorer remoto lista, +# empty-state del historial vacío, e iconos vectoriales para ese empty-state. +llimphi-widget-skeleton = { workspace = true } +llimphi-widget-empty = { workspace = true } +llimphi-icons = { workspace = true } llimphi-widget-menubar = { workspace = true } llimphi-widget-context-menu = { workspace = true } +# Dropdown/select para la config de la sesión (aislamiento + distro). +llimphi-widget-select = { workspace = true } +# Diálogos bloqueantes (centrados con scrim) para crear host/contenedor desde +# el form de sesión nueva — reemplazan las viejas ventanas secundarias. +llimphi-widget-modal = { workspace = true } +# Campos del form de conexión remota (host/usuario/puerto). +llimphi-widget-text-input = { workspace = true } +# Portapapeles del sistema para copiar/cortar/pegar en los campos de los modales. +llimphi-clipboard = { workspace = true } llimphi-motion = { workspace = true } +chrono = { workspace = true } app-bus = { workspace = true } +# Open-with por contenido: discierne el tipo del archivo (clic en el Explorer) +# y lo abre con el visor de la suite vía app-bus, como hace nahual. +shuma-discern = { path = "../sandbox/shuma-discern" } serde = { workspace = true } serde_json = { workspace = true } +ron = { workspace = true } toml = { workspace = true } directories = { workspace = true } rimay-localize = { workspace = true } wawa-config = { workspace = true } wawa-config-llimphi = { workspace = true } +# E5 — LLM como instrumento invocado (`:?`/`:explica`/`:resume`). El módulo +# sólo expresa la intención; el chasis corre pluma-llm en un thread. +pluma-llm = { workspace = true } +# Búsqueda semántica (`:buscar`): embeddings por el daemon rimay-verbo, con +# fallback a mock determinista si no hay daemon. El chasis embebe en un thread. +rimay-verbo = { workspace = true } +rimay-verbo-index = { workspace = true } +# Índice semántico persistido (embeddings en disco, formato nativo postcard) — +# evita re-embeber el corpus en cada `:buscar`. +postcard = { workspace = true } +tokio = { workspace = true } [dev-dependencies] tempfile = { workspace = true } +# Render headless del pantallazo del workspace (tabs + tiling + flotantes). +pollster = { workspace = true } +png = { workspace = true } +llimphi-widget-panes = { workspace = true } diff --git a/02_ruway/shuma/shuma-shell-llimphi/LEEME.md b/02_ruway/shuma/shuma-shell-llimphi/LEEME.md index 7ab4b62..ca032a3 100644 --- a/02_ruway/shuma/shuma-shell-llimphi/LEEME.md +++ b/02_ruway/shuma/shuma-shell-llimphi/LEEME.md @@ -12,4 +12,4 @@ cargo run --release -p shuma-shell-llimphi ## Deps -- Todos los `shuma-module-*`, [`shuma-shell-render`](../shuma-shell-render/README.md) +- Todos los `shuma-module-*`, [`shuma-shell-render`](../sandbox/shuma-shell-render/README.md) diff --git a/02_ruway/shuma/shuma-shell-llimphi/examples/pantallazo_shuma.rs b/02_ruway/shuma/shuma-shell-llimphi/examples/pantallazo_shuma.rs new file mode 100644 index 0000000..00714f1 --- /dev/null +++ b/02_ruway/shuma/shuma-shell-llimphi/examples/pantallazo_shuma.rs @@ -0,0 +1,196 @@ +//! Pantallazo headless de shuma con el **workspace tipo zellij** activo. +//! +//! Monta la `view()` real de la app (chasis completo: menubar, topbar, canvas +//! con tabs + tiling + flotantes, bottombar) sobre un `Model` sembrado vía la +//! API pública (`new_model` + `update` con `Handle::for_test`): la sesión +//! activa tiene tres paneles tiled, un panel flotante encima y una segunda tab. +//! Así el shot certifica lo que los tests no pueden — que el layout realmente +//! pinta paneles lado a lado + el flotante superpuesto + la barra de tabs. +//! +//! `cargo run -p shuma-shell-llimphi --example pantallazo_shuma --release -- [out.png]` +#![allow(dead_code)] + +use std::fs::File; +use std::io::BufWriter; + +use llimphi_ui::llimphi_hal::{wgpu, Hal}; +use llimphi_ui::llimphi_layout::taffy; +use llimphi_ui::llimphi_layout::LayoutTree; +use llimphi_ui::llimphi_raster::peniko::Color; +use llimphi_ui::llimphi_raster::{vello, Renderer}; +use llimphi_ui::llimphi_text::Typesetter; +use llimphi_ui::{measure_text_node, mount, paint, Handle}; +use llimphi_widget_panes::Axis; +use shuma_shell_llimphi::{new_model, update, view, Msg}; + +const W: u32 = 1280; +const H: u32 = 800; +const FMT: wgpu::TextureFormat = wgpu::TextureFormat::Rgba8Unorm; + +fn main() { + rimay_localize::init(); + let _ = rimay_localize::set_locale("es"); + + let out = std::env::args() + .nth(1) + .unwrap_or_else(|| "/tmp/shots/shuma.png".to_string()); + if let Some(dir) = std::path::Path::new(&out).parent() { + std::fs::create_dir_all(dir).ok(); + } + + // Sembrar el workspace por la API pública: tres paneles tiled + un flotante + // en la tab 1, y una segunda tab. Volvemos a la tab 1 para que el canvas la + // muestre con todo el layout. + let handle = Handle::::for_test(); + let mut model = new_model(); + model = update(model, Msg::PaneSplit(Axis::Horizontal), &handle); // 2 paneles + model = update(model, Msg::PaneSplit(Axis::Vertical), &handle); // 3 paneles + model = update(model, Msg::FloatNew, &handle); // + flotante (toma foco) + model = update(model, Msg::TabNew, &handle); // segunda tab + model = update(model, Msg::TabSwitch(0), &handle); // volver a la 1ª + + // Wallpaper opcional (cert. visual del feature): SHUMA_WALLPAPER=/ruta.png + // inyecta la imagen y baja la opacidad del chrome para que asome detrás. + let wp_mode = std::env::var("SHUMA_WALLPAPER").ok().filter(|s| !s.is_empty()); + if let Some(path) = &wp_mode { + match llimphi_image::load_path(std::path::Path::new(path), 64 * 1024 * 1024) { + Ok(img) => { + model.wallpaper_img = Some(img); + let dim = |c: Color, a: f32| { + let k = c.components; + Color::from_rgba8( + (k[0] * 255.0) as u8, + (k[1] * 255.0) as u8, + (k[2] * 255.0) as u8, + (a * 255.0) as u8, + ) + }; + model.theme.bg_app = dim(model.theme.bg_app, 0.5); + model.theme.bg_panel = dim(model.theme.bg_panel, 0.6); + model.theme.bg_panel_alt = dim(model.theme.bg_panel_alt, 0.6); + } + Err(e) => eprintln!("pantallazo_shuma: no pude cargar wallpaper {path}: {e}"), + } + } + + let root = view(&model); + + let mut layout = LayoutTree::new(); + let mounted = mount(&mut layout, root); + let mut ts = Typesetter::new(); + let computed = { + let tmap = &mounted.text_measures; + layout + .compute_with_measure(mounted.root, (W as f32, H as f32), |nid, known, avail| { + match tmap.get(&nid) { + Some(tm) => measure_text_node(&mut ts, tm, known, avail), + None => taffy::Size::ZERO, + } + }) + .expect("layout") + }; + let mut scene = vello::Scene::new(); + paint(&mut scene, &mounted, &computed, &mut ts, None, None); + + let hal = pollster::block_on(Hal::new(None)).expect("hal"); + let mut renderer = Renderer::new(&hal).expect("renderer"); + let target = hal.device.create_texture(&wgpu::TextureDescriptor { + label: Some("pantallazo-shuma"), + size: wgpu::Extent3d { width: W, height: H, depth_or_array_layers: 1 }, + mip_level_count: 1, + sample_count: 1, + dimension: wgpu::TextureDimension::D2, + format: FMT, + usage: wgpu::TextureUsages::STORAGE_BINDING + | wgpu::TextureUsages::RENDER_ATTACHMENT + | wgpu::TextureUsages::COPY_SRC, + view_formats: &[], + }); + let tview = target.create_view(&wgpu::TextureViewDescriptor::default()); + let bg = Color::from_rgba8(0x12, 0x14, 0x18, 255); + renderer + .render_to_view(&hal, &scene, &tview, W, H, bg) + .expect("render_to_view"); + + let rgba = write_png(&hal, &target, &out); + eprintln!("pantallazo_shuma: escrito {out} ({W}x{H})"); + + // Cert. numérica del wallpaper: con un fondo magenta sintético detrás del + // chrome translúcido, deben quedar muchos píxeles magenta-ish compuestos. + if wp_mode.is_some() { + // Detección por TINTE magenta: el chrome oscuro translúcido sobre la + // imagen magenta deja r y b notablemente por encima de g (el dark puro + // tiene r≈g≈b). Robusto al oscurecimiento del overlay. + let mut tint = 0usize; + let mut max_r = 0u8; + for px in rgba.chunks_exact(4) { + let (r, g, b) = (px[0], px[1], px[2]); + if r as i32 > g as i32 + 25 && b as i32 > g as i32 + 25 { + tint += 1; + } + max_r = max_r.max(r); + } + let total = (W * H) as usize; + let pct = tint as f32 * 100.0 / total as f32; + eprintln!( + "pantallazo_shuma: wallpaper tinte-magenta en {tint}/{total} px ({pct:.1}%), max_r={max_r}" + ); + } +} + +fn write_png(hal: &Hal, target: &wgpu::Texture, path: &str) -> Vec { + let unpadded = (W * 4) as usize; + let align = wgpu::COPY_BYTES_PER_ROW_ALIGNMENT as usize; + let padded = unpadded.div_ceil(align) * align; + let buf = hal.device.create_buffer(&wgpu::BufferDescriptor { + label: Some("readback"), + size: (padded * H as usize) as u64, + usage: wgpu::BufferUsages::MAP_READ | wgpu::BufferUsages::COPY_DST, + mapped_at_creation: false, + }); + let mut enc = hal + .device + .create_command_encoder(&wgpu::CommandEncoderDescriptor { label: None }); + enc.copy_texture_to_buffer( + wgpu::TexelCopyTextureInfo { + texture: target, + mip_level: 0, + origin: wgpu::Origin3d::ZERO, + aspect: wgpu::TextureAspect::All, + }, + wgpu::TexelCopyBufferInfo { + buffer: &buf, + layout: wgpu::TexelCopyBufferLayout { + offset: 0, + bytes_per_row: Some(padded as u32), + rows_per_image: Some(H), + }, + }, + wgpu::Extent3d { width: W, height: H, depth_or_array_layers: 1 }, + ); + hal.queue.submit(std::iter::once(enc.finish())); + let slice = buf.slice(..); + let (tx, rx) = std::sync::mpsc::channel(); + slice.map_async(wgpu::MapMode::Read, move |r| { + let _ = tx.send(r); + }); + let _ = hal.device.poll(wgpu::PollType::wait_indefinitely()); + rx.recv().expect("map").expect("map ok"); + let data = slice.get_mapped_range(); + + let mut rgba = Vec::with_capacity((W * H * 4) as usize); + for row in 0..H as usize { + let start = row * padded; + rgba.extend_from_slice(&data[start..start + unpadded]); + } + drop(data); + buf.unmap(); + + let file = File::create(path).expect("png"); + let mut enc = png::Encoder::new(BufWriter::new(file), W, H); + enc.set_color(png::ColorType::Rgba); + enc.set_depth(png::BitDepth::Eight); + let mut w = enc.write_header().expect("png header"); + w.write_image_data(&rgba).expect("png data"); + rgba +} diff --git a/02_ruway/shuma/shuma-shell-llimphi/examples/pestanas_shot.rs b/02_ruway/shuma/shuma-shell-llimphi/examples/pestanas_shot.rs new file mode 100644 index 0000000..1255717 --- /dev/null +++ b/02_ruway/shuma/shuma-shell-llimphi/examples/pestanas_shot.rs @@ -0,0 +1,324 @@ +//! Certificación **numérica** de las pestañas vivas: título de contexto, +//! parpadeo por aviso e hilo de cava. +//! +//! No se mira ninguna imagen (Regla 8 del repo): se rasteriza la `view()` real +//! y se cuentan píxeles en la banda de la barra de tabs. Lo que prueba: +//! +//! 1. **El hilo de cava pinta** — el diff de píxeles entre «sin caudal» y «con +//! caudal» es no vacío y cae en una banda de pocas filas (el hilo, 4 px bajo +//! cada chip), no repartido por toda la pantalla. +//! 2. **El parpadeo cambia la barra** — el mismo modelo con `pulso_fase` en el +//! valle y en la cresta de la respiración difiere. +//! 3. **Los títulos salen del contexto**, no del índice. +//! +//! El diff se cuenta contra el buffer entero: no hay que adivinar dónde cayó la +//! barra, la evidencia dice en qué filas cambió. +//! +//! `cargo run -p shuma-shell-llimphi --example pestanas_shot --release` + +use llimphi_ui::llimphi_hal::{wgpu, Hal}; +use llimphi_ui::llimphi_layout::taffy; +use llimphi_ui::llimphi_layout::LayoutTree; +use llimphi_ui::llimphi_raster::peniko::Color; +use llimphi_ui::llimphi_raster::{vello, Renderer}; +use llimphi_ui::llimphi_text::Typesetter; +use llimphi_ui::{measure_text_node, mount, paint, Handle}; +use shuma_shell_llimphi::{new_model, update, view, Msg}; + +const W: u32 = 1280; +const H: u32 = 800; +const FMT: wgpu::TextureFormat = wgpu::TextureFormat::Rgba8Unorm; + +/// Umbral de canal para considerar que un píxel cambió (por encima del ruido +/// de antialiasing del rasterizador). +const DELTA: i32 = 6; + +fn main() { + rimay_localize::init(); + let _ = rimay_localize::set_locale("es"); + + let handle = Handle::::for_test(); + let mut model = new_model(); + model = update(model, Msg::TabNew, &handle); + model = update(model, Msg::TabNew, &handle); + model = update(model, Msg::TabSwitch(0), &handle); + + // Títulos: sin caudal ni programa corriendo, cada pestaña se rotula con su + // contexto (el cwd del shell), no con "1"/"2"/"3". + let titulos = shuma_shell_llimphi::titulos_de_pestanas(&model); + println!("pestanas_shot: títulos = {titulos:?}"); + assert_eq!(titulos.len(), 3, "tres pestañas"); + assert!( + titulos.iter().all(|t| !t.is_empty() && t.parse::().is_err()), + "los títulos vienen del contexto, no del índice: {titulos:?}" + ); + + let hal = pollster::block_on(Hal::new(None)).expect("hal"); + let mut renderer = Renderer::new(&hal).expect("renderer"); + + // 1) Reposo: sin caudal, el hilo es sólo la línea de base. + let quieto = pintar(&hal, &mut renderer, &model); + + // 2) Con caudal inyectado en todas las pestañas. + shuma_shell_llimphi::sembrar_caudal_de_prueba(&mut model); + let con_cava = pintar(&hal, &mut renderer, &model); + let cava = diff(&quieto, &con_cava); + println!("pestanas_shot: cava → {cava}"); + assert!( + cava.pixeles > 200, + "el hilo de cava tiene que pintar de verdad ({cava})" + ); + assert!( + cava.alto() <= 12, + "el cava debe cambiar SÓLO su franja de 4 px por chip, no media pantalla ({cava})" + ); + + // 3) Parpadeo: valle vs cresta de la respiración, con un aviso levantado. + shuma_shell_llimphi::sembrar_aviso_de_prueba(&mut model); + model.pulso_fase = 0; // valle + let valle = pintar(&hal, &mut renderer, &model); + model.pulso_fase = 5; // cresta (respiración a 1 Hz sobre 10 bins) + let cresta = pintar(&hal, &mut renderer, &model); + let latido = diff(&valle, &cresta); + println!("pestanas_shot: parpadeo → {latido}"); + assert!( + latido.pixeles > 200, + "el parpadeo debe verse entre valle y cresta ({latido})" + ); + assert!( + latido.alto() <= 40, + "el parpadeo vive en los chips de la barra, no en toda la app ({latido})" + ); + + // 4) Ancho FLEXIBLE: una sola pestaña se estira para mostrar su título; con + // muchas se achican todas. Se mide sobre el render real contando la + // columna donde termina la última pestaña de la tira. + let mut sola = new_model(); + shuma_shell_llimphi::renombrar_pestana_de_prueba(&mut sola, "un titulo de contexto bastante largo"); + let ancho_sola = ancho_primera_pestana(&pintar(&hal, &mut renderer, &sola)); + + let mut muchas = sola; + // 16 pestañas: con 8 todavía entraban en la barra, así que no tenían por + // qué ceder — el shot lo dijo antes de que yo lo supusiera. + for _ in 0..15 { + muchas = update(muchas, Msg::TabNew, &handle); + } + // Volver a la primera: se compara chip ACTIVO contra chip ACTIVO. Un chip + // inactivo se pinta con `bg_panel_alt`, a un pelo del fondo de la barra, y + // la medición por color no lo distingue (daba 6 px y parecía un colapso). + muchas = update(muchas, Msg::TabSwitch(0), &handle); + let ancho_apretada = ancho_primera_pestana(&pintar(&hal, &mut renderer, &muchas)); + println!( + "pestanas_shot: ancho del 1er chip — sola={ancho_sola} px, con 16 pestañas={ancho_apretada} px" + ); + assert!( + ancho_sola > 200, + "una pestaña sola se estira para mostrar su título largo ({ancho_sola} px)" + ); + assert!( + ancho_apretada < ancho_sola, + "con la barra llena la primera cede: {ancho_apretada} px vs {ancho_sola} px" + ); + assert!( + ancho_apretada >= 60, + "pero no se vuelve una lasca ilegible: {ancho_apretada} px" + ); + + // 5) La ✕ de cerrar: aparece recién con más de una pestaña (en la última, + // cerrar es no-op y un botón que no hace nada es peor que no tenerlo). + let una = new_model(); + // Volver a la 1ª: se mide chip activo contra chip activo (ver la sección 4). + let dos = update(update(new_model(), Msg::TabNew, &handle), Msg::TabSwitch(0), &handle); + let a_una = ancho_primera_pestana(&pintar(&hal, &mut renderer, &una)); + let a_dos = ancho_primera_pestana(&pintar(&hal, &mut renderer, &dos)); + println!("pestanas_shot: chip sin ✕={a_una} px, con ✕={a_dos} px"); + assert!( + a_dos > a_una, + "con dos pestañas el chip reserva el hueco de la ✕: {a_dos} vs {a_una}" + ); + + println!("pestanas_shot: OK"); +} + +/// Ancho (px) del **primer chip** de pestaña, medido sobre lo pintado. +/// +/// Se recorre la fila del título desde la izquierda: el primer tramo cuyo color +/// se aparta del fondo de la barra es el chip. Medir «hasta el último píxel de +/// la fila» no servía — al fondo a la derecha están los botones de tiling, así +/// que la tira parecía ocupar la pantalla entera siempre. +fn ancho_primera_pestana(rgba: &[u8]) -> usize { + // Fila del medio del título (el chip vive entre y=49 y y=74, según el diff + // de la sección 3 de este mismo shot). + const FILA: u32 = 58; + let px = |x: u32| { + let i = ((FILA * W + x) * 4) as usize; + (rgba[i] as i32, rgba[i + 1] as i32, rgba[i + 2] as i32) + }; + // Referencia: el borde izquierdo de la barra es su propio relleno (padding). + let fondo = px(1); + let dist = |c: (i32, i32, i32)| { + (c.0 - fondo.0).abs() + (c.1 - fondo.1).abs() + (c.2 - fondo.2).abs() + }; + let Some(ini) = (0..W).find(|x| dist(px(*x)) > 12) else { + return 0; + }; + // Fin del chip: vuelve al fondo de la barra y se queda ahí (el gap entre + // pestañas). Un par de píxeles de antialiasing no cuentan como fin. + let mut fin = ini; + let mut seguidos_de_fondo = 0; + for x in ini..W { + if dist(px(x)) > 12 { + fin = x; + seguidos_de_fondo = 0; + } else { + seguidos_de_fondo += 1; + if seguidos_de_fondo >= 3 { + break; + } + } + } + (fin - ini) as usize +} + +/// Resultado de comparar dos renders: cuántos píxeles cambiaron y en qué filas. +struct Diff { + pixeles: usize, + y0: u32, + y1: u32, +} + +impl Diff { + fn alto(&self) -> u32 { + self.y1.saturating_sub(self.y0) + 1 + } +} + +impl std::fmt::Display for Diff { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + if self.pixeles == 0 { + return write!(f, "sin cambios"); + } + write!( + f, + "{} px cambiados, filas y={}..{} (alto {})", + self.pixeles, + self.y0, + self.y1, + self.alto() + ) + } +} + +/// Píxeles que difieren entre dos renders, con la banda de filas afectada. +/// +/// Acotado a las filas del **chrome de arriba**: la command-bar de abajo tiene +/// un caret que late con el reloj de pared, así que dos renders consecutivos +/// difieren ahí por definición. Medir la pantalla entera hacía que el ruido del +/// caret se colara en la medición de las pestañas. +const FILAS_CHROME: u32 = 200; + +fn diff(a: &[u8], b: &[u8]) -> Diff { + let mut pixeles = 0usize; + let (mut y0, mut y1) = (u32::MAX, 0u32); + for y in 0..FILAS_CHROME.min(H) { + for x in 0..W { + let i = ((y * W + x) * 4) as usize; + let cambio = (0..3).any(|k| (a[i + k] as i32 - b[i + k] as i32).abs() > DELTA); + if cambio { + pixeles += 1; + y0 = y0.min(y); + y1 = y1.max(y); + } + } + } + Diff { pixeles, y0: if y0 == u32::MAX { 0 } else { y0 }, y1 } +} + +/// Rasteriza la `view()` real y devuelve el buffer RGBA. +fn pintar(hal: &Hal, renderer: &mut Renderer, model: &shuma_shell_llimphi::Model) -> Vec { + let root = view(model); + let mut layout = LayoutTree::new(); + let mounted = mount(&mut layout, root); + let mut ts = Typesetter::new(); + let computed = { + let tmap = &mounted.text_measures; + layout + .compute_with_measure(mounted.root, (W as f32, H as f32), |nid, known, avail| { + match tmap.get(&nid) { + Some(tm) => measure_text_node(&mut ts, tm, known, avail), + None => taffy::Size::ZERO, + } + }) + .expect("layout") + }; + let mut scene = vello::Scene::new(); + paint(&mut scene, &mounted, &computed, &mut ts, None, None); + + let target = hal.device.create_texture(&wgpu::TextureDescriptor { + label: Some("pestanas-shot"), + size: wgpu::Extent3d { width: W, height: H, depth_or_array_layers: 1 }, + mip_level_count: 1, + sample_count: 1, + dimension: wgpu::TextureDimension::D2, + format: FMT, + usage: wgpu::TextureUsages::STORAGE_BINDING + | wgpu::TextureUsages::RENDER_ATTACHMENT + | wgpu::TextureUsages::COPY_SRC, + view_formats: &[], + }); + let tview = target.create_view(&wgpu::TextureViewDescriptor::default()); + renderer + .render_to_view(hal, &scene, &tview, W, H, Color::from_rgba8(0x12, 0x14, 0x18, 255)) + .expect("render_to_view"); + + leer(hal, &target) +} + +fn leer(hal: &Hal, target: &wgpu::Texture) -> Vec { + let unpadded = (W * 4) as usize; + let align = wgpu::COPY_BYTES_PER_ROW_ALIGNMENT as usize; + let padded = unpadded.div_ceil(align) * align; + let buf = hal.device.create_buffer(&wgpu::BufferDescriptor { + label: Some("readback"), + size: (padded * H as usize) as u64, + usage: wgpu::BufferUsages::MAP_READ | wgpu::BufferUsages::COPY_DST, + mapped_at_creation: false, + }); + let mut enc = hal + .device + .create_command_encoder(&wgpu::CommandEncoderDescriptor { label: None }); + enc.copy_texture_to_buffer( + wgpu::TexelCopyTextureInfo { + texture: target, + mip_level: 0, + origin: wgpu::Origin3d::ZERO, + aspect: wgpu::TextureAspect::All, + }, + wgpu::TexelCopyBufferInfo { + buffer: &buf, + layout: wgpu::TexelCopyBufferLayout { + offset: 0, + bytes_per_row: Some(padded as u32), + rows_per_image: Some(H), + }, + }, + wgpu::Extent3d { width: W, height: H, depth_or_array_layers: 1 }, + ); + hal.queue.submit(std::iter::once(enc.finish())); + let slice = buf.slice(..); + let (tx, rx) = std::sync::mpsc::channel(); + slice.map_async(wgpu::MapMode::Read, move |r| { + let _ = tx.send(r); + }); + let _ = hal.device.poll(wgpu::PollType::wait_indefinitely()); + rx.recv().expect("map").expect("map ok"); + let data = slice.get_mapped_range(); + let mut rgba = Vec::with_capacity((W * H * 4) as usize); + for row in 0..H as usize { + let start = row * padded; + rgba.extend_from_slice(&data[start..start + unpadded]); + } + drop(data); + buf.unmap(); + rgba +} diff --git a/02_ruway/shuma/shuma-shell-llimphi/src/app_update.rs b/02_ruway/shuma/shuma-shell-llimphi/src/app_update.rs new file mode 100644 index 0000000..cfd4c5f --- /dev/null +++ b/02_ruway/shuma/shuma-shell-llimphi/src/app_update.rs @@ -0,0 +1,679 @@ +//! Cuerpo de `App::update` de `Shell` — mitad 1: ticks, sesiones, herramientas, +//! contenedores locales/remotos y explorer. Extraído de `lib.rs` para adelgazar +//! el `impl App`. La mitad 2 (modales de hosts/layouts, draft de contenedor, +//! módulos, menús, workspace y perfiles) vive en [`crate::app_update_more`]; el +//! ruteo entre mitades es por el brazo `other`. + +use super::*; +use crate::containers::*; +use crate::env::*; +use crate::persist::*; +use crate::types::*; +use crate::update::*; + +/// Aplica los `Msg` de la mitad 1; delega el resto a [`crate::app_update_more::apply`]. +pub(crate) fn apply(model: Model, msg: Msg, handle: &Handle) -> Model { + let mut m = model; + match msg { + Msg::Tick => { + m.last_snapshot = Some(m.sysmon.sample()); + sample_extra_monitors(&mut m); + m.tick_count += 1; + // Autosave del output de las sesiones persistentes (cada 5 s). + if m.tick_count % 5 == 0 { + save_session_outputs(&m); + // M4 — polling del runtime de matilda (si hay instancia + // Local montada): refresca el semáforo sin pulsar Discover. + update::poll_matilda_runtime(&m, handle); + } + // M5 — polling de la flota a cadencia más lenta (~30 s): un + // fetch SSH por host es caro, así que se espacia más que el + // runtime local. Sólo corre si la flota ya fue activada. + if m.tick_count % 30 == 0 { + update::poll_matilda_fleet(&m, handle); + // M4 — y el runtime del Source montado si es remoto. + update::poll_matilda_remote_runtime(&m, handle); + } + // env.json cambió (builtin `:env` u otra instancia) → recargar. + let mtime = persist::env_groups_mtime(); + if mtime != m.env_groups_mtime { + m.env_groups_mtime = mtime; + m.env_groups = shuma_config::load_env_groups(); + } + // Marquesina: avanza el parpadeo y rota al próximo aviso narrable. + m.marquesina_fase = m.marquesina_fase.wrapping_add(1); + m.marquesina_idx = m.marquesina_idx.wrapping_add(1); + actualizar_marquesina(&mut m); + // Fondo procedural propio (párpados): re-anima a 1 fps — mucho más + // lento que el compositor. Sólo si el patrón cambia con el tiempo. + if m.bg_pattern.map(|p| p.is_animated()).unwrap_or(false) { + perfiles::regen_procedural_bg(&mut m); + } + } + Msg::ShellTick => { + drain_shell_instances(&mut m); + // Reloj del parpadeo/cava de las pestañas. Avanza SÓLO si hay + // algo vivo que mostrar; si no, se congela en fase 0 para que la + // barra quede idéntica frame a frame (ver `tabs_animadas`). + if crate::view::tabs_animadas(&m) { + m.pulso_fase = m.pulso_fase.wrapping_add(1); + } else { + m.pulso_fase = 0; + } + // Mientras la voz escucha, avanza el reloj de la superficie activa + // (chat / shell / command-bar) para que el halo del micrófono y el + // glow del input animen (≈10 fps). + if m.agente.escucha().activo() { + m.agente.fijar_reloj(ahora_ms()); + } + match m.voz_target { + Some(VozTarget::Shell(idx)) => { + let ahora = ahora_ms(); + if let Some(sess) = m.sessions.get_mut(idx) { + if let ModuleState::Shell(st) = &mut sess.shell_mut().state { + st.set_voz_reloj(ahora); + } + } + } + Some(VozTarget::CommandBar) => { + let ahora = ahora_ms(); + if let Some(inst) = m.bottombar.as_mut() { + if let ModuleState::CommandBar(st) = &mut inst.state { + st.set_reloj(ahora); + } + } + } + _ => {} + } + // A6 — la sesión activa no badgea (el usuario la está viendo): + // acusa sus comandos largos en cuanto terminan, así no aparece + // una badge stale al cambiar de diente después de verlos vivos. + let activa = m.active_session; + if let Some(s) = m.sessions.get_mut(activa) { + s.ack_long_alerts(); + } + // E5 — despachar peticiones LLM pendientes (`:?`/`:explica`/ + // `:resume`) a un thread; el resultado vuelve por LlmResult. + update::fulfill_llm_requests(&mut m, handle); + // Búsqueda semántica (`:buscar`) pendiente → thread; el + // resultado vuelve por SemanticResult. + update::fulfill_semantic_requests(&mut m, handle); + // E6 — turno del panel de chat pendiente → thread (pluma-llm); + // el resultado vuelve por Msg::Agente(Respuesta). + update::fulfill_agente_requests(&mut m, handle); + // El cwd remoto pudo cambiar tras un `cd`: re-listar si hace falta. + reconcile_explorer(&mut m, handle); + } + Msg::Resized(w, h) => { + if w > 0.0 && h > 0.0 { + m.viewport = (w, h); + } + } + Msg::WawaConfigChanged(cfg) => { + // El tema del sistema sólo pisa el de shuma si la apariencia + // efectiva es «Sistema» (sigue a wawa). Un perfil de apariencia + // fijo (global o de sesión) manda sobre wawa. + if perfiles::follows_system(&m) { + m.theme = wawa_config_llimphi::theme_from_wawa(&cfg, &m.theme); + } + let _ = rimay_localize::set_locale(&cfg.lang); + } + Msg::SelectSession(i) => { + if i < m.sessions.len() { + if i == m.active_session { + m.session_panel_open = !m.session_panel_open; + } else { + m.active_session = i; + m.session_panel_open = true; + } + // A6 — el usuario está mirando esta sesión: limpia su badge + // de comando largo. + m.sessions[i].ack_long_alerts(); + save_chrome(&m); + // La sesión activa puede fijar su propia apariencia (la + // "ventana"): re-aplicar al cambiar de sesión. + perfiles::apply_active_appearance(&mut m); + reconcile_explorer(&mut m, handle); + } + } + Msg::HoverSession(idx) => { + m.hovered_session = idx.filter(|&i| i < m.sessions.len()); + } + Msg::SelectTool(t) => { + m.active_tool = if m.active_tool == Some(t) { None } else { Some(t) }; + m.agente.set_focus(m.active_tool == Some(Tool::Agente)); + save_chrome(&m); + reconcile_explorer(&mut m, handle); + } + // Sidebar unificado izquierdo (sesiones): ejes/buscador/control/resize. + // El `Activate` no llega aquí (la vista lo intercepta a SelectSession). + // El ancho del panel es la fuente persistida `session_w`. + Msg::SidebarLeft(sm) => { + let resized = matches!(sm, llimphi_widget_rag_sidebar::RagSidebarMsg::Resize(_)); + m.sidebar_left.update(sm); + m.session_w = m.sidebar_left.panel_w; + if resized { + save_chrome(&m); + } + } + // Sidebar unificado derecho (herramientas): idem; `panel_w` espeja a + // `monitors_width`. + Msg::SidebarRight(sm) => { + use llimphi_widget_rag_sidebar::RagSidebarMsg; + let resized = matches!(sm, RagSidebarMsg::Resize(_)); + // Enter/Esc/soltar-foco del buscador dispara la búsqueda por + // SENTIDO (debounce natural: no una request por tecla, sólo al + // confirmar). Vaciar el buscador vuelve al cuerpo normal del tool. + let trigger_semantic = matches!(sm, RagSidebarMsg::SearchFocus(false)); + let cleared = matches!(&sm, RagSidebarMsg::SearchSet(s) if s.trim().is_empty()); + m.sidebar_right.update(sm); + m.monitors_width = m.sidebar_right.panel_w; + if resized { + save_chrome(&m); + } + if cleared { + m.file_search = None; + } + if trigger_semantic { + maybe_rail_semantic_search(&mut m, handle); + } + } + Msg::Agente(am) => { + m.agente.fijar_reloj(ahora_ms()); + m.agente = shuma_module_agente::update(m.agente.clone(), am); + // ¿El usuario tocó el micrófono o pidió enrolar? Arranca/pará. + atender_mic_intent(&mut m, handle); + atender_enrol_intent(&mut m, handle); + // ¿Un turno cerró con la lectura activada? Lee la prosa. + atender_leer_intent(&mut m); + // Persistí las conversaciones tras cada cambio (writes chicos). + if let Some(al) = &m.agente_almacen { + for c in m.agente.conversaciones() { + let _ = al.guardar_conversacion(c); + } + // Alta/edición o borrado de un agente desde el editor. + let mut refrescar = false; + if let Some(ag) = m.agente.take_persist_agente() { + let _ = al.guardar_agente(&ag); + refrescar = true; + } + if let Some(id) = m.agente.take_borrar_agente() { + let _ = al.borrar_agente(&id); + refrescar = true; + } + if let Some(id) = m.agente.take_borrar_conversacion() { + let _ = al.borrar_conversacion(&id); + } + if refrescar { + if let Ok(agentes) = al.agentes() { + m.agente.set_agentes(agentes); + } + } + } + // Una acción aprobada va al input del shell de la sesión activa + // (revisar y Enter — nunca se auto-ejecuta), reusando el canal + // `InsertAtCursor` del shell. + if let Some(accion) = m.agente.take_ejecucion() { + // Cadena forense (#2): la IA propuso, el humano aprobó, el + // chasis despacha — un Estado autor=Ia queda firmado en el + // hilo del device (vía el daemon willay). No hay estado + // direccionado por contenido de una acción de shell, así que + // la raíz es un content-address de la acción misma (capacidad + // ‖ línea). registrar_silencioso no-opea sin willay. + let agente = m + .agente + .nombre_agente_activo() + .unwrap_or("shuma") + .to_string(); + let mut hasher = blake3::Hasher::new(); + hasher.update(accion.id.as_bytes()); + hasher.update(b"\n"); + hasher.update(accion.linea_comando.as_bytes()); + let cp = willay_checkpoint::Checkpoint { + app: "shuma".to_string(), + cosa: format!("accion:{}", accion.id), + etiqueta: format!("IA (aprobada): {}", accion.linea_comando), + raiz: *hasher.finalize().as_bytes(), + padre: None, + ts_usec: willay_emit::ahora_usec(), + autor: willay_checkpoint::Autor::Ia { agente }, + }; + willay_emit::registrar_silencioso( + &willay_checkpoint::Registro::Estado(cp).canonizar(), + ); + let insert = ModuleMsg::Shell(shuma_module_shell::Msg::InsertAtCursor( + accion.linea_comando, + )); + let target = Slot::Session(m.active_session, Which::Shell); + m = apply_module_msg(m, target, insert); + } + } + Msg::WillayEventos(evs) => { + // Nuevo lote del centro de eventos: refresca el pool de la + // marquesina y arranca desde el más reciente narrable. + m.marquesina_eventos = evs; + m.marquesina_idx = 0; + actualizar_marquesina(&mut m); + } + Msg::VozEvento(ev) => { + // Un evento de la captura de voz: enrutalo a la superficie activa + // (chat / shell / command-bar) según `voz_target`. + atender_evento_voz(&mut m, ev); + } + Msg::VozEnrolHecho => { + // La grabación juntó las muestras y guardó el detector: cierra la + // captura y marca el wake-word listo (la compuerta F1 se monta en + // el próximo encendido del micrófono). + m._voz_guardia = None; + m._voz_rt = None; + m.agente.enrol_terminado(); + eprintln!("voz: wake-word «shuma» enrolado"); + } + Msg::RunFromHistory(cmd) => { + let slot = Slot::Session(m.active_session, Which::Shell); + m = apply_module_msg( + m, + slot, + ModuleMsg::Shell(shuma_module_shell::Msg::InsertAtCursor(cmd)), + ); + } + Msg::RunFromHistoryNow(cmd) => { + let slot = Slot::Session(m.active_session, Which::Shell); + m = apply_module_msg( + m, + slot, + ModuleMsg::Shell(shuma_module_shell::Msg::RunLine(cmd)), + ); + } + Msg::ToggleDropdown(kind) => { + m.dropdown_open = if m.dropdown_open == Some(kind) { None } else { Some(kind) }; + if m.dropdown_open == Some(DropKind::Container) { + if let Some(s) = m.sessions.get(m.active_session) { + if s.isolation == Isolation::Remote { + spawn_list_remote_containers( + handle, + s.host.text(), + s.user.text(), + s.port_num(), + s.container_engine.clone(), + ); + } + } + } + } + Msg::DismissDropdown => m.dropdown_open = None, + Msg::SetIsolation(iso) => { + m.dropdown_open = None; + if let Some(s) = m.sessions.get_mut(m.active_session) { + s.isolation = iso; + if !s.pending { + s.apply_isolation(); + } else { + s.conn = match iso { + Isolation::Local => ConnState::Connected, + Isolation::Remote => ConnState::Pending, + }; + } + } + save_sessions(&m); + } + Msg::ToggleContainer => { + let mut opening = false; + if let Some(s) = m.sessions.get_mut(m.active_session) { + s.container_open = !s.container_open; + opening = s.container_open; + } + if opening { + spawn_list_containers(handle); + } + } + Msg::SetDistro(d) => { + m.dropdown_open = None; + if let Some(s) = m.sessions.get_mut(m.active_session) { + s.distro = d; + } + save_sessions(&m); + } + Msg::FocusField(f) => { + m.focused_field = Some(f); + m.dropdown_open = None; + } + Msg::RemoteKey(e) => { + let Some(f) = m.focused_field else { return m }; + match &e.key { + llimphi_ui::Key::Named(llimphi_ui::NamedKey::Escape) => { + m.focused_field = None; + } + llimphi_ui::Key::Named(llimphi_ui::NamedKey::Enter) => { + if let Some(s) = m.sessions.get_mut(m.active_session) { + s.connect_remote(); + } + m.focused_field = None; + save_sessions(&m); + } + _ => { + if let Some(s) = m.sessions.get_mut(m.active_session) { + s.remote_field_mut(f).apply_key(&e); + } + } + } + } + Msg::ConnectRemote => { + if let Some(s) = m.sessions.get_mut(m.active_session) { + s.connect_remote(); + } + m.focused_field = None; + save_sessions(&m); + } + Msg::ReconnectSession(idx) => { + if let Some(s) = m.sessions.get_mut(idx) { + s.reconnect(); + } + m.focused_field = None; + save_sessions(&m); + } + Msg::RefreshContainers => spawn_list_containers(handle), + Msg::ContainersLoaded(v) => m.containers = v, + Msg::ExplorerLoaded { session, path, result } => { + // Aceptar sólo si sigue siendo la (sesión, cwd) que pedimos — + // si el usuario cambió de sesión o de dir, este listado es viejo. + if m.explorer.key.as_ref().is_some_and(|(s, p)| *s == session && p == &path) { + m.explorer.state = match result { + Ok(entries) => ExplorerState::Loaded(entries), + Err(e) => ExplorerState::Error(e), + }; + } + } + Msg::RefreshExplorer => { + m.explorer = ExplorerCache::default(); + reconcile_explorer(&mut m, handle); + } + Msg::FileSearchResult { slot, query, ok, hits } => { + // Limpia el «en vuelo» de la sesión y avisa en su output; si salió + // bien, llena el panel del Explorer y lo abre. + if let Slot::Session(i, w) = slot { + if let Some(s) = m.sessions.get_mut(i) { + if let ModuleState::Shell(st) = &mut s.instance_mut(w).state { + st.semantic_inflight = false; + if ok { + st.push_notice(format!( + "🔎 {} archivo(s) por significado — en el panel Explorer", + hits.len() + )); + } else { + let err = hits.first().map(|(t, _)| t.clone()).unwrap_or_default(); + st.push_notice(format!("🔎 búsqueda de archivos · {err}")); + } + } + } + if ok { + m.file_search = Some(FileSearch { session: i, query, hits }); + m.active_tool = Some(Tool::Explorer); + } + } + } + Msg::ClearFileSearch => { + m.file_search = None; + // Si la búsqueda vino del rail, limpia también su texto para que el + // Explorer vuelva al listado del cwd (no al filtro literal residual). + m.sidebar_right.search.clear(); + } + Msg::OpenFile(rel) => { + // Ruta absoluta: relativa al cwd de la sesión activa salvo que ya + // venga absoluta. + let cwd = m.active().and_then(|s| match &s.shell().state { + ModuleState::Shell(st) => Some(st.cwd.clone()), + _ => None, + }); + let abs = { + let p = std::path::Path::new(&rel); + if p.is_absolute() { + p.to_path_buf() + } else { + cwd.unwrap_or_default().join(p) + } + }; + let nota = match open_with_viewer(&abs) { + Ok(label) => format!("📂 abriendo {rel} con {label}"), + Err(e) => format!("📂 {rel}: {e}"), + }; + let idx = m.active_session; + if let Some(s) = m.sessions.get_mut(idx) { + if let ModuleState::Shell(st) = &mut s.instance_mut(Which::Shell).state { + st.push_notice(nota); + } + } + } + Msg::RemoteContainersLoaded(v) => m.remote_containers = v, + Msg::SubscribeContainer(i) => { + m.dropdown_open = None; + if let Some(name) = m.containers.get(i).cloned() { + if let Some(s) = m.sessions.get_mut(m.active_session) { + s.container = Some(name); + s.conn = ConnState::Connected; + } + } + save_sessions(&m); + } + Msg::PickRemoteContainer(name) => { + m.dropdown_open = None; + if let Some(s) = m.sessions.get(m.active_session) { + spawn_remote_engine_action( + handle, + s.host.text(), + s.user.text(), + s.port_num(), + s.container_engine.clone(), + "start", + name.clone(), + ); + } + if let Some(s) = m.sessions.get_mut(m.active_session) { + s.use_container = true; + s.container = Some(name); + if !s.pending { + s.connect_remote(); + } + } + save_sessions(&m); + } + Msg::CreateContainer => { + m.dropdown_open = None; + if !podman_disponible() { + if let Some(s) = m.sessions.get_mut(m.active_session) { + s.conn = ConnState::Disconnected; + let slot = Slot::Session(m.active_session, Which::Shell); + m = apply_module_msg( + m, + slot, + ModuleMsg::Shell(shuma_module_shell::Msg::PushNotice( + "✘ podman no encontrado en PATH — instala podman o desactiva 'Aislar en contenedor'".into(), + )), + ); + } + return m; + } + let (distro, n, mount) = m + .sessions + .get(m.active_session) + .map(|s| (s.distro, s.number.unwrap_or(0), s.mount.text())) + .unwrap_or((Distro::Ubuntu, 0, String::new())); + let name = format!("shuma-{}-{n}", distro.label().to_lowercase()); + if let Some(s) = m.sessions.get_mut(m.active_session) { + s.container = Some(name.clone()); + s.use_container = true; + s.conn = ConnState::Pending; + s.apply_isolation(); + } + let mount_opt = if mount.trim().is_empty() { None } else { Some(mount) }; + spawn_create_container(handle, distro.image(), name, mount_opt); + save_sessions(&m); + } + Msg::ToggleUseContainer => { + let mut activado = false; + if let Some(s) = m.sessions.get_mut(m.active_session) { + s.use_container = !s.use_container; + if s.use_container { + activado = true; + if let Some(pref) = engine_preferido() { + if !binary_disponible(&s.container_engine) { + s.container_engine = pref.to_string(); + } + } + } + if !s.pending { + if !s.use_container { + s.container = None; + s.apply_isolation(); + } else if s.container.is_some() { + s.apply_isolation(); + } + } + } + if activado { + spawn_list_containers(handle); + } + save_sessions(&m); + } + Msg::SetEngine(name) => { + m.dropdown_open = None; + if let Some(s) = m.sessions.get_mut(m.active_session) { + if binary_disponible(&name) + || name == "unshare" + || name == "bwrap" + || name == "podman" + { + s.container_engine = name; + } + } + } + Msg::PickRootfs(distro) => { + m.dropdown_open = None; + if let Some(s) = m.sessions.get_mut(m.active_session) { + s.use_container = true; + s.distro = distro; + if !binary_disponible(&s.container_engine) { + if let Some(pref) = engine_preferido() { + s.container_engine = pref.to_string(); + } + } + let path = rootfs_path_for(distro) + .map(|p| p.display().to_string()) + .unwrap_or_default(); + s.container = Some(path); + s.apply_isolation(); + s.conn = ConnState::Connected; + if s.pending { + s.pending = false; + s.pending_focus = None; + m.session_panel_open = true; + } + } + save_sessions(&m); + } + Msg::EnsureContainer(name) => { + let engine = m + .sessions + .iter() + .find(|s| s.container.as_deref() == Some(name.as_str())) + .map(|s| (s.container_engine.clone(), s.distro)) + .unwrap_or_else(|| ("podman".into(), Distro::Ubuntu)); + match engine.0.as_str() { + "unshare" | "bwrap" => { + if rootfs_listo(engine.1) { + handle.dispatch(Msg::ContainerCreated(name)); + } else { + spawn_pull_rootfs_lxc(handle, engine.1, None); + } + } + _ => spawn_ensure_container(handle, name), + } + } + Msg::OpenContainersWindow => { + m.containers_modal_open = true; + m.container_draft = None; + if let Some((host, user, port, engine)) = m.active_remote_target() { + spawn_list_remote_containers(handle, host, user, port, engine); + } else { + spawn_list_containers_full(handle); + } + } + Msg::Noop => {} + Msg::CloseContainersModal => { + m.containers_modal_open = false; + m.container_draft = None; + } + Msg::ContainersFullLoaded(v) => { + m.containers_full = v; + } + Msg::RefreshContainersFull => spawn_list_containers_full(handle), + Msg::StartContainer(name) => spawn_container_action(handle, "start", name), + Msg::StopContainer(name) => spawn_container_action(handle, "stop", name), + Msg::RemoveContainer(name) => spawn_container_action(handle, "rm", name), + Msg::RemoveRootfs(name) => spawn_remove_rootfs(handle, name), + Msg::RefreshRemoteContainers => { + if let Some((host, user, port, engine)) = m.active_remote_target() { + spawn_list_remote_containers(handle, host, user, port, engine); + } + } + Msg::SetRemoteNewDistro(d) => m.remote_new_distro = d, + Msg::CreateRemoteContainer => { + if let Some((host, user, port, engine)) = m.active_remote_target() { + let distro = m.remote_new_distro; + let n = m.active().and_then(|s| s.number).unwrap_or(0); + let name = format!("shuma-{}-{n}", distro.label().to_lowercase()); + spawn_create_remote_container(handle, host, user, port, engine, distro.image(), name); + } + } + Msg::RemoteStart(name) => { + if let Some((host, user, port, engine)) = m.active_remote_target() { + spawn_remote_engine_action(handle, host, user, port, engine, "start", name); + } + } + Msg::RemoteStop(name) => { + if let Some((host, user, port, engine)) = m.active_remote_target() { + spawn_remote_engine_action(handle, host, user, port, engine, "stop", name); + } + } + Msg::RemoteRemove(name) => { + if let Some((host, user, port, engine)) = m.active_remote_target() { + spawn_remote_engine_action(handle, host, user, port, engine, "rm", name); + } + } + other => return crate::app_update_more::apply(m, other, handle), + } + // Refleja en el rail de pata qué herramienta quedó abierta (si delegamos). + sync_host_active(&mut m); + m +} + +/// Dispara una búsqueda **por sentido** desde el buscador del rail derecho: +/// segunda entrada al mismo motor que `:buscar-archivos`, sin teclear el comando. +/// Hoy sólo el tool **Explorer** (scope `files`): arma un `SemanticRequest` en el +/// shell de la sesión activa y deja que [`fulfill_semantic_requests`] lo corra — +/// el resultado vuelve por `Msg::FileSearchResult`, que llena el panel del +/// Explorer (`explorer_search_panel`) reemplazando el listado del cwd. Con la +/// semántica apagada o sin archivos, no hace nada (el Explorer sigue con el +/// filtro literal por substring). El gate `semantic_inflight` de `State` evita +/// apilar peticiones si el usuario reconfirma con otra en vuelo. +fn maybe_rail_semantic_search(m: &mut Model, handle: &Handle) { + if m.active_tool != Some(Tool::Explorer) { + return; + } + let q = m.sidebar_right.search.trim().to_string(); + if q.is_empty() { + return; + } + let idx = m.active_session; + let armed = m + .sessions + .get_mut(idx) + .and_then(|s| match &mut s.shell_mut().state { + ModuleState::Shell(st) => st.arm_file_search(&q).ok(), + _ => None, + }) + .is_some(); + if armed { + fulfill_semantic_requests(m, handle); + } +} diff --git a/02_ruway/shuma/shuma-shell-llimphi/src/app_update_more.rs b/02_ruway/shuma/shuma-shell-llimphi/src/app_update_more.rs new file mode 100644 index 0000000..0283467 --- /dev/null +++ b/02_ruway/shuma/shuma-shell-llimphi/src/app_update_more.rs @@ -0,0 +1,1344 @@ +//! Cuerpo de `App::update` de `Shell` — mitad 2: modales de hosts/layouts, +//! draft de contenedor, ruteo de módulos, menús, workspace tipo zellij y +//! perfiles. Continúa [`crate::app_update`], que delega aquí por el brazo `other`. + +use super::*; +use crate::containers::*; +use crate::env::*; +use crate::persist::*; +use crate::types::*; +use crate::update::*; +use llimphi_widget_text_input::TextInputEvent; + +/// Aplica los `Msg` que [`crate::app_update::apply`] delega (mitad 2). +pub(crate) fn apply(model: Model, msg: Msg, handle: &Handle) -> Model { + let mut m = model; + match msg { + Msg::OpenHostsWindow => { + m.hosts_modal_open = true; + m.host_draft = None; + } + Msg::CloseHostsModal => { + m.hosts_modal_open = false; + m.host_draft = None; + } + Msg::HostDraftStart => { + m.host_draft = Some(HostDraft::new()); + } + Msg::HostEdit(idx) => { + if let Some(h) = m.hosts.get(idx).cloned() { + m.host_draft = Some(HostDraft::from_host(&h)); + } + } + Msg::HostDraftCancel => { + m.host_draft = None; + } + Msg::HostDraftSave => { + if let Some(draft) = m.host_draft.clone() { + if let Some(h) = draft.to_host() { + if let Some(old) = &draft.editing { + if old != &h.name { + m.hosts.retain(|x| &x.name != old); + } + } + if let Some(idx) = m.hosts.iter().position(|x| x.name == h.name) { + m.hosts[idx] = h.clone(); + } else { + m.hosts.push(h.clone()); + } + hosts::save_hosts(&m.hosts); + m.host_draft = Some(HostDraft::from_host(&h)); + } + } + } + Msg::HostDraftFocus(f) => { + if let Some(d) = m.host_draft.as_mut() { + d.focused = Some(f); + } + } + Msg::HostDraftKey(e) => { + if let Some(d) = m.host_draft.as_mut() { + let Some(f) = d.focused else { return m }; + match &e.key { + llimphi_ui::Key::Named(llimphi_ui::NamedKey::Escape) => { + d.focused = None; + } + llimphi_ui::Key::Named(llimphi_ui::NamedKey::Enter) => { + handle.dispatch(Msg::HostDraftSave); + } + llimphi_ui::Key::Named(llimphi_ui::NamedKey::Tab) => { + let next = match f { + HostDraftField::Name => HostDraftField::Host, + HostDraftField::Host => HostDraftField::User, + HostDraftField::User => HostDraftField::Port, + HostDraftField::Port => { + if d.use_password { HostDraftField::Name } else { HostDraftField::Pem } + } + HostDraftField::Pem => HostDraftField::Name, + }; + d.focused = Some(next); + } + _ => { + let target = match f { + HostDraftField::Name => &mut d.name, + HostDraftField::Host => &mut d.host, + HostDraftField::User => &mut d.user, + HostDraftField::Port => &mut d.port, + HostDraftField::Pem => &mut d.pem_path, + }; + // `handle` cubre copiar/cortar/pegar (Ctrl+C/X/V) + // contra el portapapeles del sistema. + target.handle(TextInputEvent::Key(e.clone()), &mut m.clipboard); + } + } + } + } + Msg::HostDraftCampo(f, ev) => { + if let Some(d) = m.host_draft.as_mut() { + // El press/click enfoca ese campo; el arrastre selecciona. + if matches!(ev, TextInputEvent::Press(_)) { + d.focused = Some(f); + } + let target = match f { + HostDraftField::Name => &mut d.name, + HostDraftField::Host => &mut d.host, + HostDraftField::User => &mut d.user, + HostDraftField::Port => &mut d.port, + HostDraftField::Pem => &mut d.pem_path, + }; + target.handle(ev, &mut m.clipboard); + } + } + Msg::HostDraftToggleAuth => { + if let Some(d) = m.host_draft.as_mut() { + d.use_password = !d.use_password; + } + } + Msg::HostDraftTogglePty => { + if let Some(d) = m.host_draft.as_mut() { + d.pty = !d.pty; + } + } + Msg::HostDelete(idx) => { + if idx < m.hosts.len() { + m.hosts.remove(idx); + hosts::save_hosts(&m.hosts); + } + } + Msg::OpenLayoutsModal => { + m.layouts_modal_open = true; + m.layout_name_focused = true; + m.menu_open = None; + } + Msg::CloseLayoutsModal => { + m.layouts_modal_open = false; + m.layout_name_focused = false; + } + Msg::LayoutNameFocus => { + m.layout_name_focused = true; + } + Msg::LayoutNameKey(e) => match &e.key { + llimphi_ui::Key::Named(llimphi_ui::NamedKey::Escape) => { + m.layout_name_focused = false; + } + llimphi_ui::Key::Named(llimphi_ui::NamedKey::Enter) => { + handle.dispatch(Msg::SaveLayout); + } + _ => { + m.layout_name.handle(TextInputEvent::Key(e.clone()), &mut m.clipboard); + } + }, + Msg::LayoutNameCampo(ev) => { + if matches!(ev, TextInputEvent::Press(_)) { + m.layout_name_focused = true; + } + m.layout_name.handle(ev, &mut m.clipboard); + } + Msg::SaveLayout => { + let name = m.layout_name.text().trim().to_string(); + if !name.is_empty() { + let snap = snapshot_workspace(&m, name.clone()); + if let Some(i) = m.layouts.iter().position(|l| l.name == name) { + m.layouts[i] = snap; + } else { + m.layouts.push(snap); + } + save_layouts(&m.layouts); + m.layout_name.set_text(""); + m.layout_name_focused = false; + } + } + Msg::RestoreLayout(idx) => { + if let Some(snap) = m.layouts.get(idx).cloned() { + let mut sessions = vec![Session::draft()]; + for c in snap.sessions { + sessions.push(Session::from_config(c)); + } + for s in &sessions { + if s.use_container { + if let Some(name) = s.container.clone() { + handle.dispatch(Msg::EnsureContainer(name)); + } + } + } + m.sessions = sessions; + m.active_tool = snap.chrome.active_tool; + m.session_panel_open = snap.chrome.session_panel_open; + m.session_w = snap.chrome.session_w; + m.monitors_width = snap.chrome.monitors_width; + m.sidebar_left.panel_w = snap.chrome.session_w; + m.sidebar_right.panel_w = snap.chrome.monitors_width; + m.active_session = snap + .chrome + .active_session + .min(m.sessions.len().saturating_sub(1)); + m.layouts_modal_open = false; + save_sessions(&m); + save_chrome(&m); + } + } + Msg::DeleteLayout(idx) => { + if idx < m.layouts.len() { + m.layouts.remove(idx); + save_layouts(&m.layouts); + } + } + Msg::ContainerDraftNew => { + let host = m + .sessions + .get(m.active_session) + .map(|s| s.host_key()) + .unwrap_or_else(host_local); + m.container_draft = Some(ContainerDraft::new(host)); + } + Msg::ContainerDraftCancel => { + m.container_draft = None; + } + Msg::ContainerEdit(idx) => { + if let Some(info) = m.containers_full.get(idx) { + if info.rootfs { + let name = info.name.clone(); + let host = m + .sessions + .get(m.active_session) + .map(|s| s.host_key()) + .unwrap_or_else(host_local); + let cfg = m + .container_cfgs + .iter() + .find(|c| c.name == name) + .cloned() + .unwrap_or_else(|| ContainerCfg { + name: name.clone(), + host, + engine: engine_preferido().unwrap_or("unshare").to_string(), + distro: distro_from_name(&name).unwrap_or(Distro::Ubuntu), + mounts: Vec::new(), + }); + m.container_draft = Some(ContainerDraft::from_cfg(&cfg)); + } + } + } + Msg::ContainerDraftSetEngine(name) => { + if let Some(d) = m.container_draft.as_mut() { + if d.editing.is_none() { + d.engine = name; + } + } + } + Msg::ContainerDraftSetDistro(distro) => { + if let Some(d) = m.container_draft.as_mut() { + if d.editing.is_none() { + d.distro = distro; + } + } + } + Msg::ContainerDraftAddMount => { + if let Some(d) = m.container_draft.as_mut() { + d.mounts.push(MountDraft::new()); + d.focus = Some((d.mounts.len() - 1, MountCol::Host)); + } + } + Msg::ContainerDraftRemoveMount(i) => { + if let Some(d) = m.container_draft.as_mut() { + if i < d.mounts.len() { + d.mounts.remove(i); + d.focus = None; + } + } + } + Msg::ContainerDraftToggleMountRo(i) => { + if let Some(d) = m.container_draft.as_mut() { + if let Some(md) = d.mounts.get_mut(i) { + md.readonly = !md.readonly; + } + } + } + Msg::ContainerDraftFocusMount(i, col) => { + if let Some(d) = m.container_draft.as_mut() { + if i < d.mounts.len() { + d.focus = Some((i, col)); + } + } + } + Msg::ContainerDraftSave => { + if let Some(d) = m.container_draft.clone() { + let nuevo = d.editing.is_none(); + let name = d.editing.clone().unwrap_or_else(|| { + if matches!(d.engine.as_str(), "unshare" | "bwrap") { + d.distro.label().to_lowercase() + } else { + (1..1000) + .map(|n| format!("shuma-{}-{n}", d.distro.label().to_lowercase())) + .find(|cand| !m.container_cfgs.iter().any(|c| &c.name == cand)) + .unwrap_or_else(|| format!("shuma-{}", d.distro.label().to_lowercase())) + } + }); + let cfg = d.to_cfg(name.clone()); + if let Some(slot) = m.container_cfgs.iter_mut().find(|c| c.name == name) { + *slot = cfg.clone(); + } else { + m.container_cfgs.push(cfg.clone()); + } + save_container_cfgs(&m.container_cfgs); + if nuevo { + match d.engine.as_str() { + "unshare" | "bwrap" => { + if !rootfs_listo(d.distro) { + spawn_pull_rootfs_lxc(handle, d.distro, None); + } + } + _ => { + spawn_create_container(handle, d.distro.image(), name.clone(), None); + } + } + } + m.container_draft = Some(ContainerDraft::from_cfg(&cfg)); + spawn_list_containers_full(handle); + } + } + Msg::ContainerDraftKey(e) => { + if let Some(d) = m.container_draft.as_mut() { + let Some((idx, col)) = d.focus else { return m }; + match &e.key { + llimphi_ui::Key::Named(llimphi_ui::NamedKey::Escape) => { + d.focus = None; + } + llimphi_ui::Key::Named(llimphi_ui::NamedKey::Enter) => { + handle.dispatch(Msg::ContainerDraftSave); + } + _ => { + if let Some(md) = d.mounts.get_mut(idx) { + let input = match col { + MountCol::Host => &mut md.host, + MountCol::Target => &mut md.target, + }; + input.handle(TextInputEvent::Key(e.clone()), &mut m.clipboard); + } + } + } + } + } + Msg::ContainerDraftMountCampo(i, col, ev) => { + if let Some(d) = m.container_draft.as_mut() { + if i < d.mounts.len() { + // El press/click enfoca esa celda; el arrastre selecciona. + if matches!(ev, TextInputEvent::Press(_)) { + d.focus = Some((i, col)); + } + if let Some(md) = d.mounts.get_mut(i) { + let input = match col { + MountCol::Host => &mut md.host, + MountCol::Target => &mut md.target, + }; + input.handle(ev, &mut m.clipboard); + } + } + } + } + Msg::PickHost(choice) => { + m.dropdown_open = None; + let host = choice.and_then(|i| m.hosts.get(i).cloned()); + if let Some(s) = m.sessions.get_mut(m.active_session) { + s.container = None; + match host { + None => { + s.isolation = Isolation::Local; + s.host_label = None; + if !s.pending { + s.apply_isolation(); + } else { + s.conn = ConnState::Connected; + } + } + Some(h) => { + s.isolation = Isolation::Remote; + s.host_label = Some(h.name.clone()); + s.host.set_text(h.host); + s.user.set_text(h.user); + s.port.set_text(h.port.to_string()); + if !s.pending { + s.connect_remote(); + } else { + s.conn = ConnState::Pending; + } + } + } + } + save_sessions(&m); + } + Msg::HostApply(idx) => { + m.dropdown_open = None; + let h = match m.hosts.get(idx).cloned() { + Some(h) => h, + None => return m, + }; + if let Some(s) = m.sessions.get_mut(m.active_session) { + s.isolation = Isolation::Remote; + s.host_label = Some(h.name.clone()); + s.host.set_text(h.host); + s.user.set_text(h.user); + s.port.set_text(h.port.to_string()); + if !s.pending { + s.connect_remote(); + } + } + save_sessions(&m); + } + Msg::ContainerCreated(name) => { + let idx = m + .sessions + .iter() + .position(|s| s.container.as_deref() == Some(name.as_str())); + if let Some(i) = idx { + if let Some(s) = m.sessions.get_mut(i) { + s.conn = ConnState::Connected; + if s.use_container && !s.pending { + s.apply_isolation(); + } + } + } + spawn_list_containers(handle); + save_sessions(&m); + } + Msg::ContainerFailed { name, reason } => { + let idx = m + .sessions + .iter() + .position(|s| s.container.as_deref() == Some(name.as_str())); + if let Some(i) = idx { + let engine = m + .sessions + .get(i) + .map(|s| s.container_engine.clone()) + .unwrap_or_default(); + let accion = match engine.as_str() { + "unshare" | "bwrap" => "la descarga del rootfs", + other if !other.is_empty() => "el arranque del contenedor", + _ => "el contenedor", + }; + if let Some(s) = m.sessions.get_mut(i) { + s.conn = ConnState::Disconnected; + s.container = None; + s.use_container = false; + s.apply_isolation(); + } + let slot = Slot::Session(i, Which::Shell); + m = apply_module_msg( + m, + slot, + ModuleMsg::Shell(shuma_module_shell::Msg::PushNotice(format!( + "✘ {accion} ({engine}) falló: {reason} — caí a shell local." + ))), + ); + } + save_sessions(&m); + } + Msg::CloseSession(idx) => { + if idx > 0 && idx < m.sessions.len() { + let mut s = m.sessions.remove(idx); + // Cerrar = mandar al fondo TODAS sus tabs/paneles: des-registrar + // sus sesiones del daemon para que no auto-revivan (siguen vivas + // y recuperables desde el taskmanager). + s.workspace.for_each_pane_mut(|inst| { + if let ModuleState::Shell(sh) = &inst.state { + if let Some(ulid) = sh.montada_session() { + shuma_module_shell::olvidar_montada(ulid); + } + } + }); + if s.persist { + persist::remove_session_output(&s.name); + } + m.active_session = m.active_session.min(m.sessions.len() - 1); + } + save_sessions(&m); + save_chrome(&m); + } + Msg::ToggleSessionPersist(idx) => { + if let Some(s) = m.sessions.get_mut(idx) { + s.persist = !s.persist; + let (persist, name) = (s.persist, s.name.clone()); + save_sessions(&m); + if persist { + // Snapshot inmediato: el flag queda respaldado ya. + save_session_outputs(&m); + } else { + persist::remove_session_output(&name); + } + } + } + Msg::ToggleEnvGroup(i) => { + if let Some(g) = m.env_groups.get_mut(i) { + g.active = !g.active; + let encendido = g.active; + shuma_config::apply_env_group(g, encendido); + if !encendido { + // Re-aplicar los grupos que siguen activos: si una + // variable vivía en dos grupos, recupera el valor del + // que queda encendido. + for og in m.env_groups.iter().filter(|og| og.active) { + shuma_config::apply_env_group(og, true); + } + } + let _ = shuma_config::save_env_groups(&m.env_groups); + m.env_groups_mtime = persist::env_groups_mtime(); + } + } + Msg::OpenNewSessionForm => { + let n = m.sessions.iter().filter(|s| s.number.is_some()).count() as u32 + 1; + let mut s = Session::new_pending(n); + s.pending_focus = Some(PendingField::Mount); + m.sessions.push(s); + m.active_session = m.sessions.len() - 1; + m.session_panel_open = false; + save_chrome(&m); + } + Msg::ConfirmNewSession => { + enum CreatePlan { + Rootfs { distro: Distro, mount: Option }, + Podman { image: &'static str, name: String, mount: Option }, + PodmanEnsure { name: String }, + } + let mut plan: Option = None; + let mut notice: Option = None; + if let Some(s) = m.sessions.get_mut(m.active_session) { + if s.pending { + s.pending = false; + s.pending_focus = None; + if s.use_container { + let chosen: Option = if binary_disponible(&s.container_engine) { + Some(s.container_engine.clone()) + } else { + engine_preferido().map(|e| e.to_string()) + }; + match chosen.as_deref() { + None => { + s.use_container = false; + s.container = None; + notice = Some( + "✘ ningún engine de aislamiento está disponible (faltan `unshare`/`bwrap`/`podman`). Arrancó como shell local.".into(), + ); + } + Some("unshare") | Some("bwrap") => { + let engine = chosen.unwrap(); + s.container_engine = engine.clone(); + let mount = s.mount.text(); + let mount_opt = if mount.trim().is_empty() { None } else { Some(mount) }; + let via_modal = s.container.is_some(); + if s.container.is_none() { + let path = rootfs_path_for(s.distro) + .map(|p| p.display().to_string()) + .unwrap_or_default(); + s.container = Some(path); + } + if rootfs_listo(s.distro) { + s.conn = ConnState::Connected; + } else { + s.conn = ConnState::Pending; + if !via_modal { + plan = Some(CreatePlan::Rootfs { + distro: s.distro, + mount: mount_opt, + }); + } + } + } + Some(_) => { + s.container_engine = "podman".into(); + s.conn = ConnState::Pending; + let mount = s.mount.text(); + let mount_opt = if mount.trim().is_empty() { None } else { Some(mount) }; + match s.container.clone() { + Some(name) => { + plan = Some(CreatePlan::PodmanEnsure { name }); + } + None => { + let n = s.number.unwrap_or(0); + let name = format!( + "shuma-{}-{n}", + s.distro.label().to_lowercase() + ); + s.container = Some(name.clone()); + plan = Some(CreatePlan::Podman { + image: s.distro.image(), + name, + mount: mount_opt, + }); + } + } + } + } + } + s.apply_isolation(); + if s.use_container + && matches!(s.container_engine.as_str(), "unshare" | "bwrap") + && rootfs_listo(s.distro) + { + s.conn = ConnState::Connected; + } + if s.isolation == Isolation::Remote { + if !s.host.text().trim().is_empty() && !s.user.text().trim().is_empty() { + s.connect_remote(); + } + } + m.session_panel_open = true; + } + } + if let Some(text) = notice { + let slot = Slot::Session(m.active_session, Which::Shell); + m = apply_module_msg( + m, + slot, + ModuleMsg::Shell(shuma_module_shell::Msg::PushNotice(text)), + ); + } + match plan { + Some(CreatePlan::Rootfs { distro, mount }) => { + let slot = Slot::Session(m.active_session, Which::Shell); + m = apply_module_msg( + m, + slot, + ModuleMsg::Shell(shuma_module_shell::Msg::PushNotice(format!( + "⬇ descargando rootfs LXC ({}) — ~50 MB, esto tarda unos segundos…", + distro.label() + ))), + ); + spawn_pull_rootfs_lxc(handle, distro, mount); + } + Some(CreatePlan::Podman { image, name, mount }) => { + spawn_create_container(handle, image, name, mount); + } + Some(CreatePlan::PodmanEnsure { name }) => { + spawn_ensure_container(handle, name); + } + None => {} + } + save_sessions(&m); + save_chrome(&m); + } + Msg::CancelNewSession => { + if let Some(s) = m.sessions.get(m.active_session) { + if s.pending { + let idx = m.active_session; + m.sessions.remove(idx); + m.active_session = m.active_session.min(m.sessions.len().saturating_sub(1)); + } + } + save_chrome(&m); + } + Msg::FocusPendingField(f) => { + if let Some(s) = m.sessions.get_mut(m.active_session) { + s.pending_focus = Some(f); + } + m.dropdown_open = None; + } + Msg::PendingKey(e) => { + let Some(s) = m.sessions.get_mut(m.active_session) else { + return m; + }; + let Some(f) = s.pending_focus else { return m }; + match &e.key { + llimphi_ui::Key::Named(llimphi_ui::NamedKey::Escape) => { + s.pending_focus = None; + } + llimphi_ui::Key::Named(llimphi_ui::NamedKey::Enter) => { + handle.dispatch(Msg::ConfirmNewSession); + } + _ => match f { + PendingField::Mount => { + let _ = s.mount.apply_key(&e); + } + }, + } + } + Msg::ReorderSession(from, to) => { + let len = m.sessions.len(); + if from > 0 && from < len && to > 0 && to < len && from != to { + let s = m.sessions.remove(from); + m.sessions.insert(to, s); + m.active_session = to; + } + save_sessions(&m); + save_chrome(&m); + } + Msg::SetSessionWidth(dx) => { + m.session_w = (m.session_w + dx).clamp(180.0, 480.0); + save_chrome(&m); + } + Msg::SetToolWidth(dx) => { + m.monitors_width = (m.monitors_width - dx).clamp(180.0, 480.0); + save_chrome(&m); + } + Msg::Module(slot, mmsg) => { + // M5 — acciones sobre recursos de un host de la flota: necesitan + // SSH en un thread. El módulo sólo dejó la intención en el log; + // el chasis corre el `docker`/`systemctl` remoto y, si fue + // mutante, re-observa el host para refrescar su semáforo. + if let ModuleMsg::Matilda(mat) = &mmsg { + use shuma_module_matilda::Msg as MMsg; + // M2 — live-tail (`docker logs -f`): el módulo ya preparó el + // `log_stream` (buffer + bandera stop) al aplicar el Msg; + // aquí leemos esos inputs y lanzamos un thread lector que + // dispatcha `LogStreamLine` por línea y `LogStreamEnded` al + // terminar. Un thread crudo (no `handle.spawn`) porque emite + // N mensajes a lo largo del tiempo, no uno solo. + if matches!(mat, MMsg::StartLogStream(_)) { + m = apply_module_msg(m, slot.clone(), mmsg); + if let Some((source, name, stop)) = + matilda_log_stream_inputs(&slot, &m) + { + let slot_back = slot.clone(); + let h = handle.clone(); + std::thread::spawn(move || { + let _ = shuma_module_matilda::stream_logs_blocking( + &source, + &name, + 200, + &stop, + |line| { + h.dispatch(Msg::Module( + slot_back.clone(), + ModuleMsg::Matilda(MMsg::LogStreamLine(line)), + )); + }, + ); + h.dispatch(Msg::Module( + slot_back.clone(), + ModuleMsg::Matilda(MMsg::LogStreamEnded), + )); + }); + } + return m; + } + if let MMsg::FleetContainerAction { host, name, action } = mat { + if let Some(h) = matilda_host_by_name(&slot, &m, host) { + let (name, action) = (name.clone(), *action); + let slot_back = slot.clone(); + handle.spawn(move || { + let (ok, lines) = shuma_module_matilda::fleet_container_action_blocking( + &h, &name, action, + ); + let runtime = if ok && action.is_mutating() { + shuma_module_matilda::host_runtime_remote_blocking(&h).ok() + } else { + None + }; + Msg::Module( + slot_back, + ModuleMsg::Matilda(MMsg::FleetActionDone { + host: h.name.clone(), + lines, + runtime, + }), + ) + }); + } + return apply_module_msg(m, slot, mmsg); + } + if let MMsg::FleetServiceAction { host, name, action } = mat { + if let Some(h) = matilda_host_by_name(&slot, &m, host) { + let (name, action) = (name.clone(), *action); + let slot_back = slot.clone(); + handle.spawn(move || { + let (ok, lines) = shuma_module_matilda::fleet_service_action_blocking( + &h, &name, action, + ); + let runtime = if ok && action.is_mutating() { + shuma_module_matilda::host_runtime_remote_blocking(&h).ok() + } else { + None + }; + Msg::Module( + slot_back, + ModuleMsg::Matilda(MMsg::FleetActionDone { + host: h.name.clone(), + lines, + runtime, + }), + ) + }); + } + return apply_module_msg(m, slot, mmsg); + } + // Acciones sobre el Source montado cuando es remoto: el + // módulo ya logueó "delegado al chasis"; aquí corremos el + // `docker`/`systemctl` por SSH y volcamos la salida. + if let MMsg::ContainerActionMsg { name, action } = mat { + if let Some((source, _)) = remote_matilda_inputs(&slot, &m) { + let (name, action) = (name.clone(), *action); + let slot_back = slot.clone(); + handle.spawn(move || { + let lines = shuma_module_matilda::container_action_remote_blocking( + &source, &name, action, + ) + .unwrap_or_else(|e| vec![format!("✘ {} {name}: {e}", action.label())]); + Msg::Module(slot_back, ModuleMsg::Matilda(MMsg::LogLines(lines))) + }); + } + return apply_module_msg(m, slot, mmsg); + } + if let MMsg::ServiceActionMsg { name, action } = mat { + if let Some((source, _)) = remote_matilda_inputs(&slot, &m) { + let (name, action) = (name.clone(), *action); + let slot_back = slot.clone(); + handle.spawn(move || { + let cmd = action.command(&name); + let lines = shuma_module_matilda::service_action_remote_blocking( + &source, &cmd, action.label(), &name, + ) + .unwrap_or_else(|e| vec![format!("✘ {} {name}: {e}", action.label())]); + Msg::Module(slot_back, ModuleMsg::Matilda(MMsg::LogLines(lines))) + }); + } + return apply_module_msg(m, slot, mmsg); + } + } + if let ModuleMsg::Minga(shuma_module_minga::Msg::SelectRoot(alpha)) = &mmsg { + if let Some(repo_path) = minga_repo_path(&slot, &m) { + let alpha = *alpha; + let slot_back = slot.clone(); + handle.spawn(move || { + let result = shuma_module_minga::load_root_source(&repo_path, alpha); + Msg::Module( + slot_back, + ModuleMsg::Minga(shuma_module_minga::Msg::SourceLoaded { + alpha, + result, + }), + ) + }); + } + } + m = apply_module_msg(m, slot.clone(), mmsg); + // ¿El módulo (shell de sesión o command-bar) pidió encender/apagar + // el micrófono con su `ToggleMic`? Arranca/pará la captura apuntada + // a esa superficie — el «llamado shuma» también vive en esas barras. + atender_mic_intent_slot(&mut m, handle, &slot); + } + Msg::ShortcutClicked(slot, action) => { + m = handle_shortcut(m, slot, action, handle); + } + Msg::MenuOpen(idx) => { + m.menu_open = idx; + m.menu_active = usize::MAX; + m.ctx_menu = None; + if idx.is_some() { + m.menu_anim = Tween::new(0.0, 1.0, motion::FAST, motion::ease_out_cubic); + animate(handle, motion::FAST, || Msg::MenuTick); + } + } + Msg::MenuNav(dir) => { + if let Some(mi) = m.menu_open { + let menu = menu::app_menu(&m); + m.menu_active = + llimphi_widget_menubar::menubar_nav(&menu, mi, m.menu_active, dir); + } + } + Msg::MenuActivate => { + if let Some(mi) = m.menu_open { + let menu = menu::app_menu(&m); + if let Some(cmd) = + llimphi_widget_menubar::menubar_command_at(&menu, mi, m.menu_active) + { + m = menu::handle_command(m, &cmd); + } + } + } + Msg::MenuTick => {} + Msg::ContextMenuOpen(x, y) => { + m.ctx_menu = Some((x, y)); + m.menu_open = None; + m.menu_active = usize::MAX; + } + Msg::CloseMenus => { + m.menu_open = None; + m.menu_active = usize::MAX; + m.ctx_menu = None; + m.tab_ctx = None; + } + Msg::MenuCommand(cmd) => { + m = menu::handle_command(m, &cmd); + } + Msg::HostActivate(id) => { + if let Some(t) = Tool::ALL.get(id as usize) { + m.active_tool = if m.active_tool == Some(*t) { None } else { Some(*t) }; + save_chrome(&m); + } + } + + // ─── Workspace tipo zellij ────────────────────────────── + Msg::PaneSplit(axis) => { + if let Some(s) = m.sessions.get_mut(m.active_session) { + if !s.pending { + let inst = Instance::shell(s.name.clone(), s.source.clone()); + s.workspace.split(axis, inst); + } + } + } + Msg::PaneFocus(id) => { + if let Some(s) = m.sessions.get_mut(m.active_session) { + s.workspace.focus(id); + } + } + Msg::PaneClose => { + if let Some(s) = m.sessions.get_mut(m.active_session) { + // El ✕ de la barra cierra el panel enfocado = mandar su sesión + // al fondo (des-registrar del auto-reattach), igual que cerrar + // un tab. Sin esto, cerrar por acá revivía la sesión al arrancar. + if let Some(inst) = s.workspace.close_focused() { + olvidar_montadas_de(std::slice::from_ref(&inst)); + } + } + } + Msg::PaneCycle(fwd) => { + if let Some(s) = m.sessions.get_mut(m.active_session) { + s.workspace.cycle_focus(fwd); + } + } + Msg::PaneResize(path, delta) => { + if let Some(s) = m.sessions.get_mut(m.active_session) { + s.workspace.resize(&path, delta); + } + } + Msg::TabNew => { + if let Some(s) = m.sessions.get_mut(m.active_session) { + if !s.pending { + shuma_module_shell::diag_tab(&format!( + "TabNew sess={} tabs_antes={} panes_activa={}", + m.active_session, + s.workspace.tabs.len(), + s.workspace.tab().panes.len(), + )); + let inst = Instance::shell(s.name.clone(), s.source.clone()); + s.workspace.new_tab(inst); + } + } + } + Msg::TabSwitch(i) => { + if let Some(s) = m.sessions.get_mut(m.active_session) { + s.workspace.switch_tab(i); + // Corte natural F1: la pestaña recién activada recupera el hueco + // que la marca de agua alta hubiera reservado — re-baseline al + // entrar (el rebote sólo molesta en la pestaña que estás mirando). + if let ModuleState::Shell(sh) = &s.shell().state { + sh.reset_content_hwm(); + } + } + } + Msg::TabSwitchThen(i, siguiente) => { + // Activar y encadenar: las acciones del menú que operan sobre el + // panel con foco (dividir) necesitan estar EN la tab clickeada. + if let Some(s) = m.sessions.get_mut(m.active_session) { + s.workspace.switch_tab(i); + } + m.tab_ctx = None; + handle.dispatch(*siguiente); + } + Msg::TabClose(i) => { + m.tab_ctx = None; + if let Some(s) = m.sessions.get_mut(m.active_session) { + // Dropear las Instance devueltas = DETACH (no mata): la + // sesión persistente queda viva en el daemon (al fondo) y + // reaparece en el gestor. Cerrar un tab = mandar al fondo — + // y des-registrarla del auto-reattach para que NO reviva sola. + let dropped = s.workspace.close_tab(i); + olvidar_montadas_de(&dropped); + } + } + Msg::TaskManagerToggle => { + m.taskmanager_open = !m.taskmanager_open; + if m.taskmanager_open { + m.task_scroll = 0.0; + crate::refrescar_task_rows(&mut m, handle); + } + } + Msg::TaskManagerRefresh => { + crate::refrescar_task_rows(&mut m, handle); + } + Msg::TaskRowsReady(filas) => { + m.task_rows = filas; + m.task_cargando = false; + } + Msg::TaskScrollBy(d) => { + // El clamp real lo hace la vista contra el alto del contenido; + // acá alcanza con no irse a negativo (rueda hacia arriba en el + // tope) ni a un absurdo si la lista se vació entre medio. + let techo = (m.task_rows.len() as f32) * 126.0; + m.task_scroll = (m.task_scroll + d).clamp(0.0, techo.max(0.0)); + } + Msg::TaskRestore(id) => { + crate::restaurar_sesion(&mut m, &id); + m.taskmanager_open = false; // mostrar la tab restaurada + crate::refrescar_task_rows(&mut m, handle); + } + Msg::TaskGoTo(id) => { + // La sesión ya está montada en una pestaña: se salta a ella en + // vez de restaurarla (restaurar abriría un segundo frontend de + // la misma sesión, que es cómo se duplican las pestañas). + if let Some((si, ti, _)) = m + .task_rows + .iter() + .find(|r| r.id == id) + .and_then(|r| r.en_tab.clone()) + { + if si < m.sessions.len() { + m.active_session = si; + m.sessions[si].workspace.switch_tab(ti); + } + } + m.taskmanager_open = false; + } + Msg::TaskKill(id) => { + shuma_module_shell::matar_sesion(&id); + crate::refrescar_task_rows(&mut m, handle); + } + Msg::TabCloseOthers(i) => { + m.tab_ctx = None; + if let Some(s) = m.sessions.get_mut(m.active_session) { + let dropped = s.workspace.close_others(i); + olvidar_montadas_de(&dropped); + } + } + Msg::TabCloseRight(i) => { + m.tab_ctx = None; + if let Some(s) = m.sessions.get_mut(m.active_session) { + // Mismo trato que `TabClose`: dropear las `Instance` DETACHA + // (la sesión persistente sigue viva en el daemon), y hay que + // des-registrarlas del auto-reattach para que no revivan. + let dropped = s.workspace.close_right(i); + olvidar_montadas_de(&dropped); + } + } + Msg::TabDuplicate(i) => { + m.tab_ctx = None; + if let Some(s) = m.sessions.get_mut(m.active_session) { + if !s.pending { + // El cwd de la tab que se duplica: un shell fresco parado + // donde estabas. Sin scrollback heredado — eso es historia + // de la otra tab, no del duplicado. + let cwd = s.workspace.tabs.get(i).and_then(|t| t.cwd_enfocado()); + let mut inst = Instance::shell(s.name.clone(), s.source.clone()); + if let (Some(cwd), ModuleState::Shell(st)) = (cwd, &mut inst.state) { + st.cwd = cwd; + } + s.workspace.new_tab(inst); + } + } + } + Msg::TabMove(i, derecha) => { + m.tab_ctx = None; + if let Some(s) = m.sessions.get_mut(m.active_session) { + s.workspace.move_tab(i, derecha); + } + } + Msg::TabRenameOpen(i) => { + m.tab_ctx = None; + let actual = m + .active() + .map(|s| s.workspace.tab_name(i)) + .unwrap_or_default(); + let mut campo = TextInputState::default(); + campo.set_text(actual); + m.tab_rename = Some((i, campo)); + } + // Esc cancela, Enter confirma, el resto edita el campo — mismo + // trato que el nombre de un layout (`Msg::LayoutNameKey`). + Msg::TabRenameKey(e) => match &e.key { + llimphi_ui::Key::Named(llimphi_ui::NamedKey::Escape) => { + m.tab_rename = None; + } + llimphi_ui::Key::Named(llimphi_ui::NamedKey::Enter) => { + handle.dispatch(Msg::TabRenameCommit); + } + _ => { + if let Some((_, campo)) = m.tab_rename.as_mut() { + campo.handle(TextInputEvent::Key(e.clone()), &mut m.clipboard); + } + } + }, + Msg::TabRenameCommit => { + if let Some((i, campo)) = m.tab_rename.take() { + let nombre = campo.text().to_string(); + if let Some(s) = m.sessions.get_mut(m.active_session) { + s.workspace.rename_tab(i, &nombre); + } + } + } + Msg::TabRenameCancel => { + m.tab_rename = None; + } + Msg::TabCtxOpen(i, x, y) => { + m.tab_ctx = Some((i, x, y)); + m.ctx_menu = None; + m.menu_open = None; + m.menu_active = usize::MAX; + } + Msg::FloatNew => { + if let Some(s) = m.sessions.get_mut(m.active_session) { + if !s.pending { + let inst = Instance::shell(s.name.clone(), s.source.clone()); + s.workspace.new_float(inst); + } + } + } + Msg::FloatToggle => { + if let Some(s) = m.sessions.get_mut(m.active_session) { + s.workspace.toggle_floating(); + } + } + Msg::FloatMove(id, dx, dy) => { + if let Some(s) = m.sessions.get_mut(m.active_session) { + s.workspace.move_float(id, dx, dy); + } + } + + // ─── Perfiles ─────────────────────────────────────────── + Msg::ShortcutEnterPrefix => { + m.pending_prefix = true; + } + Msg::ShortcutCancelPrefix => { + m.pending_prefix = false; + } + Msg::ShortcutFire(act) => { + m.pending_prefix = false; + if let Some(concrete) = act.to_concrete(&m) { + handle.dispatch(concrete); + } + } + Msg::SwitchShortcutProfile(name) => { + if m.shortcuts.set_active(&name).is_ok() { + m.pending_prefix = false; + if let Some(p) = perfiles::shortcuts::ShortcutProfiles::default_path() { + let _ = m.shortcuts.save(&p); + } + } + } + Msg::SwitchAppearanceProfile(name) => { + if m.appearance.set_active(&name).is_ok() { + if let Some(p) = perfiles::appearance::AppearanceProfiles::default_path() { + let _ = m.appearance.save(&p); + } + perfiles::apply_active_appearance(&mut m); + } + } + Msg::SetSessionAppearance(name) => { + if let Some(s) = m.sessions.get_mut(m.active_session) { + s.appearance = name; + } + save_sessions(&m); + perfiles::apply_active_appearance(&mut m); + } + Msg::SwitchSessionProfile(name) => { + m = switch_session_profile(m, &name); + } + + // ─── Modal de gestión de perfiles ─────────────────────── + Msg::OpenPerfilesModal => { + m.perfiles_modal_open = true; + m.prof_name_focused = true; + m.menu_open = None; + } + Msg::ClosePerfilesModal => { + m.perfiles_modal_open = false; + m.prof_name_focused = false; + m.prof_name.set_text(""); + m.wp_path_focused = false; + } + Msg::PerfilesTab(kind) => { + m.perfiles_tab = kind; + } + Msg::ProfNameFocus => { + m.prof_name_focused = true; + } + Msg::ProfNameKey(e) => match &e.key { + llimphi_ui::Key::Named(llimphi_ui::NamedKey::Escape) => { + m.prof_name_focused = false; + } + llimphi_ui::Key::Named(llimphi_ui::NamedKey::Enter) => { + handle.dispatch(Msg::ProfCreate(m.perfiles_tab)); + } + _ => { + m.prof_name.handle(TextInputEvent::Key(e.clone()), &mut m.clipboard); + } + }, + Msg::ProfNameCampo(ev) => { + if matches!(ev, TextInputEvent::Press(_)) { + m.prof_name_focused = true; + m.wp_path_focused = false; + } + m.prof_name.handle(ev, &mut m.clipboard); + } + Msg::ProfUse(kind, name) => { + let next = match kind { + ProfKind::Shortcuts => Msg::SwitchShortcutProfile(name), + ProfKind::Appearance => Msg::SwitchAppearanceProfile(name), + ProfKind::Sessions => Msg::SwitchSessionProfile(name), + }; + handle.dispatch(next); + } + Msg::ProfDuplicate(kind, src) => { + let typed = m.prof_name.text().trim().to_string(); + let name = if typed.is_empty() { format!("{src} copia") } else { typed }; + let ok = match kind { + ProfKind::Shortcuts => m.shortcuts.duplicate(&src, &name).is_ok(), + ProfKind::Appearance => m.appearance.duplicate(&src, &name).is_ok(), + ProfKind::Sessions => m.session_profiles.create(&name).is_ok(), + }; + if ok { + save_profiles(&m, kind); + m.prof_name.set_text(""); + } + } + Msg::ProfRename(kind, src) => { + let to = m.prof_name.text().trim().to_string(); + if to.is_empty() { + // sin nombre nuevo no hay nada que hacer + } else { + let ok = match kind { + ProfKind::Shortcuts => m.shortcuts.rename(&src, &to).is_ok(), + ProfKind::Appearance => m.appearance.rename(&src, &to).is_ok(), + ProfKind::Sessions => { + m = rename_session_profile(m, &src, &to); + m.session_profiles.contains(&to) + } + }; + if ok { + save_profiles(&m, kind); + m.prof_name.set_text(""); + } + } + } + Msg::ProfDelete(kind, name) => { + let ok = match kind { + ProfKind::Shortcuts => m.shortcuts.remove(&name).is_ok(), + ProfKind::Appearance => m.appearance.remove(&name).is_ok(), + ProfKind::Sessions => m.session_profiles.remove(&name).is_ok(), + }; + if ok { + save_profiles(&m, kind); + if kind == ProfKind::Appearance { + perfiles::apply_active_appearance(&mut m); + } + } + } + Msg::ProfCreate(kind) => { + let name = m.prof_name.text().trim().to_string(); + if !name.is_empty() { + let ok = match kind { + // Atajos: arranca como el nativo `shuma`. + ProfKind::Shortcuts => { + let base = perfiles::shortcuts::preset("shuma").expect("preset"); + m.shortcuts.create(&name, base).is_ok() + } + // Apariencia: arranca como el perfil activo (o Oscuro). + ProfKind::Appearance => { + let base = m.appearance.active_appearance(); + m.appearance.create(&name, base).is_ok() + } + // Sesión: contexto nuevo y vacío. + ProfKind::Sessions => m.session_profiles.create(&name).is_ok(), + }; + if ok { + save_profiles(&m, kind); + m.prof_name.set_text(""); + } + } + } + Msg::WpPathFocus => { + m.wp_path_focused = true; + m.prof_name_focused = false; + } + Msg::WpPathKey(e) => match &e.key { + llimphi_ui::Key::Named(llimphi_ui::NamedKey::Escape) => { + m.wp_path_focused = false; + } + llimphi_ui::Key::Named(llimphi_ui::NamedKey::Enter) => { + handle.dispatch(Msg::SetWallpaperActive); + } + _ => { + m.wp_path.handle(TextInputEvent::Key(e.clone()), &mut m.clipboard); + } + }, + Msg::WpPathCampo(ev) => { + if matches!(ev, TextInputEvent::Press(_)) { + m.wp_path_focused = true; + m.prof_name_focused = false; + } + m.wp_path.handle(ev, &mut m.clipboard); + } + Msg::SetWallpaperActive => { + let path = m.wp_path.text().trim().to_string(); + if !path.is_empty() { + let active = m.appearance.active().to_string(); + if m.appearance.set_wallpaper(&active, Some(path)).is_ok() { + save_profiles(&m, ProfKind::Appearance); + // Forzar re-decodificación aunque el path lógico no haya + // cambiado de nombre (p.ej. mismo perfil, archivo nuevo). + m.wallpaper_path = None; + perfiles::apply_active_appearance(&mut m); + } + } + } + Msg::ClearWallpaperActive => { + let active = m.appearance.active().to_string(); + if m.appearance.set_wallpaper(&active, None).is_ok() { + save_profiles(&m, ProfKind::Appearance); + m.wp_path.set_text(""); + perfiles::apply_active_appearance(&mut m); + } + } + Msg::SetProceduralBg(slug) => { + let active = m.appearance.active().to_string(); + if m.appearance.set_background(&active, Some(slug)).is_ok() { + save_profiles(&m, ProfKind::Appearance); + perfiles::apply_active_appearance(&mut m); + } + } + Msg::ClearProceduralBg => { + let active = m.appearance.active().to_string(); + if m.appearance.set_background(&active, None).is_ok() { + save_profiles(&m, ProfKind::Appearance); + perfiles::apply_active_appearance(&mut m); + } + } + _ => unreachable!("app_update_more::apply recibió un Msg de la mitad 1"), + } + sync_host_active(&mut m); + m +} + +/// Manda **al fondo** (des-registra del auto-reattach) las sesiones persistentes +/// del daemon de las instancias que se están cerrando. Cerrar una tab/sesión es +/// un DETACH: la sesión del daemon queda VIVA y recuperable desde el taskmanager, +/// pero deja de auto-montarse sola al próximo arranque de pata — que era el bug +/// de «los que cerré reviven todos». Un pane local (sin ULID) es no-op. +fn olvidar_montadas_de(instancias: &[Instance]) { + for inst in instancias { + if let ModuleState::Shell(s) = &inst.state { + if let Some(ulid) = s.montada_session() { + shuma_module_shell::olvidar_montada(ulid); + } + } + } +} diff --git a/02_ruway/shuma/shuma-shell-llimphi/src/app_view.rs b/02_ruway/shuma/shuma-shell-llimphi/src/app_view.rs new file mode 100644 index 0000000..638ad5d --- /dev/null +++ b/02_ruway/shuma/shuma-shell-llimphi/src/app_view.rs @@ -0,0 +1,336 @@ +//! Vistas de `Shell`: la principal, el overlay (modales/menús/dropdowns) y el +//! modo dock. Extraído de `lib.rs` para adelgazar el `impl App`. + +use super::*; +use crate::view::*; + +/// Vista principal (delegada desde `App::view`). +pub(crate) fn main_view(model: &Model) -> View { + let theme = &model.theme; + + // Modo dock: vista compacta para la barra layer-shell — la command-bar a + // todo lo ancho + un botón para volver a ventana. Sin tabs/monitores. + if model.dock_mode { + return dock_bar_view(model, theme); + } + + // Chromeless (drawer de pata): SÓLO el canvas — sin menubar, sin topbar + // de tabs, sin command-bar (el input vive en la barra de pata). + let content = if model.chromeless { + vec![render_main_area(model, theme)] + } else { + let menubar = menu::menubar_row(model, theme); + let topbar = render_topbar(model, theme); + let main_area = render_main_area(model, theme); + let bottombar = render_bottombar(model, theme); + vec![menubar, topbar, main_area, bottombar] + }; + + // Fondo detrás del contenido: una imagen de wallpaper explícita gana; si + // no, el fondo procedural propio de shuma (párpados). En modo chromeless + // (drawer de pata) se omite el procedural para dejar ver el escritorio. + let bg_image = model.wallpaper_img.as_ref().or_else(|| { + if model.chromeless { + None + } else { + model.bg_procedural_img.as_ref() + } + }); + + // Con fondo: capa de imagen a tamaño completo (Cover) detrás de una + // columna de contenido con fondo (translúcido) que la deja ver. + if let Some(img) = bg_image { + let full = Size { + width: percent(1.0_f32), + height: percent(1.0_f32), + }; + let bg = View::new(Style { + position: Position::Absolute, + inset: Rect { + left: length(0.0_f32), + top: length(0.0_f32), + right: length(0.0_f32), + bottom: length(0.0_f32), + }, + size: full, + ..Default::default() + }) + .image(img.clone()) + .image_fit(ImageFit::Cover); + let column = View::new(Style { + flex_direction: FlexDirection::Column, + size: full, + ..Default::default() + }) + .fill(theme.bg_app) + .children(content); + return View::new(Style { + size: full, + ..Default::default() + }) + .on_right_click_at(|x, y, _w, _h| Some(Msg::ContextMenuOpen(x, y))) + .children(vec![bg, column]); + } + + // Chromeless (drawer): fondo OPACO igual que la ventana normal. El + // translúcido 0.86 original ("dejar ver el escritorio detrás") en metal + // resultaba "una cosa transparente inútil y horrible" (sergio, 2026-07-14): + // el drawer no tiene frost del compositor detrás (mirada solo frostea + // barras finas), así que el glass componía directo sobre las ventanas y + // el texto de abajo se cruzaba con el del shell. Una terminal se lee + // sobre fondo sólido. + let fondo = theme.bg_app.with_alpha(1.0); + View::new(Style { + flex_direction: FlexDirection::Column, + size: Size { + width: percent(1.0_f32), + height: percent(1.0_f32), + }, + ..Default::default() + }) + .fill(fondo) + .on_right_click_at(|x, y, _w, _h| Some(Msg::ContextMenuOpen(x, y))) + .children(content) +} + +/// Overlay: modales, dropdowns y menús (delegada desde `App::view_overlay`). +pub(crate) fn overlay_view(model: &Model) -> Option> { + if model.hosts_modal_open { + return Some(view::hosts_modal(model, &model.theme)); + } + if model.containers_modal_open { + return Some(view::containers_modal(model, &model.theme)); + } + if model.layouts_modal_open { + return Some(view::layouts_modal(model, &model.theme)); + } + if model.perfiles_modal_open { + return Some(view::perfiles_modal(model, &model.theme)); + } + // Multiselect de disposición de un sidebar unificado (los 4 ejes): card + // externa con backdrop de click-away cuando su control ⚙ está abierto. + if let Some(v) = view::session_multiselect_overlay(model, &model.theme) { + return Some(v); + } + if let Some(v) = view::tool_multiselect_overlay(model, &model.theme) { + return Some(v); + } + view::dropdown_overlay(model).or_else(|| menu::overlay(model)) +} + +/// Vista compacta para el **modo dock** (barra layer-shell): la command-bar a +/// todo lo ancho + un botón «ventana» que vuelve al modo ventana. La barra es +/// fina (la fija `llimphi-layer`), así que no caben tabs/monitores. +fn dock_bar_view(model: &Model, theme: &Theme) -> View { + use llimphi_ui::llimphi_layout::taffy::prelude::{auto, AlignItems, JustifyContent}; + use llimphi_ui::llimphi_layout::taffy::Rect; + + // Input real de comando (la command-bar cableada), voraz en el centro. + let input = View::new(Style { + flex_grow: 1.0, + flex_basis: length(0.0_f32), + size: Size { width: auto(), height: percent(1.0_f32) }, + align_items: Some(AlignItems::Center), + ..Default::default() + }) + .children(vec![render_bottombar(model, theme)]); + + let btn = View::new(Style { + flex_shrink: 0.0, + size: Size { width: length(58.0_f32), height: length(28.0_f32) }, + align_items: Some(AlignItems::Center), + justify_content: Some(JustifyContent::Center), + ..Default::default() + }) + .fill(theme.bg_panel) + .radius(7.0) + .hover_fill(theme.bg_row_hover) + .on_click(Msg::MenuCommand("window.toggle-dock".to_string())) + .text_aligned("ventana".to_string(), 11.0, theme.fg_muted, llimphi_ui::llimphi_text::Alignment::Center); + + View::new(Style { + flex_direction: FlexDirection::Row, + size: Size { width: percent(1.0_f32), height: percent(1.0_f32) }, + align_items: Some(AlignItems::Center), + gap: Size { width: length(11.0_f32), height: length(0.0_f32) }, + padding: Rect { + left: length(9.0_f32), + right: length(11.0_f32), + top: length(0.0_f32), + bottom: length(0.0_f32), + }, + ..Default::default() + }) + .fill(theme.bg_app) + .children(vec![ + dock_ps1(theme), + dock_ctx_labels(theme), + input, + dock_mic(model, theme), + dock_clock(theme), + btn, + ]) +} + +/// PS1 gráfico: emblema de anillos de estado (placeholder del Rive; la barra de +/// mando del navegador de ventanas — ver 02_ruway/mirada/DISENO-SHELL-NAVEGADOR.md). +fn dock_ps1(theme: &Theme) -> View { + let accent = theme.accent; + let panel_alt = theme.bg_panel_alt; + let verde = llimphi_ui::llimphi_raster::peniko::Color::from_rgb8(0x5A, 0xD0, 0x8A); + let ambar = llimphi_ui::llimphi_raster::peniko::Color::from_rgb8(0xE0, 0xB2, 0x4A); + View::new(Style { + flex_shrink: 0.0, + size: Size { width: length(34.0_f32), height: length(34.0_f32) }, + ..Default::default() + }) + .paint_with(move |scene, _ts, rect| { + use llimphi_ui::llimphi_raster::kurbo::{Affine, Circle, RoundedRect, Stroke}; + use llimphi_ui::llimphi_raster::peniko::Fill; + if rect.w <= 0.0 || rect.h <= 0.0 { + return; + } + let cx = (rect.x + rect.w * 0.5) as f64; + let cy = (rect.y + rect.h * 0.5) as f64; + let lado = rect.w.min(rect.h) as f64; + let bg = RoundedRect::new( + rect.x as f64, + rect.y as f64, + (rect.x + rect.w) as f64, + (rect.y + rect.h) as f64, + 9.0, + ); + scene.fill(Fill::NonZero, Affine::IDENTITY, panel_alt, None, &bg); + // Anillos de estado concéntricos (emblema oh-my-zsh, gráfico y vivo). + for (i, c) in [accent, verde, ambar].iter().enumerate() { + let r = lado * 0.15 + i as f64 * lado * 0.105; + let a = 0.95 - i as f32 * 0.22; + scene.stroke(&Stroke::new(2.1), Affine::IDENTITY, c.with_alpha(a), None, &Circle::new((cx, cy), r)); + } + // Núcleo + satélite (como el emblema del boceto barra_mando_demo). + scene.fill(Fill::NonZero, Affine::IDENTITY, accent, None, &Circle::new((cx, cy), lado * 0.085)); + scene.fill( + Fill::NonZero, + Affine::IDENTITY, + verde, + None, + &Circle::new((cx + lado * 0.30, cy - lado * 0.30), lado * 0.05), + ); + }) +} + +/// Rama git de `cwd` (o de un ancestro): lee `.git/HEAD`. Sin dependencias. +fn dock_git_branch(cwd: &std::path::Path) -> Option { + let mut dir = Some(cwd); + while let Some(d) = dir { + if let Ok(s) = std::fs::read_to_string(d.join(".git/HEAD")) { + let s = s.trim(); + return Some( + s.strip_prefix("ref: refs/heads/") + .map(str::to_string) + .unwrap_or_else(|| s.chars().take(7).collect()), + ); + } + dir = d.parent(); + } + None +} + +/// Labels tipo Flutter: pwd (abreviado con ~) + rama git en acento. +fn dock_ctx_labels(theme: &Theme) -> View { + use llimphi_ui::llimphi_layout::taffy::prelude::auto; + use llimphi_ui::llimphi_text::Alignment; + let cwd = std::env::current_dir().unwrap_or_else(|_| std::path::PathBuf::from("/")); + let pretty = match std::env::var("HOME").ok().filter(|h| !h.is_empty()) { + Some(home) if cwd.starts_with(&home) => { + format!("~{}", cwd.to_string_lossy().trim_start_matches(&home)) + } + _ => cwd.to_string_lossy().into_owned(), + }; + let git = dock_git_branch(&cwd); + let pwd_v = View::new(Style { + size: Size { width: percent(1.0_f32), height: length(15.0_f32) }, + ..Default::default() + }) + .text_aligned(pretty, 11.0, theme.fg_text, Alignment::Start) + .ellipsis(1); + let git_v = View::new(Style { + size: Size { width: percent(1.0_f32), height: length(13.0_f32) }, + ..Default::default() + }) + .text_aligned( + git.map(|b| format!("· {b}")).unwrap_or_default(), + 10.0, + theme.accent, + Alignment::Start, + ); + View::new(Style { + flex_direction: FlexDirection::Column, + flex_shrink: 0.0, + size: Size { width: length(186.0_f32), height: auto() }, + gap: Size { width: length(0.0_f32), height: length(2.0_f32) }, + justify_content: Some(llimphi_ui::llimphi_layout::taffy::prelude::JustifyContent::Center), + ..Default::default() + }) + .children(vec![pwd_v, git_v]) +} + +/// Botón de micrófono: refleja el estado de escucha del agente (halo cuando está +/// activo). El toggle real (voz) se cablea en la próxima iteración. +fn dock_mic(model: &Model, theme: &Theme) -> View { + let activo = model.agente.escucha().activo(); + let accent = theme.accent; + let apagado = theme.fg_muted; + let tick = model.tick_count; + View::new(Style { + flex_shrink: 0.0, + size: Size { width: length(30.0_f32), height: length(30.0_f32) }, + ..Default::default() + }) + .paint_with(move |scene, _ts, rect| { + use llimphi_ui::llimphi_raster::kurbo::{Affine, BezPath, Circle, Line, Point, RoundedRect, Stroke}; + use llimphi_ui::llimphi_raster::peniko::Fill; + if rect.w <= 0.0 || rect.h <= 0.0 { + return; + } + let cx = (rect.x + rect.w * 0.5) as f64; + let cy = (rect.y + rect.h * 0.5) as f64; + let lado = rect.w.min(rect.h) as f64; + if activo { + for k in 0..3u32 { + let fase = (((tick as f64) * 0.25) + k as f64 / 3.0).fract(); + let r = lado * 0.20 + fase * lado * 0.42; + let a = ((1.0 - fase as f32) * 0.9).clamp(0.0, 1.0); + scene.stroke(&Stroke::new(1.5), Affine::IDENTITY, accent.with_alpha(a), None, &Circle::new((cx, cy), r)); + } + } + let gc = if activo { accent } else { apagado }; + let bw = lado * 0.12; + let bh = lado * 0.21; + let cap = RoundedRect::new(cx - bw, cy - bh - 2.0, cx + bw, cy + bh - 2.0, bw); + scene.fill(Fill::NonZero, Affine::IDENTITY, gc, None, &cap); + let aw = bw + 2.5; + let ay = cy + bh - 2.0; + let mut u = BezPath::new(); + u.move_to(Point::new(cx - aw, cy - 2.0)); + u.quad_to(Point::new(cx - aw, ay + 1.5), Point::new(cx, ay + 1.5)); + u.quad_to(Point::new(cx + aw, ay + 1.5), Point::new(cx + aw, cy - 2.0)); + scene.stroke(&Stroke::new(1.4), Affine::IDENTITY, gc, None, &u); + scene.stroke(&Stroke::new(1.4), Affine::IDENTITY, gc, None, &Line::new(Point::new(cx, ay + 1.5), Point::new(cx, ay + 4.5))); + }) +} + +/// Reloj grande fijo (HH:MM local). +fn dock_clock(theme: &Theme) -> View { + use llimphi_ui::llimphi_text::Alignment; + let hora = chrono::Local::now().format("%H:%M").to_string(); + View::new(Style { + flex_shrink: 0.0, + size: Size { width: length(52.0_f32), height: percent(1.0_f32) }, + align_items: Some(llimphi_ui::llimphi_layout::taffy::prelude::AlignItems::Center), + justify_content: Some(llimphi_ui::llimphi_layout::taffy::prelude::JustifyContent::Center), + ..Default::default() + }) + .text_aligned(hora, 18.0, theme.fg_text, Alignment::End) + .text_weight(600.0) +} diff --git a/02_ruway/shuma/shuma-shell-llimphi/src/config.rs b/02_ruway/shuma/shuma-shell-llimphi/src/config.rs index 646da27..317c504 100644 --- a/02_ruway/shuma/shuma-shell-llimphi/src/config.rs +++ b/02_ruway/shuma/shuma-shell-llimphi/src/config.rs @@ -39,7 +39,7 @@ //! //! El chasis ya no es un Quake-drawer — shuma es la app standalone //! "normal" del workspace. La metáfora overlay/F12 vive en -//! `mirada-launcher-llimphi`, no acá. +//! `mirada-launcher-llimphi`, no aquí. use serde::Deserialize; use shuma_module::Source; @@ -64,9 +64,10 @@ pub struct SlotEntry { pub inventory: Option, } -/// Una entrada del array `[[tabs]]`. Mismo shape que [`SlotEntry`] -/// pero con el `id` separado del campo `module` por convención del -/// shumarc. +/// Una entrada del array `[[tabs]]`. Superada por el modelo de **sesiones** +/// (las vistas shell/hosts/vhosts/canvas son fijas por sesión); se sigue +/// parseando para no romper shumarc viejos, pero ya no arma tabs. +#[allow(dead_code)] #[derive(Debug, Clone, Deserialize)] pub struct TabEntry { /// `id` del módulo a activar como tab. @@ -85,6 +86,8 @@ pub struct ShumaConfig { pub topbar: Option, pub bottombar: Option, pub main: Option, + /// Superado por el modelo de sesiones; se parsea por compatibilidad. + #[allow(dead_code)] #[serde(default)] pub tabs: Vec, } diff --git a/02_ruway/shuma/shuma-shell-llimphi/src/containers.rs b/02_ruway/shuma/shuma-shell-llimphi/src/containers.rs new file mode 100644 index 0000000..71c0cb6 --- /dev/null +++ b/02_ruway/shuma/shuma-shell-llimphi/src/containers.rs @@ -0,0 +1,596 @@ +//! Helpers de contenedores, rootfs y detección de engines. +//! +//! Funciones de spawn para listar, crear, iniciar, parar y borrar +//! contenedores (locales y remotos), más la gestión de rootfs LXC +//! para unshare/bwrap. +//! +//! Las funciones de detección de engines (`binary_disponible`, +//! `engine_preferido`, etc.) viven en `env.rs`; este módulo las re-exporta +//! para comodidad de los llamadores que sólo hacen `use super::containers::*`. + +pub(crate) use crate::env::{ + binary_disponible, bwrap_disponible, engine_preferido, podman_disponible, + unshare_disponible, +}; + +use crate::types::{ContainerInfo, Distro, ExplorerEntry, Msg}; +use llimphi_ui::Handle; +use shuma_module::Source; + +// ─── Rootfs (unshare / bwrap) ─────────────────────────────────────── + +/// Path donde shuma extrae rootfs LXC para usar con bwrap/unshare. +pub(crate) fn rootfs_root() -> Option { + directories::BaseDirs::new().map(|b| b.data_local_dir().join("shuma").join("rootfs")) +} + +/// Path donde la `distro` tiene su rootfs extraído. +pub(crate) fn rootfs_path_for(distro: Distro) -> Option { + rootfs_root().map(|r| r.join(distro.label().to_lowercase())) +} + +/// `true` si el rootfs de esa distro ya está extraído. +pub(crate) fn rootfs_listo(distro: Distro) -> bool { + let Some(root) = rootfs_path_for(distro) else { + return false; + }; + root.join("bin/bash").exists() || root.join("usr/bin/bash").exists() +} + +/// Prepara un rootfs para que los gestores de paquetes funcionen en +/// un userns de un solo uid. Idempotente y best-effort. +pub(crate) fn prepare_rootfs(root: &std::path::Path) { + // apt (Debian/Ubuntu): drop-in que desactiva el sandbox de descarga. + let apt_dir = root.join("etc/apt/apt.conf.d"); + if apt_dir.is_dir() { + let f = apt_dir.join("99shuma-nosandbox"); + if !f.exists() { + let _ = std::fs::write(&f, "APT::Sandbox::User \"root\";\n"); + } + } + // pacman (Arch): comentar `DownloadUser` para que descargue como root. + let pac = root.join("etc/pacman.conf"); + if let Ok(txt) = std::fs::read_to_string(&pac) { + let activa = |l: &str| { + let t = l.trim_start(); + !t.starts_with('#') && t.starts_with("DownloadUser") + }; + if txt.lines().any(activa) { + let nuevo: String = txt + .lines() + .map(|l| { + if activa(l) { + format!("#{l} # shuma: descarga como root (userns de 1 uid)") + } else { + l.to_string() + } + }) + .collect::>() + .join("\n"); + let _ = std::fs::write(&pac, format!("{nuevo}\n")); + } + } +} + +// ─── LXC image ───────────────────────────────────────────────────── + +/// Triple `(distro_slug, release, arch)` para construir la URL del LXC image. +fn lxc_image_triple(distro: Distro) -> (&'static str, &'static str, &'static str) { + match distro { + Distro::Ubuntu => ("ubuntu", "noble", "amd64"), + Distro::Debian => ("debian", "bookworm", "amd64"), + Distro::Alpine => ("alpine", "3.22", "amd64"), + Distro::Arch => ("archlinux", "current", "amd64"), + } +} + +/// Quote estilo Bourne para args a `bash -c '...'`. +pub(crate) fn shell_quote_arg(s: &str) -> String { + let mut out = String::with_capacity(s.len() + 2); + out.push('\''); + for ch in s.chars() { + if ch == '\'' { + out.push_str("'\\''"); + } else { + out.push(ch); + } + } + out.push('\''); + out +} + +/// Descarga + extrae el rootfs LXC para `distro`. Al terminar, dispatcha +/// `ContainerCreated(name)` o `ContainerFailed{reason}`. +pub(crate) fn spawn_pull_rootfs_lxc(handle: &Handle, distro: Distro, mount: Option) { + let _ = mount; + let (d, rel, arch) = lxc_image_triple(distro); + let Some(root) = rootfs_path_for(distro) else { + let name = format!("rootfs:{}", distro.label().to_lowercase()); + handle.spawn(move || Msg::ContainerFailed { + name, + reason: "no se pudo resolver $XDG_DATA_HOME".into(), + }); + return; + }; + let root_str = root.display().to_string(); + let name_for_msg = root_str.clone(); + handle.spawn(move || { + if let Err(e) = std::fs::create_dir_all(&root) { + return Msg::ContainerFailed { + name: name_for_msg, + reason: format!("mkdir {}: {e}", root.display()), + }; + } + let base = format!( + "https://images.linuxcontainers.org/images/{d}/{rel}/{arch}/default" + ); + let cmd = format!( + "set -o pipefail; \ + dir=$(curl -fsSL {base}/ | grep -oE '[0-9]{{8}}_[0-9]{{2}}%3A[0-9]{{2}}/' | sort | tail -1); \ + test -n \"$dir\" || {{ echo 'no encontré builds en el índice LXC' >&2; exit 1; }}; \ + curl -L -fsSL {base}/\"$dir\"rootfs.tar.xz | tar -xJ -C {root}", + base = shell_quote_arg(&base), + root = shell_quote_arg(&root.display().to_string()), + ); + match std::process::Command::new("bash") + .args(["-c", &cmd]) + .output() + { + Ok(out) if out.status.success() => Msg::ContainerCreated(name_for_msg), + Ok(out) => { + let err = String::from_utf8_lossy(&out.stderr) + .lines() + .last() + .unwrap_or("curl|tar salió con status no-cero") + .to_string(); + Msg::ContainerFailed { name: name_for_msg, reason: err } + } + Err(e) => Msg::ContainerFailed { + name: name_for_msg, + reason: format!("no pude ejecutar bash: {e}"), + }, + } + }); +} + +// ─── Spawn: containers locales ────────────────────────────────────── + +/// Lista los contenedores locales (`podman ps -a`) y entrega los nombres +/// por `Msg::ContainersLoaded`. +pub(crate) fn spawn_list_containers(handle: &Handle) { + handle.spawn(|| { + let names = std::process::Command::new("podman") + .args(["ps", "-a", "--format", "{{.Names}}"]) + .output() + .ok() + .filter(|o| o.status.success()) + .map(|o| { + String::from_utf8_lossy(&o.stdout) + .lines() + .map(|l| l.trim().to_string()) + .filter(|l| !l.is_empty()) + .collect::>() + }) + .unwrap_or_default(); + Msg::ContainersLoaded(names) + }); +} + +/// Lista containers locales con su status + image (ventana gestora). +pub(crate) fn spawn_list_containers_full(handle: &Handle) { + handle.spawn(|| { + let mut infos: Vec = Vec::new(); + // 1. Rootfs en disco (unshare/bwrap) — la lista PERSISTENTE. + if let Some(root) = rootfs_root() { + if let Ok(rd) = std::fs::read_dir(&root) { + let mut dirs: Vec<_> = rd + .flatten() + .filter(|e| e.path().is_dir()) + .map(|e| e.file_name().to_string_lossy().to_string()) + .collect(); + dirs.sort(); + for name in dirs { + let p = root.join(&name); + let listo = p.join("bin/bash").exists() || p.join("usr/bin/bash").exists(); + infos.push(ContainerInfo { + name, + status: if listo { "listo".into() } else { "incompleto".into() }, + image: "rootfs · unshare/bwrap".into(), + rootfs: true, + }); + } + } + } + // 2. Containers podman/docker. + let podman = std::process::Command::new("podman") + .args(["ps", "-a", "--format", "{{.Names}}\t{{.Status}}\t{{.Image}}"]) + .output() + .ok() + .filter(|o| o.status.success()) + .map(|o| { + String::from_utf8_lossy(&o.stdout) + .lines() + .filter_map(|l| { + let mut it = l.splitn(3, '\t'); + let name = it.next()?.trim().to_string(); + let status = it.next().unwrap_or("").trim().to_string(); + let image = it.next().unwrap_or("").trim().to_string(); + if name.is_empty() { + None + } else { + Some(ContainerInfo { name, status, image, rootfs: false }) + } + }) + .collect::>() + }) + .unwrap_or_default(); + infos.extend(podman); + Msg::ContainersFullLoaded(infos) + }); +} + +/// Borra un rootfs en disco en bg y refresca la lista. +pub(crate) fn spawn_remove_rootfs(handle: &Handle, name: String) { + handle.spawn(move || { + if let Some(root) = rootfs_root() { + let p = root.join(&name); + if p.starts_with(&root) && p.is_dir() { + let _ = std::fs::remove_dir_all(&p); + } + } + Msg::RefreshContainersFull + }); +} + +/// Dispara `podman ` en bg; al terminar, refresca la lista. +pub(crate) fn spawn_container_action(handle: &Handle, action: &'static str, name: String) { + handle.spawn(move || { + let mut args: Vec = if action == "rm" { + vec!["rm".into(), "-f".into()] + } else { + vec![action.into()] + }; + args.push(name.clone()); + let _ = std::process::Command::new("podman").args(&args).output(); + Msg::RefreshContainersFull + }); +} + +/// Se asegura de que el container `name` esté corriendo. +pub(crate) fn spawn_ensure_container(handle: &Handle, name: String) { + handle.spawn(move || { + match std::process::Command::new("podman") + .args(["start", &name]) + .output() + { + Ok(out) if out.status.success() => Msg::ContainerCreated(name), + Ok(out) => { + let err = String::from_utf8_lossy(&out.stderr) + .lines() + .next() + .unwrap_or("podman start salió con status no-cero") + .to_string(); + Msg::ContainerFailed { name, reason: err } + } + Err(e) => Msg::ContainerFailed { + name, + reason: format!("no pude ejecutar podman: {e}"), + }, + } + }); +} + +/// Crea un contenedor `name` de la `image` dada (detached, `sleep infinity`). +pub(crate) fn spawn_create_container( + handle: &Handle, + image: &'static str, + name: String, + mount: Option, +) { + handle.spawn(move || { + let mut args: Vec = vec![ + "run".into(), + "-d".into(), + "--name".into(), + name.clone(), + ]; + if let Some(m) = mount.as_ref().map(|m| m.trim()).filter(|m| !m.is_empty()) { + args.push("-v".into()); + args.push(format!("{m}:/work")); + args.push("-w".into()); + args.push("/work".into()); + } + args.push(image.into()); + args.push("sleep".into()); + args.push("infinity".into()); + match std::process::Command::new("podman").args(&args).output() { + Ok(out) if out.status.success() => Msg::ContainerCreated(name), + Ok(out) => { + let err = String::from_utf8_lossy(&out.stderr) + .lines() + .next() + .unwrap_or("podman run salió con status no-cero") + .to_string(); + Msg::ContainerFailed { name, reason: err } + } + Err(e) => Msg::ContainerFailed { + name, + reason: format!("no pude ejecutar podman: {e}"), + }, + } + }); +} + +// ─── Spawn: containers remotos ────────────────────────────────────── + +/// Lista los contenedores de un host remoto vía `ssh`. +pub(crate) fn spawn_list_remote_containers( + handle: &Handle, + host: String, + user: String, + port: u16, + engine: String, +) { + handle.spawn(move || { + let eng = if matches!(engine.as_str(), "podman" | "docker") { + engine.as_str() + } else { + "podman" + }; + let target = format!("{user}@{host}"); + let names = std::process::Command::new("ssh") + .args([ + "-p", + &port.to_string(), + "-o", + "BatchMode=yes", + "-o", + "ConnectTimeout=8", + &target, + "--", + eng, + "ps", + "-a", + "--format", + "{{.Names}}", + ]) + .output() + .ok() + .filter(|o| o.status.success()) + .map(|o| { + String::from_utf8_lossy(&o.stdout) + .lines() + .map(|l| l.trim().to_string()) + .filter(|l| !l.is_empty()) + .collect::>() + }) + .unwrap_or_default(); + Msg::RemoteContainersLoaded(names) + }); +} + +/// Corre ` ` en el host remoto por `ssh`. +pub(crate) fn spawn_remote_engine_action( + handle: &Handle, + host: String, + user: String, + port: u16, + engine: String, + action: &'static str, + name: String, +) { + if !matches!(engine.as_str(), "podman" | "docker") { + return; + } + handle.spawn(move || { + let target = format!("{user}@{host}"); + let mut args: Vec = vec![ + "-p".into(), + port.to_string(), + "-o".into(), + "BatchMode=yes".into(), + "-o".into(), + "ConnectTimeout=8".into(), + target, + "--".into(), + engine, + action.into(), + ]; + if action == "rm" { + args.push("-f".into()); + } + args.push(name); + let _ = std::process::Command::new("ssh").args(&args).output(); + Msg::RefreshRemoteContainers + }); +} + +/// Crea un contenedor en el host remoto. +pub(crate) fn spawn_create_remote_container( + handle: &Handle, + host: String, + user: String, + port: u16, + engine: String, + image: &'static str, + name: String, +) { + if !matches!(engine.as_str(), "podman" | "docker") { + return; + } + handle.spawn(move || { + let target = format!("{user}@{host}"); + let _ = std::process::Command::new("ssh") + .args([ + "-p", + &port.to_string(), + "-o", + "BatchMode=yes", + "-o", + "ConnectTimeout=8", + &target, + "--", + &engine, + "run", + "-d", + "--name", + &name, + image, + "sleep", + "infinity", + ]) + .output(); + Msg::RefreshRemoteContainers + }); +} + +// ─── Spawn: listado del Explorer (cwd remoto por SSH) ─────────────── + +/// Lista el `cwd` de una sesión remota (Remote / RemoteContainer) por `ssh` +/// (BatchMode, igual que el gestor de contenedores remotos) y entrega el +/// resultado por `Msg::ExplorerLoaded`. El trabajo de red va off-thread: +/// `read_dir` local no alcanza al filesystem del host remoto. +pub(crate) fn spawn_explorer_list( + handle: &Handle, + session: usize, + source: Source, + cwd: String, +) { + handle.spawn(move || { + let result = explorer_list_blocking(&source, &cwd); + Msg::ExplorerLoaded { session, path: cwd, result } + }); +} + +/// Construye el comando `ls`, lo manda por SSH al host de `source` y parsea +/// la salida. Sólo Remote / RemoteContainer; otras fuentes son un bug del +/// llamador (el panel local usa `read_dir`). +fn explorer_list_blocking(source: &Source, cwd: &str) -> Result, String> { + let (host, user, port, remote_cmd) = match source { + Source::Remote { host, user, port, .. } => { + // `ls -1Ap`: una entrada por línea, incluye ocultos (sin ./..), + // y sufija `/` a los directorios — así sabemos el tipo sin `stat`. + let cmd = if cwd.starts_with('/') { + format!("ls -1Ap -- {}", shell_quote_arg(cwd)) + } else { + "ls -1Ap".to_string() // cwd "~"/relativo → home de la sesión SSH + }; + (host.as_str(), user.as_str(), *port, cmd) + } + Source::RemoteContainer { host, user, port, engine, name, .. } => { + (host.as_str(), user.as_str(), *port, remote_container_ls_cmd(engine, name, cwd)) + } + _ => return Err("la sesión no es remota".into()), + }; + let out = ssh_capture(host, user, port, &remote_cmd)?; + Ok(parse_ls_output(&out)) +} + +/// Comando que, ejecutado **en el host remoto**, lista el cwd interior de un +/// contenedor de ese host. Espejo mínimo de `remote_container_command` del +/// shell: ` exec` para podman/docker, `chroot` para rootfs. +fn remote_container_ls_cmd(engine: &str, name: &str, cwd: &str) -> String { + let inner = if cwd.starts_with('/') { + format!("cd {} 2>/dev/null; ls -1Ap", shell_quote_arg(cwd)) + } else { + "ls -1Ap".to_string() + }; + match engine { + "unshare" | "bwrap" => format!( + "chroot {} /bin/sh -lc {}", + shell_quote_arg(name), + shell_quote_arg(&inner) + ), + eng => format!( + "{eng} exec -i {} /bin/sh -lc {}", + shell_quote_arg(name), + shell_quote_arg(&inner) + ), + } +} + +/// Corre `remote_cmd` en `user@host:port` por `ssh` y devuelve su stdout, o +/// la primera línea de stderr como error. BatchMode evita prompts colgados. +fn ssh_capture(host: &str, user: &str, port: u16, remote_cmd: &str) -> Result { + let target = format!("{user}@{host}"); + let out = std::process::Command::new("ssh") + .args([ + "-p", + &port.to_string(), + "-o", + "BatchMode=yes", + "-o", + "ConnectTimeout=8", + &target, + remote_cmd, + ]) + .output() + .map_err(|e| format!("no pude ejecutar ssh: {e}"))?; + if out.status.success() { + Ok(String::from_utf8_lossy(&out.stdout).into_owned()) + } else { + let err = String::from_utf8_lossy(&out.stderr); + Err(err + .lines() + .find(|l| !l.trim().is_empty()) + .unwrap_or("ssh falló") + .to_string()) + } +} + +/// Parsea la salida de `ls -1Ap`: directorios sufijados con `/`, ordenados +/// con dirs primero y acotados a 200 (igual que el panel local). +fn parse_ls_output(out: &str) -> Vec { + let mut entradas: Vec = out + .lines() + .map(|l| l.trim_end_matches('\r')) + .filter(|l| !l.is_empty()) + .map(|l| match l.strip_suffix('/') { + Some(n) => ExplorerEntry { is_dir: true, name: n.to_string() }, + None => ExplorerEntry { is_dir: false, name: l.to_string() }, + }) + .collect(); + entradas.sort_by(|a, b| b.is_dir.cmp(&a.is_dir).then(a.name.cmp(&b.name))); + entradas.truncate(200); + entradas +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn parse_ls_separates_dirs_and_sorts() { + // `ls -1Ap` sufija `/` a los dirs; el resto son archivos. Incluye un + // `\r` colgado y una línea vacía para validar el trim/filtro. + let out = "zeta.txt\nsrc/\n.config\nbin/\nalpha.md\r\n\n"; + let e = parse_ls_output(out); + // Dirs primero (alfabético), luego archivos (alfabético, ocultos incl.). + let got: Vec<(bool, &str)> = e.iter().map(|x| (x.is_dir, x.name.as_str())).collect(); + assert_eq!( + got, + vec![ + (true, "bin"), + (true, "src"), + (false, ".config"), + (false, "alpha.md"), + (false, "zeta.txt"), + ] + ); + } + + #[test] + fn remote_container_ls_uses_engine_exec_or_chroot() { + // podman/docker → ` exec`. El cwd interior va dentro del `-lc` + // (las comillas internas quedan escapadas por el quoting anidado, así + // que verificamos el contenido lógico, no la forma exacta del escape). + let podman = remote_container_ls_cmd("podman", "caja", "/work"); + assert!(podman.starts_with("podman exec -i 'caja' /bin/sh -lc")); + assert!(podman.contains("/work") && podman.contains("ls -1Ap")); + // rootfs (unshare/bwrap) → chroot al path. + let rootfs = remote_container_ls_cmd("bwrap", "/home/u/.local/share/shuma/rootfs/arch", "~"); + assert!(rootfs.starts_with("chroot '/home/u/.local/share/shuma/rootfs/arch' /bin/sh -lc")); + // cwd "~" (no absoluto) → sin `cd`, lista el home del contenedor. + assert!(!rootfs.contains("cd ")); + } +} diff --git a/02_ruway/shuma/shuma-shell-llimphi/src/env.rs b/02_ruway/shuma/shuma-shell-llimphi/src/env.rs new file mode 100644 index 0000000..eabfd89 --- /dev/null +++ b/02_ruway/shuma/shuma-shell-llimphi/src/env.rs @@ -0,0 +1,110 @@ +//! Detección de engines disponibles, source por defecto y path de askpass. +//! +//! Este módulo no depende de ningún tipo del chasis — puede ser importado +//! por `types`, `containers` y `persist` sin ciclos. + +use shuma_module::Source; + +// ─── Detección de binarios ───────────────────────────────────────── + +pub(crate) fn binary_disponible(name: &str) -> bool { + let Some(path_env) = std::env::var_os("PATH") else { + return false; + }; + for dir in std::env::split_paths(&path_env) { + if dir.join(name).exists() { + return true; + } + } + false +} + +/// `true` si el binario `podman` está disponible en `PATH`. +pub(crate) fn podman_disponible() -> bool { + binary_disponible("podman") +} + +/// `true` si el binario `bwrap` (bubblewrap) está disponible en `PATH`. +pub(crate) fn bwrap_disponible() -> bool { + binary_disponible("bwrap") +} + +/// `true` si `unshare` + `chroot` están en `PATH`. +pub(crate) fn unshare_disponible() -> bool { + binary_disponible("unshare") && binary_disponible("chroot") +} + +/// Engine preferido para containers de esta máquina. +/// 1. `unshare` — sin instalar nada extra. +/// 2. `bwrap` — sin config, buen aislamiento. +/// 3. `podman` — fallback OCI completo. +pub(crate) fn engine_preferido() -> Option<&'static str> { + if unshare_disponible() { + Some("unshare") + } else if bwrap_disponible() { + Some("bwrap") + } else if podman_disponible() { + Some("podman") + } else { + None + } +} + +// ─── Source por defecto ───────────────────────────────────────────── + +/// `Source` por defecto de la tab shell según las env vars del proceso. +pub(crate) fn default_shell_source() -> Source { + let nonempty = |k: &str| std::env::var(k).ok().filter(|v| !v.is_empty()); + if let (Some(addr), Some(pub_hex)) = ( + nonempty("SHUMA_REMOTE_TCP_ADDR"), + nonempty("SHUMA_REMOTE_TCP_PUB"), + ) { + return Source::DaemonTcp { + addr, + server_pub_hex: pub_hex, + label: None, + }; + } + if let Some(path) = nonempty("SHUMA_REMOTE_SOCKET") { + return Source::Daemon { + socket: Some(std::path::PathBuf::from(path)), + label: None, + }; + } + if std::env::var("SHUMA_REMOTE").as_deref() == Ok("1") { + return Source::Daemon { + socket: None, + label: None, + }; + } + Source::Local +} + +// ─── Askpass ──────────────────────────────────────────────────────── + +/// Resuelve el path del binario `shuma-askpass`. +pub(crate) fn resolve_askpass_path() -> Option { + if let Ok(p) = std::env::var("SHUMA_ASKPASS") { + let pb = std::path::PathBuf::from(p); + if pb.exists() { + return Some(pb); + } + } + if let Ok(exe) = std::env::current_exe() { + if let Some(dir) = exe.parent() { + let sibling = dir.join("shuma-askpass"); + if sibling.exists() { + return Some(sibling); + } + } + } + if let Some(path_env) = std::env::var_os("PATH") { + for dir in std::env::split_paths(&path_env) { + let cand = dir.join("shuma-askpass"); + if cand.exists() { + return Some(cand); + } + } + } + None +} diff --git a/02_ruway/shuma/shuma-shell-llimphi/src/hosts.rs b/02_ruway/shuma/shuma-shell-llimphi/src/hosts.rs new file mode 100644 index 0000000..03d4911 --- /dev/null +++ b/02_ruway/shuma/shuma-shell-llimphi/src/hosts.rs @@ -0,0 +1,100 @@ +//! Persistencia de **hosts remotos** para sesiones SSH/daemon. +//! +//! Vive en `$XDG_CONFIG_HOME/shuma/hosts.json`. El usuario gestiona la +//! lista desde una ventana secundaria; cuando crea una sesión nueva con +//! aislamiento Remote, el form del panel ofrece un select con los hosts +//! guardados (o "Crear nuevo…" que abre el gestor). +//! +//! La auth no guarda passwords en plano — usa `Password` (askpass al +//! conectar) o `Key { path, ... }` con la PEM en un archivo del usuario. +//! La passphrase de la PEM (si tiene) también se lee con askpass. + +use serde::{Deserialize, Serialize}; + +/// Un host remoto guardado. +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +pub struct RemoteHost { + /// Nombre amigable para identificar el host (libre). + pub name: String, + pub host: String, + pub user: String, + #[serde(default = "default_port")] + pub port: u16, + #[serde(default)] + pub auth: HostAuth, + /// Transporte: `true` = canal SSH con **PTY** (los interactivos del otro + /// lado —vim, htop, claude— andan); `false` = un `ssh exec` por comando, + /// más barato pero mudo para todo lo de pantalla completa. Ninguno de los + /// dos instala nada en el host; la persistencia tipo tmux es el otro + /// transporte (daemon), que sí pide despliegue. + #[serde(default)] + pub pty: bool, +} + +fn default_port() -> u16 { + 22 +} + +/// Método de autenticación. +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +#[serde(tag = "kind", rename_all = "snake_case")] +pub enum HostAuth { + /// Lee la pass al conectar via `shuma-askpass` (SSH_ASKPASS). + Password, + /// Clave privada en `path` (archivo PEM). Si requiere passphrase, + /// la lee via askpass al conectar. + Key { path: String }, +} + +impl Default for HostAuth { + fn default() -> Self { + HostAuth::Password + } +} + +impl HostAuth { + pub fn label(&self) -> &'static str { + match self { + HostAuth::Password => "Contraseña", + HostAuth::Key { .. } => "Clave (PEM)", + } + } +} + +impl RemoteHost { + /// Etiqueta corta para mostrar en listas/dropdowns. + pub fn display(&self) -> String { + let p = if self.port == 22 { + String::new() + } else { + format!(":{}", self.port) + }; + format!("{} · {}@{}{}", self.name, self.user, self.host, p) + } +} + +/// Path canónico del archivo. +pub fn hosts_path() -> Option { + directories::BaseDirs::new().map(|b| b.config_dir().join("shuma").join("hosts.json")) +} + +/// Lee la lista persistida. Vacío si no hay archivo o no parsea. +pub fn load_hosts() -> Vec { + hosts_path() + .and_then(|p| std::fs::read_to_string(p).ok()) + .and_then(|s| serde_json::from_str::>(&s).ok()) + .unwrap_or_default() +} + +/// Persiste la lista a disco (silencioso ante errores de IO). +pub fn save_hosts(hosts: &[RemoteHost]) { + let Some(path) = hosts_path() else { + return; + }; + if let Ok(json) = serde_json::to_string_pretty(hosts) { + if let Some(dir) = path.parent() { + let _ = std::fs::create_dir_all(dir); + } + let _ = std::fs::write(&path, json); + } +} diff --git a/02_ruway/shuma/shuma-shell-llimphi/src/lib.rs b/02_ruway/shuma/shuma-shell-llimphi/src/lib.rs new file mode 100644 index 0000000..0dffb23 --- /dev/null +++ b/02_ruway/shuma/shuma-shell-llimphi/src/lib.rs @@ -0,0 +1,1871 @@ +//! `shuma-shell-llimphi` — chasis del shell shuma sobre Llimphi. +//! +//! Shuma es la app standalone "normal" del workspace: una ventana con +//! tabs siempre visibles, monitores a la derecha, command-bar abajo. La +//! metáfora Quake-drawer (overlay sobre el escritorio + F12 para +//! invocar) vive en `mirada-launcher-llimphi`, no aquí. +//! +//! **Layout** (sin `[main]` en shumarc): +//! +//! ```text +//! ┌──────────────────────────────────────────────────┐ +//! │ TopBar · launcher (apps + shortcuts) │ +//! ├────────────────────────────────┬─────────────────┤ +//! │ tabs: [shell] [lienzo] [matilda]│ │ +//! ├────────────────────────────────┤ Monitores │ +//! │ │ CPU + MEM + │ +//! │ contenido del tab activo │ los del módulo │ +//! │ │ │ +//! ├────────────────────────────────┴─────────────────┤ +//! │ BottomBar · command-bar › escribe… │ +//! └──────────────────────────────────────────────────┘ +//! ``` +//! +//! Si el shumarc declara `[main]`, ese módulo ocupa toda el área central +//! a pantalla completa (sin tabs ni monitores) — útil para correr shuma +//! como wrapper de matilda standalone, por ejemplo. + +#![forbid(unsafe_code)] + +mod app_update; +mod app_update_more; +mod app_view; +pub mod config; +pub mod containers; +pub mod env; +pub mod hosts; +pub mod menu; +pub mod perfiles; +pub mod persist; +pub(crate) mod semantic; +pub mod types; +pub mod update; +pub mod view; +pub mod workspace; + +// Superficie pública para hosts (pata) y el bin: el `Model`, el `Msg`, el `App` +// (`Shell`) y `run()` viven en este crate-lib — la lógica de dominio ya no está +// amarrada al binario (Regla 2: frontend sobre core agnóstico). pata podrá +// embeber `Model` y rutearle `Msg`. +pub use types::{Model, Msg}; + +use std::time::Duration; + +use llimphi_motion::{animate, motion, Tween}; +use llimphi_theme::Theme; +use llimphi_ui::llimphi_layout::taffy::{ + prelude::{length, percent, FlexDirection, Size, Style}, + Position, Rect, +}; +use llimphi_ui::{ + App, Handle, ImageFit, KeyEvent, KeyState, Modifiers, View, WheelDelta, +}; +use llimphi_widget_text_input::TextInputState; +use shuma_module::{ModuleContributions, MonitorSpec, ShortcutAction, Source}; +use shuma_sysmon::SystemSampler; +use std::collections::HashMap; + +// Tipos y sub-módulos re-exportados al espacio raíz para que update/view +// los puedan usar con `use super::*` sin paths explícitos. +use containers::*; +use env::*; +use persist::*; +use types::*; +use update::*; + +pub(crate) const HISTORY: usize = 60; +const TICK: Duration = Duration::from_secs(1); +/// Cadencia rápida para drenar el output del shell (streaming de +/// `shuma-exec`). 100 ms hace la salida sentirse en vivo sin comerse CPU notable. +const SHELL_TICK: Duration = Duration::from_millis(100); +pub(crate) const MONITORS_INITIAL_WIDTH: f32 = 280.0; + +/// Construye el cliente del rail hospedado: por default delega a pata cuando +/// está corriendo (opt-out con `SHUMA_DELEGATE_SIDEBAR=0`). +fn shuma_host(handle: &Handle) -> Option { + if !pata_host::delegate_sidebar_default("SHUMA_DELEGATE_SIDEBAR") { + return None; + } + let teeth = host_tool_teeth(); + let h = handle.clone(); + pata_host::HostClient::connect("shuma.shell", "shuma", teeth, move |id| { + h.dispatch(Msg::HostActivate(id)) + }) +} + +/// Sincroniza con el rail hospedado de pata **cuál diente está activo**: el +/// índice de `active_tool` en `Tool::ALL` (o `None` si no hay herramienta +/// abierta). Sólo manda `SetActive` cuando el valor cambió respecto del último +/// reportado (`host_active_synced`), para no escribir el socket en cada tick. +/// No-op si shuma no delega (sin `_host`). Se llama una vez al final de `update`, +/// así cubre todos los caminos que tocan `active_tool` sin repetir la llamada. +pub(crate) fn sync_host_active(m: &mut Model) { + let active = m + .active_tool + .and_then(|t| Tool::ALL.iter().position(|x| *x == t)) + .map(|i| i as u32); + if active == m.host_active_synced { + return; + } + m.host_active_synced = active; + if let Some(h) = m._host.as_mut() { + h.set_active(active); + } +} + +/// Dientes que shuma presta al rail de pata: uno por herramienta. +fn host_tool_teeth() -> Vec { + Tool::ALL + .iter() + .enumerate() + .map(|(i, t)| pata_host::HostedTooth::new(i as u32, tool_icon_name(*t), t.label().to_string())) + .collect() +} + +/// Nombre de icono para una herramienta. +fn tool_icon_name(t: Tool) -> &'static str { + match t { + Tool::History => "tools", + Tool::Monitor => "system", + Tool::Explorer => "files", + Tool::Matilda => "settings", + Tool::Agente => "chat", + } +} + +/// Arranca shuma standalone (la app de ventana). El bin sólo llama aquí; toda la +/// lógica vive en este crate-lib para que también la pueda hospedar pata. +pub fn run() { + rimay_localize::init(); + wire_askpass(); + llimphi_ui::run::(); +} + +/// Abre `path` con el visor de la suite elegido **por su contenido**: discierne +/// el tipo (shuma-discern, sobre el header del archivo) y despacha el open-with +/// universal de app-bus (mismo camino que nahual). Devuelve la etiqueta de la +/// app lanzada, o un error legible (no existe, no es archivo, sin visor). +fn open_with_viewer(path: &std::path::Path) -> Result { + if !path.exists() { + return Err("no existe".to_string()); + } + if !path.is_file() { + return Err("no es un archivo".to_string()); + } + // Header para discernir (primeros 8 KiB alcanzan para magic-bytes/probes). + let sample = { + use std::io::Read; + let mut f = std::fs::File::open(path).map_err(|e| e.to_string())?; + let mut buf = vec![0u8; 8192]; + let n = f.read(&mut buf).map_err(|e| e.to_string())?; + buf.truncate(n); + buf + }; + let path_str = path.to_str().ok_or_else(|| "ruta no-UTF8".to_string())?; + let hint = shuma_discern::Hint { + path: Some(path_str), + size_total: std::fs::metadata(path).ok().map(|m| m.len()), + }; + let mime = shuma_discern::DiscernPipeline::default() + .discern(&sample, &hint) + .and_then(|d| d.mime) + .ok_or_else(|| "no pude discernir el tipo".to_string())?; + let registry = app_bus::AppRegistry::with_defaults(); + match registry.open_with(&mime, path_str).map_err(|e| e.to_string())? { + Some((entry, _child)) => Ok(entry.label.clone()), + None => Err(format!("sin visor para {mime}")), + } +} + +/// Cablea el askpass para sudo + ssh (compartido por ventana y dock). +fn wire_askpass() { + if let Some(path) = resolve_askpass_path() { + if std::env::var_os("SUDO_ASKPASS").is_none() { + std::env::set_var("SUDO_ASKPASS", &path); + } + if std::env::var_os("SSH_ASKPASS").is_none() { + std::env::set_var("SSH_ASKPASS", &path); + } + if std::env::var_os("SSH_ASKPASS_REQUIRE").is_none() { + std::env::set_var("SSH_ASKPASS_REQUIRE", "force"); + } + } +} + +/// Absorbe **una vez por proceso** el entorno de una terminal normal (login +/// shell) al proceso de shuma: hace aparecer el `PATH` de tu `.zshrc`/`.bashrc` +/// (donde suele vivir `claude`, npm/nvm, `~/.local/bin`), proxies, `EDITOR`, +/// etc. — que shuma no hereda al lanzarse desde pata/mirada/un launcher en vez +/// de una shell de login. Idempotente y aditivo. Corre en el hilo principal, +/// antes de construir el modelo y spawnear hijos (requisito de `set_var`). El +/// `:env sync` del shell lo re-dispara a mano. +fn wire_login_env() { + use std::sync::Once; + static ONCE: Once = Once::new(); + ONCE.call_once(|| { + let report = shuma_config::login_env::sync_into_process(); + if let Some(e) = &report.failed { + eprintln!("shuma · entorno de terminal no absorbido: {e}"); + } else if !report.applied.is_empty() { + let shell = report.shell.as_deref().unwrap_or("shell"); + eprintln!( + "shuma · {} vars de {shell} absorbidas al entorno{}", + report.applied.len(), + if report.path_changed { " (PATH incluido)" } else { "" }, + ); + } + }); +} + +/// Arranca shuma **dockeada**: una barra wlr-layer-shell anclada a un borde, en +/// vez de una ventana. Mismo `Shell` App (la lógica no cambia); el modo lo lee +/// `init` del env `SHUMA_DOCK` y `view` pinta compacto. Borde por `SHUMA_DOCK_EDGE` +/// (top/bottom/left/right, default bottom). Cae con aviso si el compositor no +/// expone wlr-layer-shell. +pub fn run_dock() { + rimay_localize::init(); + wire_askpass(); + std::env::set_var("SHUMA_DOCK", "1"); + let edge = match std::env::var("SHUMA_DOCK_EDGE").as_deref() { + Ok("top") => llimphi_layer::Edge::Top, + Ok("left") => llimphi_layer::Edge::Left, + Ok("right") => llimphi_layer::Edge::Right, + _ => llimphi_layer::Edge::Bottom, + }; + if let Err(e) = llimphi_layer::run::(llimphi_layer::LayerConfig { + edge, + thickness: 48, + layer: llimphi_layer::LayerKind::Top, + exclusive: true, + keyboard: llimphi_layer::Keyboard::OnDemand, + namespace: "shuma".to_string(), + ..Default::default() + }) { + eprintln!("shuma · modo dock no disponible: {e}"); + } +} + +/// Re-lanza el mismo binario en el modo opuesto (ventana ↔ barra dockeada), +/// heredando el cwd. Usado por el botón «Endockar / Modo ventana» del menú y por +/// el repliegue al perder foco. No migra la sesión viva (historial/PTY): la +/// nueva instancia arranca limpia — migrarla exigiría IPC entre procesos. +pub(crate) fn respawn_mode(to_dock: bool) { + if let Ok(exe) = std::env::current_exe() { + let mut c = std::process::Command::new(exe); + if to_dock { + c.arg("--dock"); + } + let _ = c.spawn(); + } +} + +/// Construye el `Model` de shuma **sin efectos del host** (sin ticks, watcher de +/// config, cliente de rail, ni disparo de contenedores). Pieza hosteable +/// (Regla 2): el bin standalone y pata construyen el mismo Model y cada host +/// engancha sus efectos vía [`spawn_host_effects`]. Los campos de efecto +/// (`_wawa_watcher`/`_host`) quedan en `None` hasta que el host los provea. +pub fn new_model() -> Model { + let wawa = wawa_config::WawaConfig::load(); + let theme = wawa_config_llimphi::theme_from_wawa(&wawa, &Theme::dark()); + let _ = rimay_localize::set_locale(&wawa.lang); + + // Perfiles. El de sesión (tipo Firefox) decide el directorio de datos: hay + // que fijarlo ANTES de leer sesiones/chrome/layouts, que ahora resuelven su + // ruta vía `perfiles::sessions::active_data_dir`. + let session_profiles = perfiles::sessions::SessionProfiles::load_or_init( + &perfiles::sessions::SessionProfiles::default_path().unwrap_or_default(), + ); + perfiles::sessions::set_active(session_profiles.active()); + let shortcuts = perfiles::shortcuts::ShortcutProfiles::load_or_init( + &perfiles::shortcuts::ShortcutProfiles::default_path().unwrap_or_default(), + ); + let appearance = perfiles::appearance::AppearanceProfiles::load_or_init( + &perfiles::appearance::AppearanceProfiles::default_path().unwrap_or_default(), + ); + + let cfg = config::ShumaConfig::load_default(); + let topbar = resolve_slot(cfg.topbar.as_ref()).or_else(|| { + Some(Instance::launcher( + shuma_module_launcher::State::from_apps_dir(), + )) + }); + let bottombar = resolve_slot(cfg.bottombar.as_ref()).or_else(|| { + Some(Instance::command_bar( + shuma_module_commandbar::State::default(), + )) + }); + let main = resolve_slot(cfg.main.as_ref()); + + let mut sessions = vec![Session::draft()]; + for c in load_sessions() { + let mut sess = Session::from_config(c); + // Sesión persistente: rehidratar el output guardado en el shell + // recién construido (los bloques viejos abren plegados). + if sess.persist { + if let Some(snap) = persist::load_session_output(&sess.name) { + if let ModuleState::Shell(st) = &mut sess.shell_mut().state { + st.restore_output(snap); + } + } + } + sessions.push(sess); + } + + // Handoff de desacople (botón Undock de pata): si `SHUMA_HANDOFF` apunta a + // un snapshot de salida, rehidratarlo en la sesión draft y abrir directo en + // ella. Es la otra mitad del "mover de verdad" — pata serializa la sesión + // embebida, nos la pasa, y la cierra de su lado, así no queda duplicada. + // El cwd ya llega por `SHUMA_CWD`/`cd` y el historial por la history + // persistente compartida; esto suma el scrollback visible. Consumimos el + // archivo para que no se re-aplique si reabres shuma. + let mut handoff_active: Option = None; + if let Ok(path) = std::env::var("SHUMA_HANDOFF") { + if let Some(snap) = persist::load_output_snapshot_file(&path) { + if let ModuleState::Shell(st) = &mut sessions[0].shell_mut().state { + st.restore_output(snap); + handoff_active = Some(0); + } + } + let _ = std::fs::remove_file(&path); + } + + // Grupos de environment: cargar env.json (garantizando el grupo «general», + // destino del builtin `:env`) y aplicar los activos al proceso — los shells + // hijos los heredan. + let mut env_groups = shuma_config::load_env_groups(); + if !env_groups.iter().any(|g| g.name == "general") { + env_groups.insert(0, shuma_config::EnvGroup::new("general")); + let _ = shuma_config::save_env_groups(&env_groups); + } + for g in &env_groups { + if g.active { + shuma_config::apply_env_group(g, true); + } + } + let env_groups_mtime = persist::env_groups_mtime(); + + let chrome = load_chrome(); + // Si vino un handoff de desacople, abrimos en esa sesión; si no, la última + // activa persistida. + let active_session = handoff_active + .unwrap_or_else(|| chrome.active_session.min(sessions.len().saturating_sub(1))); + + let mut model = Model { + theme, + dock_mode: false, + chromeless: false, + collapse_on_blur: false, + shortcuts, + appearance, + session_profiles, + pending_prefix: false, + perfiles_modal_open: false, + perfiles_tab: ProfKind::Shortcuts, + prof_name: TextInputState::new(), + prof_name_focused: false, + wallpaper_img: None, + wallpaper_path: None, + wp_path: TextInputState::new(), + wp_path_focused: false, + bg_pattern: None, + bg_procedural_img: None, + topbar, + bottombar, + main, + sessions, + active_session, + hovered_session: None, + active_tool: chrome.active_tool, + taskmanager_open: false, + task_rows: Vec::new(), + task_cargando: false, + task_scroll: 0.0, + session_panel_open: chrome.session_panel_open, + dropdown_open: None, + containers: Vec::new(), + remote_containers: Vec::new(), + remote_new_distro: Distro::Ubuntu, + containers_full: Vec::new(), + container_cfgs: load_container_cfgs(), + focused_field: None, + hosts: hosts::load_hosts(), + host_draft: None, + container_draft: None, + hosts_modal_open: false, + containers_modal_open: false, + layouts: load_layouts(), + layouts_modal_open: false, + explorer: ExplorerCache::default(), + file_search: None, + layout_name: TextInputState::new(), + layout_name_focused: false, + viewport: (1280.0, 800.0), + overlay_box: None, + session_w: chrome.session_w, + sysmon: SystemSampler::new(HISTORY), + last_snapshot: None, + monitors_width: chrome.monitors_width, + // Sidebars unificados: el ancho del panel arranca del chrome persistido + // (`session_w`/`monitors_width`); los ejes de disposición en su default + // (Fijo, rail adentro) reproducen la columna clásica del chasis. + sidebar_left: llimphi_widget_rag_sidebar::RagSidebarState { + panel_w: chrome.session_w, + ..Default::default() + }, + sidebar_right: llimphi_widget_rag_sidebar::RagSidebarState { + panel_w: chrome.monitors_width, + ..Default::default() + }, + extra_history: HashMap::new(), + extra_display: HashMap::new(), + _wawa_watcher: None, + menu_open: None, + menu_active: usize::MAX, + menu_anim: Tween::idle(1.0), + ctx_menu: None, + tab_ctx: None, + tab_rename: None, + pulso_fase: 0, + env_groups, + env_groups_mtime, + tick_count: 0, + hosted_bar: false, + _host: None, + host_active_synced: None, + agente: shuma_module_agente::State::new(), + agente_almacen: None, + _voz_rt: None, + _voz_guardia: None, + _voz_tts_rt: None, + _voz_locutor: None, + voz_target: None, + marquesina_eventos: Vec::new(), + marquesina_idx: 0, + marquesina_fase: 0, + clipboard: llimphi_clipboard::SystemClipboard::new(), + }; + // Aplicar la apariencia efectiva (global o de la sesión activa) sobre el + // tema base: si la activa es «Sistema» queda el tema de wawa ya calculado. + perfiles::apply_active_appearance(&mut model); + // Abrir el almacén del chat y sembrar los agentes por defecto; si falla + // (disco, permisos), el panel sigue funcionando sólo en memoria. + init_agente(&mut model); + model +} + +/// Re-adjunta la sesión persistente del daemon que quedó montada (si sigue +/// viva) en el shell de la sesión **activa** del chasis. El host lo llama UNA +/// vez tras construir el Model. Tiene que caer aquí — el drawer pinta el +/// chasis: un reattach en un `State` aparte queda como adjunto invisible que +/// encima re-dimensiona el PTY de la sesión (el attach del daemon resizea al +/// cliente entrante) sin que nadie lo vea. +pub fn auto_reattach_activa(m: &mut Model) { + let idx = m.active_session.min(m.sessions.len().saturating_sub(1)); + let Some(sess) = m.sessions.get_mut(idx) else { return }; + if let ModuleState::Shell(st) = &mut sess.shell_mut().state { + let viejo = std::mem::replace(st, shuma_module_shell::State::new(Source::Local)); + *st = shuma_module_shell::auto_reattach(viejo); + } +} + +/// Re-adjunta TODAS las sesiones persistentes que quedaron montadas y siguen +/// vivas en el daemon: **una por tab**, en la sesión activa del chasis. La +/// primera reusa el pane con foco (el tab default); el resto abre un tab nuevo +/// cada una. Reemplaza a [`auto_reattach_activa`], que sólo re-montaba la última +/// y dejaba las demás vivas pero huérfanas al renacer pata tras un suspend. +pub fn auto_reattach_todas(m: &mut Model) { + let vivas = shuma_module_shell::montadas_vivas(); + if vivas.is_empty() { + return; + } + let idx = m.active_session.min(m.sessions.len().saturating_sub(1)); + let Some(sess) = m.sessions.get_mut(idx) else { return }; + let source = sess.source.clone(); + for (i, info) in vivas.iter().enumerate() { + if i == 0 { + // La primera reusa el pane con foco del tab default. + let slot = &mut sess.shell_mut().state; + if matches!(slot, ModuleState::Shell(_)) { + let fresco = shuma_module_shell::State::new(source.clone()); + *slot = ModuleState::Shell(shuma_module_shell::reattach_en(fresco, info)); + } + } else { + // El resto, un tab nuevo cada una. + let mut inst = Instance::shell(info.label.clone(), source.clone()); + let fresco = shuma_module_shell::State::new(source.clone()); + inst.state = ModuleState::Shell(shuma_module_shell::reattach_en(fresco, info)); + sess.workspace.new_tab(inst); + } + } + // `new_tab` deja el foco en el último tab; devolverlo al primero (la sesión + // que estaba activa) para no desorientar al usuario. + sess.workspace.switch_tab(0); +} + +/// Filas de miniatura que guarda cada `TaskRow`: lo último que escribió la +/// sesión. Ocho entran cómodas en una tarjeta y alcanzan para reconocer qué +/// estabas haciendo (un prompt de claude, un `cargo build`, un vim). +const PREVIEW_LINEAS: usize = 8; + +/// Qué sesiones del daemon tiene ESTA ventana montadas en pestañas, indexadas +/// por ULID: `(índice de sesión, índice de tab, nombre de la sesión)`. Es lo que +/// deja al gestor distinguir «la tenés abierta ahí» de «la cerraste y sigue +/// corriendo» — la pregunta que el gestor existe para contestar. +pub(crate) fn sesiones_en_pestanas(m: &Model) -> HashMap { + let mut out = HashMap::new(); + for (si, sess) in m.sessions.iter().enumerate() { + for (ti, ulid) in sess.workspace.sesiones_montadas() { + out.insert(ulid, (si, ti, sess.name.clone())); + } + } + out +} + +/// Lee el daemon y arma las filas del gestor. **Hace IO bloqueante** (una +/// consulta de lista + un snapshot por sesión): va en un worker, nunca en el +/// hilo de UI — ver [`refrescar_task_rows`]. +pub(crate) fn leer_task_rows( + montadas: HashMap, +) -> Vec { + shuma_module_shell::listar_sesiones() + .iter() + .map(|i| { + // La pantalla se re-renderiza al tamaño con que nació la sesión: a + // otro ancho, la salida ya envuelta por el programa se ve partida. + let (titulo_osc, pantalla) = shuma_module_shell::mirar_sesion( + i.session, + i.rows.clamp(1, 200), + i.cols.clamp(20, 400), + ) + .unwrap_or((None, Vec::new())); + let preview = pantalla + .iter() + .rev() + .take(PREVIEW_LINEAS) + .rev() + .map(|l| l.chars().take(200).collect::()) + .collect(); + let cmd = if i.args.is_empty() { + i.program.clone() + } else { + format!("{} {}", i.program, i.args.join(" ")) + }; + crate::types::TaskRow { + id: i.session.to_string(), + label: i.label.clone(), + program: i.program.clone(), + cmd, + cwd: i.cwd.clone(), + titulo_osc, + preview, + alive: i.alive, + exit_code: i.exit_code, + attached: i.attached, + created_ms: i.created_unix_ms, + en_tab: montadas.get(&i.session.to_string()).cloned(), + } + }) + .collect() +} + +/// Dispara un refresco del gestor **fuera del hilo de UI** y deja la marca de +/// «cargando». Las filas vuelven por [`Msg::TaskRowsReady`]. +/// +/// Antes esto era una llamada síncrona dentro de `update`: con el daemon lento +/// (o parseando el anillo de ocho sesiones) congelaba la ventana entera. +pub(crate) fn refrescar_task_rows(m: &mut Model, handle: &Handle) { + m.task_cargando = true; + let montadas = sesiones_en_pestanas(m); + handle.spawn(move || Msg::TaskRowsReady(leer_task_rows(montadas))); +} + +/// Restaura la sesión `id` (ULID) del fondo del daemon a una **tab nueva** de la +/// sesión activa (con foco en ella, para verla). No-op si ya no existe. +pub(crate) fn restaurar_sesion(m: &mut Model, id: &str) { + let vivas = shuma_module_shell::listar_sesiones(); + let Some(info) = vivas.iter().find(|i| i.session.to_string() == id) else { + return; + }; + let idx = m.active_session.min(m.sessions.len().saturating_sub(1)); + let Some(sess) = m.sessions.get_mut(idx) else { return }; + let source = sess.source.clone(); + let mut inst = Instance::shell(info.label.clone(), source.clone()); + let fresco = shuma_module_shell::State::new(source.clone()); + inst.state = ModuleState::Shell(shuma_module_shell::reattach_en(fresco, info)); + sess.workspace.new_tab(inst); // deja el foco en la tab nueva (queremos verla) +} + +/// Abre el [`shuma_agente::Almacen`], siembra los agentes por defecto y alimenta +/// el panel de chat con agentes + conversaciones persistidas. +fn init_agente(model: &mut Model) { + // El panel rotula «re-enrolar» si ya hay un wake-word de «shuma» en disco. + model.agente.set_wake_listo(cargar_detector_wake().is_some()); + let Some(path) = persist::agente_db_path() else { + return; + }; + match shuma_agente::Almacen::abrir(&path) { + Ok(almacen) => { + let agentes = almacen.sembrar_defaults().unwrap_or_default(); + let convs = almacen.conversaciones().unwrap_or_default(); + model.agente.set_agentes(agentes); + model.agente.set_conversaciones(convs); + // Reanudá en la conversación más reciente (como las apps web de IA). + model.agente.abrir_mas_reciente(); + model.agente_almacen = Some(almacen); + } + Err(e) => eprintln!("shuma: no se pudo abrir el almacén del chat: {e}"), + } +} + +/// Epoch en milisegundos (el chasis sí puede leer el reloj; el módulo no). +fn ahora_ms() -> u64 { + std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .map(|d| d.as_millis() as u64) + .unwrap_or(0) +} + +/// Marca el Model como **hospedado en una barra externa** (pata): el input de la +/// sesión activa lo pinta el host con [`active_input_view`], así que el canvas +/// omite su input para no duplicarlo. Llamar tras [`new_model`] en el host. +pub fn set_hosted_in_bar(model: &mut Model, on: bool) { + model.hosted_bar = on; +} + +/// Engancha los efectos que dependen del host (event loop): ticks periódicos, +/// watcher de `WawaConfig`, cliente del rail de pata, y dispara la verificación +/// de contenedores de las sesiones. El bin standalone lo llama en `App::init`; +/// un host como pata lo llama con un `Handle` lifteado ([`Handle::lift`]) para +/// que los ticks/efectos de shuma vuelvan a su loop como `pata::Msg`. +pub fn spawn_host_effects(model: &mut Model, handle: &Handle) { + handle.spawn_periodic(TICK, || Msg::Tick); + handle.spawn_periodic(SHELL_TICK, || Msg::ShellTick); + model._wawa_watcher = { + let handle = handle.clone(); + wawa_config::ConfigWatcher::spawn(move |cfg| { + handle.dispatch(Msg::WawaConfigChanged(Box::new(cfg))); + }) + .ok() + }; + for s in &model.sessions { + if s.use_container { + if let Some(name) = s.container.clone() { + handle.dispatch(Msg::EnsureContainer(name)); + } + } + } + model._host = shuma_host(handle); + spawn_willay_feed(handle); +} + +/// Hilo que alimenta la **marquesina** desde el centro de eventos `willay`: se +/// suscribe al daemon y, en cada cambio del índice, pide los eventos recientes y +/// los empuja como [`Msg::WillayEventos`]. Espeja el hilo de red de +/// `willay-panel-llimphi` (socket bloqueante, reconecta solo). Si el daemon no +/// está arriba, reintenta cada 2 s; sin daemon, la marquesina simplemente queda +/// vacía (placeholder default) — degradación limpia. +fn spawn_willay_feed(handle: &Handle) { + use willay_core::proto::{Respuesta, Solicitud}; + /// Cuántos eventos recientes traer del índice para narrar. + const LIMITE: u32 = 24; + let handle = handle.clone(); + let pedir_y_empujar = |h: &Handle| { + if let Ok(mut em) = willay_emit::Emisor::conectar() { + if let Ok(Respuesta::Eventos(v)) = em.pedir(&Solicitud::Recientes(LIMITE)) { + h.dispatch(Msg::WillayEventos(v)); + } + } + }; + let _ = std::thread::Builder::new() + .name("shuma-willay-marquesina".into()) + .spawn(move || loop { + // Carga inicial (y tras cada reconexión). + pedir_y_empujar(&handle); + // Suscripción: bloquea empujando en cada cambio; al caer, reintenta. + if let Ok(em) = willay_emit::Emisor::conectar() { + let h = handle.clone(); + let _ = em.escuchar_cambios(move || pedir_y_empujar(&h)); + } + std::thread::sleep(std::time::Duration::from_secs(2)); + }); +} + +/// Clasifica la urgencia de un evento del centro willay — el **manejador +/// sintáctico de eventos**: decide por palabras clave (no por embeddings). Es la +/// costura donde después entra el triage semántico de `pata-notify-triage` +/// (clustering + reglas por significado). Sólo se narran **notificaciones**; el +/// resto del índice (capturas, clips, checkpoints) no roba la barra. +fn clasificar_urgencia(ev: &willay_core::Evento) -> shuma_module_commandbar::Urgencia { + use shuma_module_commandbar::Urgencia; + use willay_core::Clase; + match ev.clase { + // Notificaciones y comunicaciones: por palabras clave. + Clase::Notificacion => { + let hay = format!("{} {}", ev.titulo, ev.cuerpo).to_lowercase(); + const URGENTE: &[&str] = &[ + "error", "fall", "failed", "crítico", "critico", "urgente", + "caído", "caido", "denied", "rechaz", "venci", "expir", + ]; + const RUIDO: &[&str] = &[ + "sincroniz", "actualiz", "instalad", "completad", "listo", "sync", "descargad", + ]; + if URGENTE.iter().any(|k| hay.contains(k)) { + Urgencia::Urgente + } else if RUIDO.iter().any(|k| hay.contains(k)) { + Urgencia::Silencio + } else { + Urgencia::Leve + } + } + // Capturas y clips se adaptan bien a una frase ("captura · DP-1", + // "copiaste · …"): avisos leves. + Clase::Captura | Clase::Clip => Urgencia::Leve, + // Los checkpoints son el flujo forense/autosave: ruido para la barra. + Clase::Checkpoint => Urgencia::Silencio, + } +} + +/// Arma el aviso a narrar ahora: filtra los eventos narrables (no silenciados) y +/// elige el `idx`-ésimo (rota en cada tick). `None` = nada que narrar. +fn marquesina_actual( + eventos: &[willay_core::Evento], + idx: usize, +) -> Option { + use shuma_module_commandbar::{Marquesina, Urgencia}; + let narrables: Vec<&willay_core::Evento> = eventos + .iter() + .filter(|e| clasificar_urgencia(e) != Urgencia::Silencio) + .collect(); + if narrables.is_empty() { + return None; + } + let e = narrables[idx % narrables.len()]; + let texto = if e.origen.is_empty() { + e.titulo.clone() + } else { + format!("{} · {}", e.origen, e.titulo) + }; + Some(Marquesina { urgencia: clasificar_urgencia(e), ..Marquesina::leve(texto) }) +} + +/// Empuja el aviso actual + la fase de parpadeo al estado de la command-bar (el +/// input en reposo la pinta como placeholder). No-op si la bottombar no es una +/// command-bar. +pub(crate) fn actualizar_marquesina(m: &mut Model) { + let marq = marquesina_actual(&m.marquesina_eventos, m.marquesina_idx); + let fase = m.marquesina_fase; + // Command-bar (la barra de shuma standalone). + if let Some(inst) = m.bottombar.as_mut() { + if let ModuleState::CommandBar(st) = &mut inst.state { + st.set_marquesina(marq.clone()); + st.set_fase(fase); + } + } + // Input del shell de la sesión activa: la marquesina es su placeholder. Sólo + // en standalone — cuando pata hospeda (`hosted_bar`), pata es el único + // escritor (con su triage + sys_alert) vía [`set_active_marquesina`], para no + // pisarse un tick sí y otro no. + if !m.hosted_bar { + set_active_marquesina(m, marq, fase); + } +} + +/// Fija la marquesina en el **input de la sesión activa** (su placeholder cuando +/// está vacío). Es la API que usa el host (pata) para alimentar la barra real en +/// live-wire, y que la usa el chasis en standalone. No-op si la activa no es un +/// shell. +pub fn set_active_marquesina(m: &mut Model, marq: Option, fase: u8) { + let idx = m.active_session; + if let Some(sess) = m.sessions.get_mut(idx) { + if let ModuleState::Shell(st) = &mut sess.shell_mut().state { + st.set_marquesina(marq, fase); + } + } +} + +/// Resultado del último comando **terminado** de la sesión activa: `Some(true)` +/// ok, `Some(false)` falló, `None` si ninguno / la activa no es un shell. Lo lee +/// el host (pata) para la **chakana** (PS1) en live-wire. +pub fn active_ultimo_resultado(m: &Model) -> Option { + let sess = m.sessions.get(m.active_session)?; + match &sess.shell().state { + ModuleState::Shell(st) => st.ultimo_resultado(), + _ => None, + } +} + +/// Directorio de trabajo de la sesión activa. Lo usa el host (pata) para el +/// **label flotante pwd/git** de la barra de mando. `None` si la activa no es +/// un shell. +pub fn active_cwd(m: &Model) -> Option { + let sess = m.sessions.get(m.active_session)?; + match &sess.shell().state { + ModuleState::Shell(st) => Some(st.cwd.clone()), + _ => None, + } +} + +// ─── Overview de sesiones para hospedar los dientes afuera (pata) ─────── + +pub use shuma_module_shell::Activity; + +/// Tarjeta **pública** de una sesión: lo mínimo que el host (pata) necesita para +/// pintar el diente de esa sesión en su propio rail (el `` como workspace de +/// terminal, donde los tabs del terminal son los dientes del sidebar). El tipo +/// interno [`Session`] es `pub(crate)`; esto lo proyecta sin exponerlo. +#[derive(Clone, Debug)] +pub struct SessionCard { + /// Índice de la sesión en el modelo — el `id` del diente y el argumento de + /// [`Msg::SelectSession`]. + pub index: usize, + /// Etiqueta legible de la sesión (su nombre). + pub label: String, + /// Número corto del diente (el que la shuma pinta debajo del icono), si tiene. + pub number: Option, + /// `true` si es la sesión activa (la que el usuario está mirando). + pub active: bool, + /// Estado de actividad del shell con foco — alimenta el color del LED. + pub activity: Activity, + /// Comandos largos terminados pendientes de acuse (badge de aviso). + pub long_alerts: usize, + /// Resultado del último comando terminado: `Some(false)` = falló (badge de + /// error), `Some(true)` = ok, `None` = ninguno todavía. + pub ultimo_ok: Option, +} + +/// Los títulos de las pestañas de la sesión activa, como los pinta la barra — +/// título OSC del programa → programa corriendo → cwd. Público para que el +/// `pestanas_shot` (y un host que quiera espejar las pestañas) los lea sin +/// destapar el `Workspace`. +pub fn titulos_de_pestanas(m: &Model) -> Vec { + m.active() + .map(|s| { + s.workspace + .tabs + .iter() + .enumerate() + .map(|(i, t)| t.titulo(i)) + .collect() + }) + .unwrap_or_default() +} + +/// **Sólo para certificación**: inyecta caudal en todos los paneles de todas las +/// pestañas de la sesión activa, como si acabaran de escupir salida, y cierra el +/// bin. Sin esto no hay forma de ver el cava en headless (haría falta un PTY +/// real produciendo bytes). +#[doc(hidden)] +pub fn sembrar_caudal_de_prueba(m: &mut Model) { + for s in m.sessions.iter_mut() { + for tab in s.workspace.tabs.iter_mut() { + for inst in tab.panes.values_mut() { + if let ModuleState::Shell(st) = &mut inst.state { + // Una ráfaga con forma: bins crecientes, como una compilación + // arrancando. Cada `muestrear` cierra un bin del anillo. + for k in 1..=8u64 { + st.pulso.sumar(k * k * 220); + st.pulso.muestrear(); + } + } + } + } + } +} + +/// **Sólo para certificación**: le pone nombre manual a la primera pestaña, para +/// medir cómo negocia su ancho un título largo. +#[doc(hidden)] +pub fn renombrar_pestana_de_prueba(m: &mut Model, nombre: &str) { + if let Some(s) = m.sessions.get_mut(m.active_session) { + if let Some(t) = s.workspace.tabs.first_mut() { + t.name = Some(nombre.to_string()); + } + } +} + +/// **Sólo para certificación**: levanta un aviso pendiente en las pestañas que +/// no son la activa, para poder medir el parpadeo. +#[doc(hidden)] +pub fn sembrar_aviso_de_prueba(m: &mut Model) { + for s in m.sessions.iter_mut() { + let activa = s.workspace.active_tab; + for (i, tab) in s.workspace.tabs.iter_mut().enumerate() { + if i != activa { + tab.avisador.aviso = Some(crate::workspace::TabAviso::Espera); + } + } + } +} + +/// Proyecta las sesiones vivas a [`SessionCard`]s para que el host (pata) pinte +/// un diente por sesión en su sidebar y remarque el que recibe una notificación +/// (comando largo terminó / falló / actividad de fondo). El orden espeja +/// `model.sessions`; `index` es el argumento directo de [`Msg::SelectSession`]. +pub fn sessions_overview(m: &Model) -> Vec { + m.sessions + .iter() + .enumerate() + .map(|(index, s)| { + let ultimo_ok = match &s.shell().state { + ModuleState::Shell(st) => st.ultimo_resultado(), + _ => None, + }; + SessionCard { + index, + label: s.name.clone(), + number: s.number, + active: index == m.active_session, + activity: s.activity(), + long_alerts: s.long_alerts(), + ultimo_ok, + } + }) + .collect() +} + +/// Traduce un evento de `rimay-voz-host` a mensajes del panel de chat: cambia el +/// indicador de escucha y, en el dictado, inserta el texto en el input. +fn mapear_evento_voz(ev: rimay_voz_host::EventoEscucha) -> Vec { + use rimay_voz_host::EventoEscucha as E; + use shuma_module_agente::{EstadoEscucha as Es, Msg as AM}; + match ev { + E::Escuchando => vec![AM::EscuchaCambio(Es::Oyendo)], + E::Desperto => vec![AM::EscuchaCambio(Es::Despierto)], + E::Dictar(t) => vec![AM::Dictado(t), AM::EscuchaCambio(Es::Dictando)], + E::SeDurmio => vec![AM::EscuchaCambio(Es::Esperando)], + } +} + +/// Traduce un `EventoEscucha` a `(estado del indicador, texto dictado?)` — la +/// forma genérica que consumen el shell y la command-bar (el panel de chat usa +/// su propio [`mapear_evento_voz`] con sus `Msg`). +fn evento_a_estado( + ev: &rimay_voz_host::EventoEscucha, +) -> (shuma_module_agente::EstadoEscucha, Option) { + use rimay_voz_host::EventoEscucha as E; + use shuma_module_agente::EstadoEscucha as Es; + match ev { + E::Escuchando => (Es::Oyendo, None), + E::Desperto => (Es::Despierto, None), + E::Dictar(t) => (Es::Dictando, Some(t.clone())), + E::SeDurmio => (Es::Esperando, None), + } +} + +/// Apaga el indicador de escucha en **todas** las superficies (chat, shells, +/// command-bar). Como hay un solo micrófono, al arrancar/parar la captura los +/// halos de las barras que no son el target deben quedar en reposo. +fn resetear_indicadores_voz(m: &mut Model) { + let off = shuma_module_agente::EstadoEscucha::Apagado; + m.agente.fijar_escucha(off); + for sess in &mut m.sessions { + if let ModuleState::Shell(st) = &mut sess.shell_mut().state { + st.fijar_escucha(off); + } + } + if let Some(inst) = m.bottombar.as_mut() { + if let ModuleState::CommandBar(st) = &mut inst.state { + st.fijar_escucha(off); + } + } +} + +/// Arranca la captura de voz con la config del SO (`wawa-config::ai.voz`, editada +/// en wawa-panel): backend STT/TTS del híbrido + palabra de llamada. Sostiene un +/// runtime tokio dedicado (el bucle Elm no lo es) y reenvía los eventos a la +/// superficie `target` (chat, shell o command-bar) vía [`Msg::VozEvento`]. +fn iniciar_voz(m: &mut Model, handle: &Handle, target: VozTarget) { + // Un solo micrófono: apaga cualquier indicador previo antes de re-apuntar. + resetear_indicadores_voz(m); + let voz = wawa_config::WawaConfig::load().ai.voz; + let vcfg = rimay_voz::VozConfig { + stt: rimay_voz::Backend::parse(&voz.stt), + tts: rimay_voz::Backend::parse(&voz.tts), + socket: None, + }; + // Compuerta wake-word (F1): si está activada en wawa-panel y hay un detector + // enrolado en disco, se monta — así, dormido, sólo se transcribe lo que suena + // a «shuma». Sin enrolar, cae a F0 (transcribe-todo). + let detector: Option> = if voz.wake { + cargar_detector_wake().map(|d| std::sync::Arc::new(d) as _) + } else { + None + }; + let opciones = rimay_voz_host::OpcionesEscucha { + llamado: voz.effective_llamado().to_string(), + detector, + }; + // Runtime propio: la captura tiene tasks tokio + intervalos; el bucle Elm no + // es tokio. `enter()` da contexto para el `tokio::spawn` interno del host. + let rt = match tokio::runtime::Runtime::new() { + Ok(rt) => rt, + Err(e) => { + m.agente.fijar_escucha(shuma_module_agente::EstadoEscucha::Apagado); + eprintln!("voz: no se pudo crear el runtime: {e}"); + return; + } + }; + let arranque = { + let _g = rt.enter(); + rimay_voz_host::escuchar_cfg(vcfg, opciones) + }; + match arranque { + Ok((guardia, mut rx)) => { + m.voz_target = Some(target); + let h = handle.clone(); + rt.spawn(async move { + // Un solo canal genérico: el handler enruta cada evento a la + // superficie activa (`voz_target`). Así el mismo lazo sirve al + // chat, al shell y a la command-bar sin duplicar la captura. + while let Some(ev) = rx.recv().await { + h.dispatch(Msg::VozEvento(ev)); + } + }); + m._voz_guardia = Some(guardia); + m._voz_rt = Some(rt); + eprintln!("voz: 🎙 escuchando — di «shuma»"); + } + Err(e) => { + // El runtime se dropea al salir del scope (no quedó nada corriendo). + resetear_indicadores_voz(m); + eprintln!("voz: no se pudo abrir el micrófono: {e}"); + } + } +} + +/// Para la captura de voz: corta la guardia (aborta tasks + hilo de audio) y +/// luego el runtime, en ese orden, y apaga los indicadores de todas las barras. +fn parar_voz(m: &mut Model) { + m._voz_guardia = None; // Drop: para mic + tasks + m._voz_rt = None; // Drop: cierra el runtime + m.voz_target = None; + resetear_indicadores_voz(m); +} + +/// Enruta un `EventoEscucha` de la captura activa a la superficie apuntada por +/// `voz_target`: fija su indicador de escucha y, en el dictado, inserta el texto +/// (en el chat vía sus `Msg`; en el shell y la command-bar como texto tipeado). +fn atender_evento_voz(m: &mut Model, ev: rimay_voz_host::EventoEscucha) { + match m.voz_target { + Some(VozTarget::Agente) => { + m.agente.fijar_reloj(ahora_ms()); + for msg in mapear_evento_voz(ev) { + m.agente = shuma_module_agente::update(m.agente.clone(), msg); + } + } + Some(VozTarget::Shell(idx)) => { + let (estado, dictado) = evento_a_estado(&ev); + if let Some(sess) = m.sessions.get_mut(idx) { + if let ModuleState::Shell(st) = &mut sess.shell_mut().state { + st.fijar_escucha(estado); + st.set_voz_reloj(ahora_ms()); + // El dictado entra por el mismo camino que el texto tipeado. + if let Some(t) = dictado { + *st = shuma_module_shell::update( + st.clone(), + shuma_module_shell::Msg::InsertAtCursor(t), + ); + } + } + } + } + Some(VozTarget::CommandBar) => { + let (estado, dictado) = evento_a_estado(&ev); + if let Some(inst) = m.bottombar.as_mut() { + if let ModuleState::CommandBar(st) = &mut inst.state { + st.fijar_escucha(estado); + st.set_reloj(ahora_ms()); + if let Some(t) = dictado { + *st = shuma_module_commandbar::update( + st.clone(), + shuma_module_commandbar::Msg::Dictado(t), + ); + } + } + } + } + None => {} + } +} + +/// Atiende el intent de micrófono que dejó el panel de chat tras un `ToggleMic`: +/// arranca o para la captura real (target = chat). +fn atender_mic_intent(m: &mut Model, handle: &Handle) { + match m.agente.tomar_mic_intent() { + Some(true) => iniciar_voz(m, handle, VozTarget::Agente), + Some(false) => parar_voz(m), + None => {} + } +} + +/// Atiende el intent de micrófono de un módulo del chasis (shell de sesión o +/// command-bar) tras rutear su `ToggleMic`: arranca/para la captura apuntándola a +/// esa superficie. Se llama tras `apply_module_msg` en el handler de `Msg::Module`. +fn atender_mic_intent_slot(m: &mut Model, handle: &Handle, slot: &Slot) { + let intent = match slot { + Slot::Session(idx, Which::Shell) => match m.sessions.get_mut(*idx) { + Some(sess) => match &mut sess.shell_mut().state { + ModuleState::Shell(st) => st.tomar_mic_intent().map(|on| (on, VozTarget::Shell(*idx))), + _ => None, + }, + None => None, + }, + Slot::BottomBar => match m.bottombar.as_mut() { + Some(inst) => match &mut inst.state { + ModuleState::CommandBar(st) => { + st.tomar_mic_intent().map(|on| (on, VozTarget::CommandBar)) + } + _ => None, + }, + None => None, + }, + _ => None, + }; + match intent { + Some((true, target)) => iniciar_voz(m, handle, target), + Some((false, _)) => parar_voz(m), + None => {} + } +} + +/// Atiende el intent de **lectura TTS** que dejó el panel al cerrar un turno con +/// la lectura activada: sintetiza la prosa con el `Locutor` y la reproduce. +/// No-op si no hay nada que leer (el caso común). +fn atender_leer_intent(m: &mut Model) { + if let Some(texto) = m.agente.tomar_leer_intent() { + reproducir_texto(m, texto); + } +} + +/// Sintetiza `texto` con el `Locutor` del SO y lo reproduce por los parlantes en +/// una task. La síntesis (nube/local/mock) y la reproducción (`rimay-voz-host`) +/// corren fuera del bucle Elm, en el runtime de lectura. +fn reproducir_texto(m: &mut Model, texto: String) { + if !asegurar_locutor(m) { + return; + } + let (Some(rt), Some(loc)) = (m._voz_tts_rt.as_ref(), m._voz_locutor.as_ref()) else { + return; + }; + let loc = loc.clone(); + rt.spawn(async move { + match loc.sintetizar(&texto).await { + Ok(audio) => { + if let Err(e) = rimay_voz_host::reproducir(&audio).await { + eprintln!("voz: no se pudo reproducir: {e}"); + } + } + Err(e) => eprintln!("voz: no se pudo sintetizar: {e}"), + } + }); +} + +/// Construye —una sola vez— el runtime tokio y el `Locutor` TTS a partir de la +/// config del SO (`ai.voz.tts`, editable en wawa-panel). Devuelve `false` si el +/// runtime no se pudo crear. El `Locutor` cae a mock si el backend elegido no +/// está disponible (mismo criterio que la captura). +fn asegurar_locutor(m: &mut Model) -> bool { + if m._voz_locutor.is_some() { + return true; + } + let rt = match tokio::runtime::Runtime::new() { + Ok(rt) => rt, + Err(e) => { + eprintln!("voz: no se pudo crear el runtime de lectura: {e}"); + return false; + } + }; + let voz = wawa_config::WawaConfig::load().ai.voz; + let vcfg = rimay_voz::VozConfig { + stt: rimay_voz::Backend::parse(&voz.stt), + tts: rimay_voz::Backend::parse(&voz.tts), + socket: None, + }; + let loc = rt.block_on(vcfg.construir_tts_o_mock()); + m._voz_tts_rt = Some(rt); + m._voz_locutor = Some(loc); + true +} + +/// Ruta del wake-word enrolado: `$XDG_CONFIG_HOME/shuma/wake.ron`. +fn ruta_wake() -> Option { + directories::ProjectDirs::from("", "", "shuma").map(|d| d.config_dir().join("wake.ron")) +} + +/// Carga el detector de wake-word enrolado, si existe y parsea. +fn cargar_detector_wake() -> Option { + let path = ruta_wake()?; + let txt = std::fs::read_to_string(path).ok()?; + ron::from_str::(&txt) + .ok() + .filter(|d| d.enrolado()) +} + +/// Persiste el detector enrolado a disco (crea el dir si falta). +fn guardar_detector_wake(det: &rimay_voz::DetectorPlantilla) -> bool { + let Some(path) = ruta_wake() else { return false }; + if let Some(dir) = path.parent() { + let _ = std::fs::create_dir_all(dir); + } + match ron::to_string(det) { + Ok(s) => std::fs::write(path, s).is_ok(), + Err(_) => false, + } +} + +/// Atiende el intent de enrolar que dejó el panel tras `EnrolarWake`/`Cancelar`. +fn atender_enrol_intent(m: &mut Model, handle: &Handle) { + match m.agente.tomar_enrol_intent() { + Some(true) => iniciar_enrol(m, handle), + Some(false) => parar_voz(m), // cancela: corta la captura de enrolado + None => {} + } +} + +/// Arranca la grabación de enrolamiento: capta `ENROL_OBJETIVO` utterances de +/// «shuma», arma el `DetectorPlantilla`, lo persiste y avisa al panel. +fn iniciar_enrol(m: &mut Model, handle: &Handle) { + let rt = match tokio::runtime::Runtime::new() { + Ok(rt) => rt, + Err(e) => { + m.agente.enrol_terminado(); + eprintln!("voz: no se pudo crear el runtime de enrolado: {e}"); + return; + } + }; + let arranque = { + let _g = rt.enter(); + rimay_voz_host::enrolar() + }; + match arranque { + Ok((guardia, mut rx)) => { + let h = handle.clone(); + rt.spawn(async move { + let objetivo = shuma_module_agente::ENROL_OBJETIVO as usize; + let mut audios = Vec::new(); + while let Some(audio) = rx.recv().await { + audios.push(audio); + h.dispatch(Msg::Agente(shuma_module_agente::Msg::EnrolarCapturado)); + if audios.len() >= objetivo { + let det = rimay_voz::DetectorPlantilla::enrolar( + &audios, + rimay_voz::UMBRAL_LLAMADO_DEFAULT, + rimay_voz::ParamsLlamado::default(), + ); + guardar_detector_wake(&det); + // El chasis cierra la captura y marca el wake listo. + h.dispatch(Msg::VozEnrolHecho); + break; + } + } + }); + m._voz_guardia = Some(guardia); + m._voz_rt = Some(rt); + eprintln!("voz: 🎙 enrolando — di «shuma» {} veces", shuma_module_agente::ENROL_OBJETIVO); + } + Err(e) => { + m.agente.enrol_terminado(); // saca al panel del modo enrolar + eprintln!("voz: no se pudo abrir el micrófono para enrolar: {e}"); + } + } +} + +/// Conmuta el **perfil de sesión** (contexto tipo Firefox): guarda el estado del +/// perfil actual, cambia el directorio de datos activo y **recarga** sesiones, +/// chrome, disposiciones y containers desde el nuevo directorio. Aislamiento +/// total entre contextos sin duplicar la lógica de persistencia. +pub(crate) fn switch_session_profile(mut m: Model, name: &str) -> Model { + if m.session_profiles.active() == name { + return m; // ya estamos ahí + } + // Guardar el estado del perfil actual antes de irnos. + save_sessions(&m); + save_chrome(&m); + save_session_outputs(&m); + // Conmutar (sólo perfiles existentes). + if m.session_profiles.set_active(name).is_err() { + return m; + } + perfiles::sessions::set_active(name); + if let Some(p) = perfiles::sessions::SessionProfiles::default_path() { + let _ = m.session_profiles.save(&p); + } + // Recargar desde el nuevo directorio. + let mut sessions = vec![Session::draft()]; + for c in load_sessions() { + let mut sess = Session::from_config(c); + if sess.persist { + if let Some(snap) = persist::load_session_output(&sess.name) { + if let ModuleState::Shell(st) = &mut sess.shell_mut().state { + st.restore_output(snap); + } + } + } + sessions.push(sess); + } + m.sessions = sessions; + let chrome = load_chrome(); + m.active_session = chrome.active_session.min(m.sessions.len().saturating_sub(1)); + m.active_tool = chrome.active_tool; + m.session_panel_open = chrome.session_panel_open; + m.session_w = chrome.session_w; + m.monitors_width = chrome.monitors_width; + m.sidebar_left.panel_w = chrome.session_w; + m.sidebar_right.panel_w = chrome.monitors_width; + m.layouts = load_layouts(); + m.container_cfgs = load_container_cfgs(); + m.pending_prefix = false; + perfiles::apply_active_appearance(&mut m); + m +} + +/// Persiste a disco la biblioteca de perfiles del tipo dado. +pub(crate) fn save_profiles(m: &Model, kind: ProfKind) { + match kind { + ProfKind::Shortcuts => { + if let Some(p) = perfiles::shortcuts::ShortcutProfiles::default_path() { + let _ = m.shortcuts.save(&p); + } + } + ProfKind::Appearance => { + if let Some(p) = perfiles::appearance::AppearanceProfiles::default_path() { + let _ = m.appearance.save(&p); + } + } + ProfKind::Sessions => { + if let Some(p) = perfiles::sessions::SessionProfiles::default_path() { + let _ = m.session_profiles.save(&p); + } + } + } +} + +/// Renombra un **perfil de sesión**: mueve su directorio de datos en disco y +/// actualiza el índice. Si es el activo, reapunta el directorio global. +pub(crate) fn rename_session_profile(mut m: Model, from: &str, to: &str) -> Model { + // Mover el directorio en disco antes de tocar el índice (si existe). + if let (Some(old), Some(new)) = ( + perfiles::sessions::data_dir_for(from), + perfiles::sessions::data_dir_for(to), + ) { + if old.exists() && !new.exists() { + if let Some(parent) = new.parent() { + let _ = std::fs::create_dir_all(parent); + } + let _ = std::fs::rename(&old, &new); + } + } + let was_active = m.session_profiles.active() == from; + if m.session_profiles.rename(from, to).is_ok() { + if was_active { + perfiles::sessions::set_active(to); + } + if let Some(p) = perfiles::sessions::SessionProfiles::default_path() { + let _ = m.session_profiles.save(&p); + } + } + m +} + +// ─── App impl ─────────────────────────────────────────────────────── + +pub struct Shell; + +impl App for Shell { + type Model = Model; + type Msg = Msg; + + fn title() -> &'static str { + "shuma" + } + + fn app_id() -> Option<&'static str> { + Some("shuma.shell") + } + + fn initial_size() -> (u32, u32) { + (1280, 800) + } + + fn init(handle: &Handle) -> Self::Model { + // Antes de construir el modelo (que cachea el PATH para el completado): + // absorbe el entorno de una terminal real para que `claude` y demás + // binarios de tu `.zshrc`/`.bashrc` estén disponibles desde el arranque. + wire_login_env(); + let mut model = new_model(); + model.dock_mode = std::env::var_os("SHUMA_DOCK").is_some(); + model.collapse_on_blur = std::env::var_os("SHUMA_BAR_ON_BLUR").is_some(); + spawn_host_effects(&mut model, handle); + // `SHUMA_EXEC` (lo fija `-e/--exec`): corre ese comando como primer + // bloque. Lo quitamos del entorno para no heredarlo a procesos hijos + // (un comando que reinvoque shuma no debe re-disparar). + if let Some(cmd) = std::env::var("SHUMA_EXEC").ok().filter(|s| !s.is_empty()) { + std::env::remove_var("SHUMA_EXEC"); + handle.dispatch(Msg::RunFromHistoryNow(cmd)); + } + model + } + + fn on_resize(_model: &Self::Model, width: u32, height: u32) -> Option { + Some(Msg::Resized(width as f32, height as f32)) + } + + fn on_window_focus(model: &Self::Model, focused: bool) -> Option { + // En modo ventana, al perder el foco y si está configurado, repliega a la + // barra dockeada (re-lanza en modo dock y cierra esta ventana). Opt-in. + if !focused && !model.dock_mode && model.collapse_on_blur { + return Some(Msg::MenuCommand("window.toggle-dock".to_string())); + } + None + } + + fn on_key(model: &Self::Model, e: &KeyEvent) -> Option { + if e.state != KeyState::Pressed { + return None; + } + // Los modales bloqueantes capturan TODO el teclado. + if model.hosts_modal_open { + if let llimphi_ui::Key::Named(llimphi_ui::NamedKey::Escape) = &e.key { + return Some(Msg::CloseHostsModal); + } + return Some(Msg::HostDraftKey(e.clone())); + } + if model.containers_modal_open { + if let llimphi_ui::Key::Named(llimphi_ui::NamedKey::Escape) = &e.key { + return Some(Msg::CloseContainersModal); + } + return Some(Msg::ContainerDraftKey(e.clone())); + } + if model.layouts_modal_open { + if let llimphi_ui::Key::Named(llimphi_ui::NamedKey::Escape) = &e.key { + return Some(Msg::CloseLayoutsModal); + } + return Some(Msg::LayoutNameKey(e.clone())); + } + if model.perfiles_modal_open { + if let llimphi_ui::Key::Named(llimphi_ui::NamedKey::Escape) = &e.key { + return Some(Msg::ClosePerfilesModal); + } + if model.wp_path_focused { + return Some(Msg::WpPathKey(e.clone())); + } + return Some(Msg::ProfNameKey(e.clone())); + } + // Renombrando una pestaña: el campo del chip se lleva TODO el teclado + // (Enter confirma, Esc cancela). Va antes de los atajos del workspace: + // si no, tipear una `t` mientras renombrás abriría una pestaña nueva. + if model.tab_rename.is_some() { + return Some(Msg::TabRenameKey(e.clone())); + } + if model.focused_field.is_some() { + return Some(Msg::RemoteKey(e.clone())); + } + if model.dropdown_open.is_some() { + if let llimphi_ui::Key::Named(llimphi_ui::NamedKey::Escape) = &e.key { + return Some(Msg::DismissDropdown); + } + } + // Buscador de un sidebar unificado con foco: las teclas van al filtro + // (Esc/Enter lo sueltan, Backspace borra, el texto se inserta), no a los + // atajos del workspace ni al shell. + if model.sidebar_left.search_focused { + return sidebar_search_key(e, &model.sidebar_left.search, Msg::SidebarLeft); + } + if model.sidebar_right.search_focused { + return sidebar_search_key(e, &model.sidebar_right.search, Msg::SidebarRight); + } + if let Some(msg) = menu::intercept_key(model, e) { + return Some(msg); + } + // Atajos del workspace según el perfil de atajos activo (shuma/hyprland/ + // tmux/zellij/vim o uno propio): tabs, tiling, flotantes. + if let Some(msg) = perfiles::shortcuts::resolve_key(model, e) { + return Some(msg); + } + // Con el diente del chat abierto, el teclado escribe en su input. + if model.active_tool == Some(Tool::Agente) { + return Some(Msg::Agente(shuma_module_agente::Msg::Key(e.clone()))); + } + forward_key_to_focused_shell(model, e) + } + + fn on_wheel( + model: &Self::Model, + delta: WheelDelta, + _cursor: (f32, f32), + modifiers: Modifiers, + ) -> Option { + if modifiers.ctrl && delta.y != 0.0 { + let factor = zoom_factor_de_rueda(delta.y); + return Some(Msg::Module( + Slot::Session(model.active_session, Which::Shell), + ModuleMsg::Shell(shuma_module_shell::Msg::ZoomBy(factor)), + )); + } + if modifiers.shift && delta.y != 0.0 { + let dx = delta.y * 40.0; + return Some(Msg::Module( + Slot::Session(model.active_session, Which::Shell), + ModuleMsg::Shell(shuma_module_shell::Msg::ScrollHoriz(dx)), + )); + } + let dpx = -delta.y * 40.0; + if dpx == 0.0 { + return None; + } + forward_wheel_to_focused_shell(model, dpx) + } + + fn update(model: Self::Model, msg: Self::Msg, handle: &Handle) -> Self::Model { + crate::app_update::apply(model, msg, handle) + } + + fn view(model: &Self::Model) -> View { + crate::app_view::main_view(model) + } + + fn view_overlay(model: &Self::Model) -> Option> { + crate::app_view::overlay_view(model) + } +} + +/// Rutea una tecla al **buscador de un sidebar unificado** con foco: Esc/Enter +/// sueltan el foco; Backspace borra el último char; el `text` del evento se +/// inserta al filtro. Devuelve el `RagSidebarMsg` correspondiente (envuelto por +/// `wrap` en `Msg::SidebarLeft`/`Msg::SidebarRight`), o `None` para teclas que no +/// alteran el filtro (igual traga el resto: ya interceptamos antes de los atajos). +fn sidebar_search_key( + e: &KeyEvent, + current: &str, + wrap: impl Fn(llimphi_widget_rag_sidebar::RagSidebarMsg) -> Msg, +) -> Option { + use llimphi_widget_rag_sidebar::RagSidebarMsg; + match &e.key { + llimphi_ui::Key::Named(llimphi_ui::NamedKey::Escape) + | llimphi_ui::Key::Named(llimphi_ui::NamedKey::Enter) => { + Some(wrap(RagSidebarMsg::SearchFocus(false))) + } + llimphi_ui::Key::Named(llimphi_ui::NamedKey::Backspace) => { + let mut s = current.to_string(); + s.pop(); + Some(wrap(RagSidebarMsg::SearchSet(s))) + } + _ => { + if let Some(t) = &e.text { + if !t.is_empty() && !t.chars().any(char::is_control) { + let mut s = current.to_string(); + s.push_str(t); + return Some(wrap(RagSidebarMsg::SearchSet(s))); + } + } + None + } + } +} + +/// ¿Debe Esc cerrar el drawer Quake que hospeda a esta shuma? El chasis (pata) +/// lo pregunta ANTES de reenviar la tecla a `on_key`. Devuelve `false` cuando +/// shuma tiene algo propio que descartar con Esc —un modal, un dropdown, un +/// campo/draft con foco, una sesión en creación— o cuando el shell enfocado +/// corre una TUI de pantalla completa (vim/htop/less/man) que necesita el Esc. +/// En cualquier otro caso (prompt ocioso, modo líneas) Esc repliega el drawer. +pub fn escape_closes_drawer(model: &Model) -> bool { + if model.hosts_modal_open + || model.containers_modal_open + || model.layouts_modal_open + || model.perfiles_modal_open + || model.focused_field.is_some() + || model.dropdown_open.is_some() + { + return false; + } + if model.host_draft.as_ref().is_some_and(|d| d.focused.is_some()) { + return false; + } + if model.container_draft.as_ref().is_some_and(|d| d.focus.is_some()) { + return false; + } + if model.active().is_some_and(|s| s.pending) { + return false; + } + let fullscreen_tui = + |state: &ModuleState| matches!(state, ModuleState::Shell(s) if s.is_fullscreen_tui()); + if let Some(inst) = model.main.as_ref() { + if fullscreen_tui(&inst.state) { + return false; + } + } + if let Some(s) = model.active() { + if fullscreen_tui(&s.shell().state) { + return false; + } + } + true +} + +// ─── Superficie hosteable (para pata) ──────────────────────────────── +// +// Funciones libres que **delegan** a los métodos del `App` `Shell`. El +// standalone queda idéntico (el App impl no se toca); un host como pata +// construye el `Model` con `new_model()`, lo tickea con `spawn_host_effects` +// (handle lifteado), le rutea input/Msg con `update`/`on_key`/`on_wheel`/ +// `on_resize`, y pinta `view(model).map(...)` + `view_overlay(model).map(...)`. + +/// Aplica un `Msg` al `Model` de shuma (delegado a `App::update`). +pub fn update(model: Model, msg: Msg, handle: &Handle) -> Model { + ::update(model, msg, handle) +} + +/// Vista principal de shuma para `model` (delegado a `App::view`). +pub fn view(model: &Model) -> View { + ::view(model) +} + +/// Overlay (modales/menús/dropdowns) de shuma, si hay (delegado a `App::view_overlay`). +pub fn view_overlay(model: &Model) -> Option> { + ::view_overlay(model) +} + +/// Traduce una tecla a un `Msg` de shuma según el foco actual (delegado a `App::on_key`). +pub fn on_key(model: &Model, e: &KeyEvent) -> Option { + ::on_key(model, e) +} + +/// Sonda de diagnóstico de atajos (para el «sólo sirve Ctrl+Shift+C»): describe +/// el chord que se computa de `e` y si matchea un bind del perfil activo. Ver +/// [`perfiles::shortcuts::diag_shortcut`]. +pub fn diag_shortcut(model: &Model, e: &KeyEvent) -> String { + perfiles::shortcuts::diag_shortcut(model, e) +} + +/// Declara la **caja de anclaje de overlays**: dónde monta el host los menús +/// contextuales y modales de shuma. Sólo hace falta cuando NO coincide con el +/// área del cuerpo — hospedada en el drawer de pata, el overlay va sobre la +/// surface entera (que es el sistema de coordenadas del puntero) mientras el +/// cuerpo ocupa una franja. Ver [`Model::overlay_viewport`]. +pub fn set_overlay_box(model: &mut Model, w: f32, h: f32) { + model.overlay_box = if w > 1.0 && h > 1.0 { + Some((w, h)) + } else { + None + }; +} + +/// El factor de zoom de **un** paso de rueda con `Ctrl`. `dy` viene en la +/// convención de llimphi (positivo = hacia abajo), así que rodar **hacia arriba +/// agranda**. Público porque el drawer de pata hospeda el mismo canvas y tiene +/// que aplicar la MISMA curva: cuando cada uno tenía su `1.1` a mano, el zoom +/// del drawer y el de la shuma suelta se desincronizaban en silencio. +pub fn zoom_factor_de_rueda(dy: f32) -> f32 { + if dy > 0.0 { + 1.0 / 1.1 + } else { + 1.1 + } +} + +/// Traduce la rueda a un `Msg` de shuma (delegado a `App::on_wheel`). +pub fn on_wheel( + model: &Model, + delta: WheelDelta, + cursor: (f32, f32), + modifiers: Modifiers, +) -> Option { + ::on_wheel(model, delta, cursor, modifiers) +} + +/// Reacciona a un resize del área hospedada (delegado a `App::on_resize`). +pub fn on_resize(model: &Model, width: u32, height: u32) -> Option { + ::on_resize(model, width, height) +} + +/// Vista del **input vivo de la sesión activa**, aislado del resto del chrome, +/// para hospedarlo en una barra externa (el cabezal de pata): es el mismísimo +/// `shell_input_view` que pinta el canvas, ruteado por el `lift` de la sesión +/// activa, así que tipear ahí ejecuta en esa sesión. `None` si la activa no es +/// un shell (form de nueva sesión / sin sesiones) — en ese caso el host muestra +/// un fallback. Espeja `shuma_module_shell::input_view` a nivel de la app +/// completa (la sesión activa ES un `shuma-module-shell`). +pub fn active_input_view(model: &Model, theme: &Theme) -> Option> { + let session = model.active()?; + if session.pending { + return None; + } + let idx = model.active_session; + match &session.shell().state { + ModuleState::Shell(state) => Some(shuma_module_shell::input_view(state, theme, move |m| { + Msg::Module(Slot::Session(idx, Which::Shell), ModuleMsg::Shell(m)) + })), + _ => None, + } +} + +/// El `Msg` que **desenfoca** el input de la sesión activa (apaga el cue de foco +/// del input hospedado en la barra). El host (pata) lo dispara cuando el +/// compositor le quita el teclado (KB leave / click en otra ventana o Alt+Tab), +/// así el foco visual no se queda pegado. `None` si la activa no es un shell. +pub fn blur_active_input(model: &Model) -> Option { + let session = model.active()?; + if session.pending { + return None; + } + let idx = model.active_session; + match &session.shell().state { + ModuleState::Shell(_) => Some(Msg::Module( + Slot::Session(idx, Which::Shell), + ModuleMsg::Shell(shuma_module_shell::Msg::BlurInput), + )), + _ => None, + } +} + +/// Propaga a TODOS los shells (paneles de toda sesión + slots de barra) si su +/// canvas está **a la vista**. El host (pata) lo baja al plegar el drawer y lo +/// sube al desplegarlo: con el canvas oculto, un PTY interactivo vivo +/// (claude/vim) deja de comerse el tipeo de la barra — las teclas vuelven al +/// input; el PTY sigue corriendo de fondo. Standalone nunca lo toca (default +/// `true`). +pub fn set_canvas_visible(model: &mut Model, visible: bool) { + fn set_one(inst: &mut Instance, visible: bool) { + if let ModuleState::Shell(s) = &mut inst.state { + s.canvas_visible = visible; + } + } + if let Some(i) = model.topbar.as_mut() { + set_one(i, visible); + } + if let Some(i) = model.bottombar.as_mut() { + set_one(i, visible); + } + if let Some(i) = model.main.as_mut() { + set_one(i, visible); + } + for s in model.sessions.iter_mut() { + s.workspace.for_each_pane_mut(|inst| set_one(inst, visible)); + } +} + +/// El `Msg` que **enfoca** el input de la sesión activa (enciende el cue de +/// foco: caret + marco brillante). Simétrico de [`blur_active_input`]: el host +/// (pata) lo dispara cuando el compositor le ENTREGA el teclado (KB enter por +/// hover/click/fallback de escritorio vacío) — sin esto el teclado llegaba +/// pero el input se veía apagado ("no agarra foco"). `None` si la sesión +/// activa no es un shell. +pub fn focus_active_input(model: &Model) -> Option { + let session = model.active()?; + if session.pending { + return None; + } + let idx = model.active_session; + match &session.shell().state { + ModuleState::Shell(_) => Some(Msg::Module( + Slot::Session(idx, Which::Shell), + ModuleMsg::Shell(shuma_module_shell::Msg::FocusInput), + )), + _ => None, + } +} + +/// `true` si el `Msg` es el "focalizar el input" de un shell de sesión (click +/// sobre el input vivo). El host (pata) lo usa para abrir su drawer cuando se +/// clickea el cabezal de la barra —espeja el auto-open de FocusInput del path +/// bare—. +pub fn msg_is_focus_input(msg: &Msg) -> bool { + matches!( + msg, + Msg::Module(_, ModuleMsg::Shell(shuma_module_shell::Msg::FocusInput)) + ) +} + +/// `true` si el `Msg` es el **enviar** del input de un shell de sesión (click en +/// el botón de avioncito que reemplaza al micrófono con texto). El host (pata) lo +/// usa para desplegar su drawer al submitear —espeja el open-al-Enter del bare—. +pub fn msg_is_submit(msg: &Msg) -> bool { + matches!( + msg, + Msg::Module(_, ModuleMsg::Shell(shuma_module_shell::Msg::Submit)) + ) +} + +#[cfg(test)] +mod tests_zoom_rueda { + use super::zoom_factor_de_rueda; + + /// El sentido, que es lo fácil de invertir: `dy` viene en la convención de + /// llimphi (positivo = hacia abajo, porque el runtime le da vuelta el signo + /// a winit), así que **rodar hacia arriba agranda**. + #[test] + fn arriba_agranda_y_abajo_achica() { + assert!(zoom_factor_de_rueda(-1.0) > 1.0, "rueda arriba debe agrandar"); + assert!(zoom_factor_de_rueda(1.0) < 1.0, "rueda abajo debe achicar"); + } + + /// Un paso para cada lado vuelve al tamaño original: sin deriva al ir y venir. + #[test] + fn un_paso_y_su_inverso_se_cancelan() { + let ida = zoom_factor_de_rueda(-1.0); + let vuelta = zoom_factor_de_rueda(1.0); + assert!((ida * vuelta - 1.0).abs() < 1e-6, "ida×vuelta = {}", ida * vuelta); + } +} + +#[cfg(test)] +mod tests_marquesina { + use super::{clasificar_urgencia, marquesina_actual}; + use shuma_module_commandbar::Urgencia; + use willay_core::{Clase, Evento, Payload}; + + fn notif(titulo: &str, cuerpo: &str) -> Evento { + Evento::nuevo(Clase::Notificacion, 100, "app", titulo, cuerpo, Payload::Nada) + } + + #[test] + fn clasifica_por_palabras_clave() { + assert_eq!(clasificar_urgencia(¬if("Build failed", "tests rotos")), Urgencia::Urgente); + assert_eq!(clasificar_urgencia(¬if("Nuevo mensaje", "te escribió Ana")), Urgencia::Leve); + assert_eq!( + clasificar_urgencia(¬if("Sincronización completada", "")), + Urgencia::Silencio + ); + } + + #[test] + fn varios_tipos_de_evento_se_narran_no_solo_notificaciones() { + // Capturas y clips se adaptan a una frase → se narran (leves). + let clip = Evento::nuevo(Clase::Clip, 100, "x", "copiaste algo", "", Payload::Nada); + let cap = Evento::nuevo(Clase::Captura, 100, "hapiy", "Captura DP-1", "", Payload::Nada); + assert_eq!(clasificar_urgencia(&clip), Urgencia::Leve); + assert_eq!(clasificar_urgencia(&cap), Urgencia::Leve); + // Los checkpoints (autosave/forense) son ruido para la barra. + let cp = Evento::nuevo(Clase::Checkpoint, 100, "pluma", "guardado", "doc", Payload::Nada); + assert_eq!(clasificar_urgencia(&cp), Urgencia::Silencio); + } + + #[test] + fn marquesina_filtra_silenciados_y_compone_texto() { + let evs = vec![ + notif("Sincronización completada", ""), // silenciado → se saltea + notif("Reunión en 10", "calendario"), // leve → narrable + ]; + let m = marquesina_actual(&evs, 0).expect("hay uno narrable"); + assert_eq!(m.urgencia, Urgencia::Leve); + assert_eq!(m.texto, "app · Reunión en 10"); + } + + #[test] + fn sin_eventos_narrables_no_hay_marquesina() { + let evs = vec![notif("Actualización disponible", "")]; // silenciado + assert!(marquesina_actual(&evs, 0).is_none()); + assert!(marquesina_actual(&[], 0).is_none()); + } + + #[test] + fn rota_entre_narrables_por_idx() { + let evs = vec![notif("Alfa", ""), notif("Beta", "")]; // ambos leves + let a = marquesina_actual(&evs, 0).unwrap().texto; + let b = marquesina_actual(&evs, 1).unwrap().texto; + assert_ne!(a, b); + // El idx envuelve (módulo). + assert_eq!(marquesina_actual(&evs, 2).unwrap().texto, a); + } +} diff --git a/02_ruway/shuma/shuma-shell-llimphi/src/main.rs b/02_ruway/shuma/shuma-shell-llimphi/src/main.rs index 39df94c..613420b 100644 --- a/02_ruway/shuma/shuma-shell-llimphi/src/main.rs +++ b/02_ruway/shuma/shuma-shell-llimphi/src/main.rs @@ -1,675 +1,50 @@ -//! `shuma-shell-llimphi` — chasis del shell shuma sobre Llimphi. +//! `shuma-shell-llimphi` (bin) — entrypoint fino. //! -//! Shuma es la app standalone "normal" del workspace: una ventana con -//! tabs siempre visibles, monitores a la derecha, command-bar abajo. La -//! metáfora Quake-drawer (overlay sobre el escritorio + F12 para -//! invocar) vive en `mirada-launcher-llimphi`, no acá. -//! -//! **Layout** (sin `[main]` en shumarc): -//! -//! ```text -//! ┌──────────────────────────────────────────────────┐ -//! │ TopBar · launcher (apps + shortcuts) │ -//! ├────────────────────────────────┬─────────────────┤ -//! │ tabs: [shell] [lienzo] [matilda]│ │ -//! ├────────────────────────────────┤ Monitores │ -//! │ │ CPU + MEM + │ -//! │ contenido del tab activo │ los del módulo │ -//! │ │ │ -//! ├────────────────────────────────┴─────────────────┤ -//! │ BottomBar · command-bar › escribí… │ -//! └──────────────────────────────────────────────────┘ -//! ``` -//! -//! Si el shumarc declara `[main]`, ese módulo ocupa toda el área central -//! a pantalla completa (sin tabs ni monitores) — útil para correr shuma -//! como wrapper de matilda standalone, por ejemplo. -//! -//! El chasis no conoce a sus módulos: el `Kind` estático enumera los -//! compilados. El shumarc elige cuáles activar y en qué slot. +//! Toda la lógica (Model/update/view/App, sesiones, chrome) vive en la **lib** +//! homónima para que sea un frontend sobre core agnóstico (Regla 2) y la pueda +//! hospedar también pata. Este bin sólo arranca la app de ventana. #![forbid(unsafe_code)] -mod config; - -use std::time::Duration; - -use llimphi_motion::{animate, motion, Tween}; -use llimphi_theme::Theme; -use llimphi_ui::llimphi_layout::taffy::{ - prelude::{length, percent, Dimension, FlexDirection, Size, Style}, - Rect, -}; -use llimphi_ui::llimphi_raster::kurbo::{Affine, BezPath, PathEl, Point, Stroke}; -use llimphi_ui::llimphi_raster::peniko::Color; -use llimphi_ui::{ - App, DragPhase, Handle, KeyEvent, KeyState, Modifiers, PaintRect, View, WheelDelta, -}; -use llimphi_widget_splitter::{splitter_two, Direction, PaneSize, SplitterPalette}; -use llimphi_widget_stat_card::{stat_card_view, StatCardPalette}; -use llimphi_widget_tabs::{tabs_view, TabsPalette, TabsSpec}; -use shuma_module::{ModuleContributions, MonitorSpec, ShortcutAction, ShortcutSpec, Source}; -use shuma_sysmon::{Snapshot, SystemSampler}; -use std::collections::HashMap; - -const HISTORY: usize = 60; -const TICK: Duration = Duration::from_secs(1); -/// Cadencia rápida para drenar el output del shell (streaming de -/// `shuma-exec`). 1 Hz se siente lento al ver `for i in …; do echo $i; -/// sleep 0.1; done`; 100 ms hace la salida sentirse en vivo sin -/// comerse CPU notable. -const SHELL_TICK: Duration = Duration::from_millis(100); -const MONITORS_INITIAL_WIDTH: f32 = 280.0; - -/// Id del diente "Monitores" en el rail hospedado de pata. Los dientes de -/// las tabs usan su índice (`0..tabs.len()`); este sentinela alto no choca -/// con ningún índice real y togglea el panel de monitores. -const MONITORS_TOOTH: u32 = u32::MAX; - -/// Construye el cliente del rail hospedado si `SHUMA_DELEGATE_SIDEBAR` está -/// set. shuma publica sus tabs como dientes (cambian de tab al activarse) + -/// un diente "Monitores" que togglea el panel derecho. Cuando shuma tiene -/// foco, esos dientes aparecen en el rail global de pata; el área central -/// queda como puro lienzo (monitores ocultos por default). `app_id` debe ser -/// el mismo que reporta el compositor (`Shell::app_id`). -fn shuma_host(handle: &Handle, tabs: &[Instance]) -> Option { - if std::env::var_os("SHUMA_DELEGATE_SIDEBAR").is_none() { - return None; - } - let teeth = host_teeth(tabs); - let h = handle.clone(); - pata_host::HostClient::connect("shuma.shell", "shuma", teeth, move |id| { - h.dispatch(Msg::HostActivate(id)) - }) -} - -/// Dientes que shuma presta al rail de pata: uno por tab (id = índice) más el -/// toggle de monitores. -fn host_teeth(tabs: &[Instance]) -> Vec { - let mut teeth: Vec = tabs - .iter() - .enumerate() - .map(|(i, inst)| pata_host::HostedTooth::new(i as u32, tooth_icon(inst.kind), inst.label.clone())) - .collect(); - teeth.push(pata_host::HostedTooth::new( - MONITORS_TOOTH, - "system", - rimay_localize::t("shuma-label-monitors"), - )); - teeth -} - -/// Icono (vocabulario abierto de `pata`) para el diente de una tab según su -/// `Kind`. pata mapea estos nombres a sus formas (`files`→carpeta, -/// `tools`/`settings`→grupo, `monads`→mónada). -fn tooth_icon(kind: Kind) -> &'static str { - match kind { - Kind::Shell => "tools", - Kind::Matilda => "settings", - Kind::Minga => "monads", - Kind::Canvas => "files", - Kind::Launcher | Kind::CommandBar => "tools", - } -} - -/// `Source` por defecto de la tab shell según las env vars del proceso — -/// para que `SHUMA_REMOTE*` enrute los comandos al daemon sin shumarc. -/// (rescate del `detect_remote_transport` del shell GPUI): -/// -/// - `SHUMA_REMOTE_TCP_ADDR=host:port` + `SHUMA_REMOTE_TCP_PUB=` -/// → TCP autenticado Noise XK (`DaemonTcp`). La keypair propia la carga -/// `start_run` al conectar; acá sólo pasamos addr + pubkey del server. -/// - `SHUMA_REMOTE_SOCKET=/path` → daemon por ese Unix socket. -/// - `SHUMA_REMOTE=1` → daemon por el socket canónico (`socket: None`). -/// - sin ninguna → `Local` (ejecución directa). -fn default_shell_source() -> Source { - let nonempty = |k: &str| std::env::var(k).ok().filter(|v| !v.is_empty()); - if let (Some(addr), Some(pub_hex)) = ( - nonempty("SHUMA_REMOTE_TCP_ADDR"), - nonempty("SHUMA_REMOTE_TCP_PUB"), - ) { - return Source::DaemonTcp { - addr, - server_pub_hex: pub_hex, - label: None, - }; - } - if let Some(path) = nonempty("SHUMA_REMOTE_SOCKET") { - return Source::Daemon { - socket: Some(std::path::PathBuf::from(path)), - label: None, - }; - } - if std::env::var("SHUMA_REMOTE").as_deref() == Ok("1") { - return Source::Daemon { - socket: None, - label: None, - }; - } - Source::Local -} - fn main() { - rimay_localize::init(); - llimphi_ui::run::(); -} + bitacora::abrir("shuma"); + let args: Vec = std::env::args().skip(1).collect(); -// ─── Tipos de módulos conocidos por este binario ─────────────────── - -/// Qué `Kind` puede ocupar cada slot. Una variante por módulo -/// compilado: agregar uno nuevo (p. ej. `matilda`) es una variante + -/// ramas en `update`/`view`. El static dispatch sortea la ausencia de -/// `View::map` en llimphi-ui. -#[derive(Debug, Clone, Copy, PartialEq, Eq)] -enum Kind { - Launcher, - CommandBar, - Shell, - Matilda, - Minga, - Canvas, -} - -impl Kind { - /// `id` canónico — bloque 5 lo usa para matchear shumarc. - #[allow(dead_code)] - fn id(self) -> &'static str { - match self { - Kind::Launcher => shuma_module_launcher::ID, - Kind::CommandBar => shuma_module_commandbar::ID, - Kind::Shell => shuma_module_shell::ID, - Kind::Matilda => shuma_module_matilda::ID, - Kind::Minga => shuma_module_minga::ID, - Kind::Canvas => shuma_module_canvas::ID, - } - } -} - -/// State vivo de un módulo. Una variante por `Kind` para evitar trait -/// objects (cada módulo trae su propio `Msg` que no es object-safe). -enum ModuleState { - Launcher(shuma_module_launcher::State), - CommandBar(shuma_module_commandbar::State), - Shell(shuma_module_shell::State), - // `State` de matilda lleva el inventory entero (varios cientos - // de bytes); boxearlo mantiene el enum ModuleState compacto. - Matilda(Box), - Minga(shuma_module_minga::State), - Canvas(shuma_module_canvas::State), -} - -/// Una instancia activa de un módulo. `kind` + `state` deben coincidir -/// (lo invariante lo garantiza el constructor). -struct Instance { - kind: Kind, - label: String, - state: ModuleState, -} - -impl Instance { - fn launcher(state: shuma_module_launcher::State) -> Self { - Self { - kind: Kind::Launcher, - label: rimay_localize::t("shuma-label-launcher"), - state: ModuleState::Launcher(state), + // `-e/--exec `: corre ese comando como primer bloque al arrancar + // (estilo `xterm -e`). Lo consume el resto de argv. Lo usa, p. ej., el + // launcher de apps WASM (`llimphi-wasm-open`) para mostrar la salida de una + // app WASI de consola DENTRO de shuma en vez de en una ventana de consola + // propia. Se pasa por env (mismo patrón que `SHUMA_DOCK`), que `init` lee. + if let Some(pos) = args.iter().position(|a| a == "-e" || a == "--exec") { + let cmd = shell_join(&args[pos + 1..]); + if !cmd.is_empty() { + std::env::set_var("SHUMA_EXEC", cmd); } } - fn command_bar(state: shuma_module_commandbar::State) -> Self { - Self { - kind: Kind::CommandBar, - label: rimay_localize::t("shuma-label-command"), - state: ModuleState::CommandBar(state), - } - } - - fn shell(label: String, source: Source) -> Self { - Self { - kind: Kind::Shell, - label, - state: ModuleState::Shell(shuma_module_shell::State::new(source)), - } - } - - fn matilda(label: String, source: Source) -> Self { - Self::matilda_with_inventory(label, source, None) - } - - fn matilda_with_inventory( - label: String, - source: Source, - inventory: Option<&std::path::Path>, - ) -> Self { - let state = match inventory { - Some(p) => { - let inv = load_matilda_inventory(p).unwrap_or_else(example_inventory_fallback); - shuma_module_matilda::State::with_inventory_path(source, inv, p.to_path_buf()) - } - None => shuma_module_matilda::State::new(source), - }; - Self { - kind: Kind::Matilda, - label, - state: ModuleState::Matilda(Box::new(state)), - } - } - - fn minga(label: String, source: Source) -> Self { - Self { - kind: Kind::Minga, - label, - state: ModuleState::Minga(shuma_module_minga::State::new(source)), - } - } - - fn canvas(label: String) -> Self { - Self { - kind: Kind::Canvas, - label, - state: ModuleState::Canvas(shuma_module_canvas::State::new()), - } + // `--dock` arranca shuma como barra wlr-layer-shell (modo dock); sin flag, + // como ventana normal. + if args.iter().any(|a| a == "--dock") { + shuma_shell_llimphi::run_dock(); + } else { + shuma_shell_llimphi::run(); } } -#[derive(Debug, Clone)] -enum ModuleMsg { - Launcher(shuma_module_launcher::Msg), - CommandBar(shuma_module_commandbar::Msg), - #[allow(dead_code)] - Shell(shuma_module_shell::Msg), - Matilda(shuma_module_matilda::Msg), - Minga(shuma_module_minga::Msg), - Canvas(shuma_module_canvas::Msg), -} - -// ─── Slot del chasis al que va un Msg de módulo ──────────────────── - -/// Identifica de dónde viene un `ModuleMsg`. Los slots únicos (TopBar/ -/// Bottombar/Main) se identifican por sí mismos; el Tab lleva el -/// índice del tab para enrutar al instance correcto. -#[derive(Debug, Clone)] -enum Slot { - TopBar, - BottomBar, - #[allow(dead_code)] - Main, - Tab(usize), -} - -// ─── Modelo + Msg ─────────────────────────────────────────────────── - -struct Model { - theme: Theme, - - // Slots fijos (únicos): - topbar: Option, - bottombar: Option, - /// Si está set, ocupa toda el área central (sin tabs). Útil para - /// configurar shuma como wrapper de una sola app (matilda standalone, - /// editor, etc.) vía shumarc. - main: Option, - - // Tabs siempre visibles cuando `main` está vacío. - tabs: Vec, - active_tab: usize, - - // Monitor stack en el panel derecho del área central. - sysmon: SystemSampler, - last_snapshot: Option, - monitors_width: f32, - /// Historial por monitor extra (los que aportan los módulos vía - /// `contributions()`). La clave es `"/"`. El chasis - /// los muestrea en cada `Tick` y los acumula como `f32`. - extra_history: HashMap>, - /// Último `Sample::display` por monitor — se pinta como subtítulo - /// de la stat-card. - extra_display: HashMap, - /// Watcher del bus de config wawa. Vive lo que vive el modelo — - /// al dropear se cierran los notify::RecommendedWatcher y el thread - /// de debounce sale silenciosamente. Ningún read directo desde - /// el código de update — sólo recibe callbacks que se traducen a - /// `Msg::WawaConfigChanged`. - _wawa_watcher: Option, - - /// Menú principal: índice del menú raíz abierto (`None` = cerrado). - menu_open: Option, - /// Fila activa (resaltada por teclado) del dropdown del menú principal. - menu_active: usize, - /// Animación de aparición/swap del dropdown del menú principal (0→1). - menu_anim: Tween, - /// Menú contextual de terminal: ancla `(x, y)` en ventana (`None` = - /// cerrado). Se abre con right-click sobre el área de trabajo. - ctx_menu: Option<(f32, f32)>, - - /// Cliente del rail hospedado: con `SHUMA_DELEGATE_SIDEBAR`, shuma presta - /// sus tabs + el toggle de monitores al rail de pata. Kept-alive (las - /// activaciones llegan por callback → `Msg::HostActivate`); el `_` evita - /// el lint de campo sin leer, como `_wawa_watcher`. - _host: Option, - /// Visibilidad del panel de monitores. Sin delegar arranca `true` (siempre - /// visible); en modo delegado arranca oculto y lo controla el diente - /// "Monitores" del rail de pata. - monitors_visible: bool, -} - -#[derive(Clone)] -enum Msg { - Tick, - /// Tick rápido que drena la salida del shell (~100 ms) sin tocar - /// el muestreo de sysmon. - ShellTick, - /// Click en una tab. - SelectTab(usize), - /// Drag del splitter de monitores. - ResizeMonitors(f32), - /// Msg de un módulo. El chasis lo enruta a `update` según `slot`. - Module(Slot, ModuleMsg), - /// Click en un shortcut de la toolbar. `slot` es el módulo emisor - /// (a quien se le enruta la `ModuleAction`). - ShortcutClicked(Slot, ShortcutAction), - /// La config de wawa (`$XDG_CONFIG_HOME/wawa/config.json`) cambió; - /// rearmamos el theme, accent y locale sin reiniciar. Boxed por - /// tamaño (la config tiene un BTreeMap de módulos). - WawaConfigChanged(Box), - - /// Barra de menú principal: abrir/cerrar un menú raíz (`None` = cerrar). - MenuOpen(Option), - /// Navegación de teclado en el dropdown del menú principal (±1 fila). - MenuNav(i32), - /// Enter sobre la fila activa del menú principal. - MenuActivate, - /// Tick de re-render para la animación de aparición del dropdown. - MenuTick, - /// Comando elegido en el menú principal o contextual — se traduce al - /// `Msg`/acción real del chasis o del módulo shell focado. - MenuCommand(String), - /// Right-click sobre el área de trabajo → abre el menú contextual de - /// terminal en `(x, y)` de ventana. - ContextMenuOpen(f32, f32), - /// Cierra cualquier menú abierto (click-fuera / Esc). - CloseMenus, - - /// Rail hospedado de pata: el usuario activó un diente. `id < tabs.len()` - /// selecciona esa tab; `MONITORS_TOOTH` togglea el panel de monitores. - HostActivate(u32), -} - -struct Shell; - -impl App for Shell { - type Model = Model; - type Msg = Msg; - - fn title() -> &'static str { - "shuma" - } - - fn app_id() -> Option<&'static str> { - Some("shuma.shell") - } - - fn initial_size() -> (u32, u32) { - (1280, 800) - } - - fn init(handle: &Handle) -> Self::Model { - handle.spawn_periodic(TICK, || Msg::Tick); - handle.spawn_periodic(SHELL_TICK, || Msg::ShellTick); - - // wawa-config (bus de preferencias del SO) — theme/accent/lang. - // Lo cargamos antes de armar las instancias para que el primer - // render ya tenga el theme correcto. El watcher avisa cambios - // posteriores con `Msg::WawaConfigChanged`. - let wawa = wawa_config::WawaConfig::load(); - let theme = wawa_config_llimphi::theme_from_wawa(&wawa, &Theme::dark()); - let _ = rimay_localize::set_locale(&wawa.lang); - let wawa_watcher = { - let handle = handle.clone(); - wawa_config::ConfigWatcher::spawn(move |cfg| { - handle.dispatch(Msg::WawaConfigChanged(Box::new(cfg))); - }) - .ok() - }; - - let cfg = config::ShumaConfig::load_default(); - let topbar = resolve_slot(cfg.topbar.as_ref()).or_else(|| { - Some(Instance::launcher( - shuma_module_launcher::State::from_apps_dir(), - )) - }); - let bottombar = resolve_slot(cfg.bottombar.as_ref()).or_else(|| { - Some(Instance::command_bar( - shuma_module_commandbar::State::default(), - )) - }); - let main = resolve_slot(cfg.main.as_ref()); - - let tabs = if cfg.tabs.is_empty() { - // Default cuando no hay `[[tabs]]`: shell + lienzo + matilda - // locales para que el chasis sea exploratorio desde el día - // uno sin que el usuario tenga que escribir un shumarc. El - // lienzo se mantiene en sync con el grafo del shell cada - // `SHELL_TICK` (~100 ms). - vec![ - Instance::shell(rimay_localize::t("shuma-label-shell"), default_shell_source()), - Instance::canvas(rimay_localize::t("shuma-label-canvas")), - Instance::matilda(rimay_localize::t("shuma-label-matilda"), Source::Local), - ] +/// Une `args` en una línea de shell, citando con comillas simples los que +/// tengan espacios o caracteres especiales (escapando comillas simples +/// internas con el truco `'\''`). Así `-e prog --flag "un arg"` se reconstruye +/// como una línea ejecutable sin romperse por los espacios. +fn shell_join(args: &[String]) -> String { + fn quote(a: &str) -> String { + if !a.is_empty() + && a.bytes() + .all(|b| b.is_ascii_alphanumeric() || b"-_./=:@%+".contains(&b)) + { + a.to_string() } else { - cfg.tabs.iter().filter_map(resolve_tab).collect() - }; - - // Rail hospedado: si `SHUMA_DELEGATE_SIDEBAR` está set, prestamos las - // tabs + el toggle de monitores al rail de pata. Se conecta acá, una - // vez armadas las tabs, para publicar sus etiquetas como dientes. - let host = shuma_host(handle, &tabs); - // Sin delegar el panel siempre se ve; delegado arranca oculto (puro - // lienzo) y el rail de pata lo despliega. - let monitors_visible = host.is_none(); - - Model { - theme, - topbar, - bottombar, - main, - tabs, - active_tab: 0, - sysmon: SystemSampler::new(HISTORY), - last_snapshot: None, - monitors_width: MONITORS_INITIAL_WIDTH, - extra_history: HashMap::new(), - extra_display: HashMap::new(), - _wawa_watcher: wawa_watcher, - menu_open: None, - menu_active: usize::MAX, - menu_anim: Tween::idle(1.0), - ctx_menu: None, - _host: host, - monitors_visible, + format!("'{}'", a.replace('\'', "'\\''")) } } - - fn on_key(model: &Self::Model, e: &KeyEvent) -> Option { - if e.state != KeyState::Pressed { - return None; - } - // Con un menú abierto, Esc lo cierra y se come la tecla (no va al - // shell). El resto de teclas siguen su curso normal. - if let Some(msg) = menu::intercept_key(model, e) { - return Some(msg); - } - // Reenvía teclas al módulo focado. Hoy sólo el shell consume - // teclas (input del REPL); el resto de módulos siguen sin - // recibirlas hasta que las necesiten. - forward_key_to_focused_shell(model, e) - } - - fn on_wheel( - model: &Self::Model, - delta: WheelDelta, - _cursor: (f32, f32), - _modifiers: Modifiers, - ) -> Option { - // `delta.y` viene en líneas (positivo = hacia abajo). El scroll - // del shell mide px desde el fondo, donde positivo = ver - // historial, así que invertimos y escalamos a ~40 px por línea. - let dpx = -delta.y * 40.0; - if dpx == 0.0 { - return None; - } - forward_wheel_to_focused_shell(model, dpx) - } - - fn update(model: Self::Model, msg: Self::Msg, handle: &Handle) -> Self::Model { - let mut m = model; - match msg { - Msg::Tick => { - m.last_snapshot = Some(m.sysmon.sample()); - sample_extra_monitors(&mut m); - } - Msg::ShellTick => { - drain_shell_instances(&mut m); - } - Msg::WawaConfigChanged(cfg) => { - // Re-armar el theme con el nuevo variant + accent. El - // fallback es el theme actual — si la nueva config tiene - // un variant raro, conservamos lo de antes. - m.theme = wawa_config_llimphi::theme_from_wawa(&cfg, &m.theme); - // Locale activo — `set_locale` es no-op si el lang no - // está en el catálogo; los próximos `t(...)` ya devuelven - // strings en el nuevo idioma sin necesidad de reiniciar - // (los labels in-memory siguen siendo viejos hasta que - // el módulo correspondiente vuelva a rehidratarlos, - // pero todo lo que se calcula en cada `view()` se - // refresca al instante). - let _ = rimay_localize::set_locale(&cfg.lang); - } - Msg::SelectTab(i) => { - if i < m.tabs.len() { - m.active_tab = i; - } - } - Msg::ResizeMonitors(dx) => { - m.monitors_width = (m.monitors_width - dx).clamp(180.0, 480.0); - } - Msg::Module(slot, mmsg) => { - // Hook: SelectRoot del módulo minga dispara la carga - // de la fuente reconstruida en un thread aparte. El - // mensaje se sigue propagando para que el state marque - // `selected = Some(alpha)` y `selected_source = None` - // mientras carga. - if let ModuleMsg::Minga(shuma_module_minga::Msg::SelectRoot(alpha)) = &mmsg { - if let Some(repo_path) = minga_repo_path(&slot, &m) { - let alpha = *alpha; - let slot_back = slot.clone(); - handle.spawn(move || { - let result = shuma_module_minga::load_root_source(&repo_path, alpha); - Msg::Module( - slot_back, - ModuleMsg::Minga(shuma_module_minga::Msg::SourceLoaded { - alpha, - result, - }), - ) - }); - } - } - m = apply_module_msg(m, slot, mmsg); - } - Msg::ShortcutClicked(slot, action) => { - m = handle_shortcut(m, slot, action, handle); - } - Msg::MenuOpen(idx) => { - m.menu_open = idx; - m.menu_active = usize::MAX; - // Abrir el menú principal cierra el contextual (y viceversa). - m.ctx_menu = None; - // Animación de aparición/swap: cada vez que se abre (o se - // cambia de) menú, el dropdown se funde+desliza de nuevo. - if idx.is_some() { - m.menu_anim = Tween::new(0.0, 1.0, motion::FAST, motion::ease_out_cubic); - animate(handle, motion::FAST, || Msg::MenuTick); - } - } - Msg::MenuNav(dir) => { - if let Some(mi) = m.menu_open { - let menu = menu::app_menu(&m); - m.menu_active = - llimphi_widget_menubar::menubar_nav(&menu, mi, m.menu_active, dir); - } - } - Msg::MenuActivate => { - if let Some(mi) = m.menu_open { - let menu = menu::app_menu(&m); - if let Some(cmd) = - llimphi_widget_menubar::menubar_command_at(&menu, mi, m.menu_active) - { - m = menu::handle_command(m, &cmd); - } - } - } - Msg::MenuTick => {} - Msg::ContextMenuOpen(x, y) => { - m.ctx_menu = Some((x, y)); - m.menu_open = None; - m.menu_active = usize::MAX; - } - Msg::CloseMenus => { - m.menu_open = None; - m.menu_active = usize::MAX; - m.ctx_menu = None; - } - Msg::MenuCommand(cmd) => { - m = menu::handle_command(m, &cmd); - } - Msg::HostActivate(id) => { - // Rail hospedado: un diente de tab selecciona esa tab; el - // diente sentinela togglea el panel de monitores. - if id == MONITORS_TOOTH { - m.monitors_visible = !m.monitors_visible; - } else if (id as usize) < m.tabs.len() { - m.active_tab = id as usize; - } - } - } - m - } - - fn view(model: &Self::Model) -> View { - let theme = &model.theme; - - let menubar = menu::menubar_row(model, theme); - let topbar = render_topbar(model, theme); - let main_area = render_main_area(model, theme); - let bottombar = render_bottombar(model, theme); - - // El right-click se engancha en la raíz (origen 0,0 → las coords - // locales que llegan al handler ya son de ventana) y abre el menú - // contextual de terminal. Un nodo hijo con su propio handler de - // right-click ganaría; hoy ninguno lo pone, así que la raíz es el - // catch-all. - View::new(Style { - flex_direction: FlexDirection::Column, - size: Size { - width: percent(1.0_f32), - height: percent(1.0_f32), - }, - ..Default::default() - }) - .fill(theme.bg_app) - .on_right_click_at(|x, y, _w, _h| Some(Msg::ContextMenuOpen(x, y))) - .children(vec![menubar, topbar, main_area, bottombar]) - } - - fn view_overlay(model: &Self::Model) -> Option> { - menu::overlay(model) - } + args.iter().map(|a| quote(a)).collect::>().join(" ") } - -// Helpers partidos del monolito (regla dura #1, 1522 LOC): update + view. -mod menu; -mod update; -mod view; - -use update::*; -use view::*; diff --git a/02_ruway/shuma/shuma-shell-llimphi/src/menu.rs b/02_ruway/shuma/shuma-shell-llimphi/src/menu.rs index 6cce688..2be4890 100644 --- a/02_ruway/shuma/shuma-shell-llimphi/src/menu.rs +++ b/02_ruway/shuma/shuma-shell-llimphi/src/menu.rs @@ -22,7 +22,7 @@ use llimphi_widget_menubar::{ menubar_overlay_animated, menubar_view, MenuBarSpec, DEFAULT_HEIGHT as MENU_H, }; -use super::{Model, Msg, ModuleMsg, ModuleState, Slot}; +use super::{Model, Msg, ModuleMsg, ModuleState, Slot, Which}; // ─── Estado del shell focado (lo que habilita/deshabilita el menú) ── @@ -54,8 +54,13 @@ pub(crate) fn focused_shell(model: &Model) -> Option { return Some(info); } } - if let Some(inst) = model.tabs.get(model.active_tab) { - if let Some(info) = from(Slot::Tab(model.active_tab), &inst.state) { + // El shell de la sesión activa es el canvas principal → siempre recibe + // teclas (a menos que un menú las intercepte). + if let Some(s) = model.active() { + if let Some(info) = from( + Slot::Session(model.active_session, Which::Shell), + &s.shell().state, + ) { return Some(info); } } @@ -76,7 +81,15 @@ pub(crate) fn app_menu(model: &Model) -> AppMenu { let t = rimay_localize::t; // Archivo: lo único universal y honesto es salir del proceso. + // «Endockar» (ventana → barra) o «Modo ventana» (barra → ventana) según el + // modo actual. Re-lanza el binario en el modo opuesto (ver `respawn_mode`). + let dock_label = if model.dock_mode { + "Modo ventana" + } else { + "Endockar" + }; let archivo = Menu::new(t("file")) + .item(MenuItem::new(dock_label, "window.toggle-dock").separated()) .item(MenuItem::new(t("exit"), "app.quit").shortcut("Ctrl+Q")); // Editar: opera sobre la línea de comando del shell focado. Sin @@ -101,14 +114,17 @@ pub(crate) fn app_menu(model: &Model) -> AppMenu { cancelar = cancelar.disabled(); } let mut ver = Menu::new(t("view")).item(limpiar_pant).item(cancelar); - // Una entrada por tab para saltar directo (mapea a `Msg::SelectTab`). - for (i, inst) in model.tabs.iter().enumerate() { - let mut it = MenuItem::new(inst.label.clone(), format!("view.tab.{i}")); + // Disposiciones guardadas (estilo sesiones de tmux): guardar/restaurar el + // espacio de trabajo entero. Siempre disponible (no depende del shell). + ver = ver.item(MenuItem::new(t("shuma-layouts"), "view.layouts").separated()); + // Una entrada por sesión para saltar directo (mapea a `Msg::SelectSession`). + for (i, s) in model.sessions.iter().enumerate() { + let mut it = MenuItem::new(s.name.clone(), format!("view.session.{i}")); if i == 0 { it = it.separated(); } - if i == model.active_tab { - it = it.disabled(); // ya estás acá + if i == model.active_session { + it = it.disabled(); // ya estás aquí } ver = ver.item(it); } @@ -141,10 +157,77 @@ pub(crate) fn app_menu(model: &Model) -> AppMenu { .menu(archivo) .menu(editar) .menu(ver) + .menu(perfiles_menu(model)) .menu(ayuda) .menu(idioma) } +/// El menú **Perfiles**: tres bloques conmutables con un clic — atajos +/// (globales), apariencia (global + de la sesión actual) y perfiles de sesión +/// (contextos tipo Firefox). El activo lleva ●. +fn perfiles_menu(model: &Model) -> Menu { + const DOT: &str = "\u{25CF}"; // ● + let header = |label: &str| MenuItem::new(label, "noop").disabled().separated(); + let mut menu = Menu::new("Perfiles"); + + // Gestor (crear/duplicar/renombrar/borrar). + menu = menu.item(MenuItem::new("Gestionar perfiles…", "prof.manage")); + + // ── Atajos (global) ── + menu = menu.item(header("Atajos")); + let sk_active = model.shortcuts.active().to_string(); + for name in model.shortcuts.names() { + let mut it = MenuItem::new(name.clone(), format!("prof.sk.{name}")); + if name == sk_active { + it = it.icon(DOT); + } + menu = menu.item(it); + } + + // ── Apariencia (default global) ── + menu = menu.item(header("Apariencia (global)")); + let ap_active = model.appearance.active().to_string(); + for name in model.appearance.names() { + let mut it = MenuItem::new(name.clone(), format!("prof.ap.{name}")); + if name == ap_active { + it = it.icon(DOT); + } + menu = menu.item(it); + } + + // ── Apariencia de ESTA sesión (la "ventana") ── + menu = menu.item(header("Apariencia (esta sesión)")); + let sess_ap = model + .active() + .and_then(|s| s.appearance.clone()); + let mut como_global = MenuItem::new("Como el global", "prof.aps.~"); + if sess_ap.is_none() { + como_global = como_global.icon(DOT); + } + menu = menu.item(como_global); + for name in model.appearance.names() { + let mut it = MenuItem::new(name.clone(), format!("prof.aps.{name}")); + if sess_ap.as_deref() == Some(name.as_str()) { + it = it.icon(DOT); + } + menu = menu.item(it); + } + + // ── Sesión (contextos tipo Firefox) ── + menu = menu.item(header("Sesión / contexto")); + let se_active = model.session_profiles.active().to_string(); + for name in model.session_profiles.names() { + let mut it = MenuItem::new(name.clone(), format!("prof.sess.{name}")); + if *name == se_active { + it = it.icon(DOT); + } + menu = menu.item(it); + } + menu = menu.item(MenuItem::new("Nuevo contexto", "prof.sess.new").separated()); + + menu +} + /// `MenuBarSpec` compartido por `menubar_view` y `menubar_overlay`. pub(crate) fn menubar_spec<'a>( menu: &'a AppMenu, @@ -155,7 +238,7 @@ pub(crate) fn menubar_spec<'a>( menu, open: model.menu_open, theme, - viewport: viewport(), + viewport: model.overlay_viewport(), height: MENU_H, on_open: Arc::new(Msg::MenuOpen), on_command: Arc::new(|c: &str| Msg::MenuCommand(c.to_string())), @@ -173,6 +256,9 @@ pub(crate) fn menubar_row(model: &Model, theme: &Theme) -> View { /// Construye el overlay a mostrar: prioriza el menú contextual de /// terminal; si no, el dropdown del menú principal abierto. pub(crate) fn overlay(model: &Model) -> Option> { + if let Some((i, x, y)) = model.tab_ctx { + return Some(tab_context_menu(model, i, x, y)); + } if let Some((x, y)) = model.ctx_menu { return Some(terminal_context_menu(model, x, y)); } @@ -231,7 +317,7 @@ fn terminal_context_menu(model: &Model, x: f32, y: f32) -> View { context_menu_view(ContextMenuSpec { anchor: (x, y), - viewport: viewport(), + viewport: model.overlay_viewport(), header: Some(rimay_localize::t("terminal")), items, active: usize::MAX, @@ -241,6 +327,129 @@ fn terminal_context_menu(model: &Model, x: f32, y: f32) -> View { }) } +/// Una fila del menú contextual de tab: rótulo + atajo + acción, más si está +/// habilitada. `None` como acción = separador. +/// +/// La tabla existe para que el rótulo y el `Msg` **no puedan desincronizarse**: +/// antes el menú era un `Vec` de ítems y un `match` sobre índices numéricos en +/// paralelo, así que insertar una fila en el medio corría todas las acciones sin +/// que nada lo notara. Con esto, `on_pick` indexa la MISMA tabla que se pinta — +/// y el test `cada_fila_del_menu_de_tab_tiene_su_accion` la recorre entera. +struct FilaTab { + rotulo: &'static str, + atajo: Option<&'static str>, + accion: Option, + habilitada: bool, +} + +impl FilaTab { + fn nueva(rotulo: &'static str, accion: Msg) -> Self { + Self { rotulo, atajo: None, accion: Some(accion), habilitada: true } + } + fn con_atajo(mut self, a: &'static str) -> Self { + self.atajo = Some(a); + self + } + fn habilitada_si(mut self, c: bool) -> Self { + self.habilitada = c; + self + } + fn separador() -> Self { + Self { rotulo: "", atajo: None, accion: None, habilitada: false } + } +} + +/// Las filas del menú contextual de la tab `i` de `n_tabs`. **Pura** (no toma el +/// `Model`) para poder certificar rótulos, acciones y habilitaciones sin montar +/// nada ni construir un modelo (Regla 8). +fn filas_menu_tab(n_tabs: usize, i: usize) -> Vec { + let varias = n_tabs > 1; + let hay_derecha = i + 1 < n_tabs; + vec![ + FilaTab::nueva("Nueva tab", Msg::TabNew).con_atajo("Ctrl+Shift+T"), + FilaTab::nueva("Duplicar tab", Msg::TabDuplicate(i)), + FilaTab::separador(), + FilaTab::nueva("Renombrar…", Msg::TabRenameOpen(i)), + FilaTab::separador(), + // Mover: sólo hacia donde haya lugar. + FilaTab::nueva("Mover a la izquierda", Msg::TabMove(i, false)).habilitada_si(i > 0), + FilaTab::nueva("Mover a la derecha", Msg::TabMove(i, true)).habilitada_si(hay_derecha), + FilaTab::separador(), + // Dividir el panel de ESTA tab: primero la activamos (el split va al + // panel con foco de la tab activa), y el `on_pick` manda los dos Msg. + FilaTab::nueva("Dividir ⇅", Msg::PaneSplit(llimphi_widget_panes::Axis::Vertical)), + FilaTab::nueva("Dividir ⇆", Msg::PaneSplit(llimphi_widget_panes::Axis::Horizontal)), + FilaTab::separador(), + FilaTab::nueva("Cerrar tab", Msg::TabClose(i)) + .con_atajo("Ctrl+Shift+W") + .habilitada_si(varias), + FilaTab::nueva("Cerrar las de la derecha", Msg::TabCloseRight(i)) + .habilitada_si(hay_derecha), + FilaTab::nueva("Cerrar otras", Msg::TabCloseOthers(i)).habilitada_si(varias), + ] +} + +/// El `Msg` que dispara elegir la fila `k` del menú de la tab `i`. Una fila +/// deshabilitada o un separador no hacen nada (cierran el menú). Las que operan +/// sobre el panel con foco —dividir— se envuelven en `TabSwitchThen`: sin +/// activar primero, el split le caía a la tab que estabas mirando, no a la que +/// clickeaste. Es **la misma tabla** que se pinta, indexada igual. +fn accion_de_fila(filas: &[FilaTab], k: usize, i: usize) -> Msg { + let Some(f) = filas.get(k) else { return Msg::CloseMenus }; + let Some(msg) = f.accion.clone().filter(|_| f.habilitada) else { + return Msg::CloseMenus; + }; + if matches!(msg, Msg::PaneSplit(_)) { + Msg::TabSwitchThen(i, Box::new(msg)) + } else { + msg + } +} + +/// Menú contextual de una **tab** (click derecho sobre el chip): operaciones de +/// tab sin la ✕ riesgosa. `i` es el índice de la tab clickeada. +fn tab_context_menu(model: &Model, i: usize, x: f32, y: f32) -> View { + let n_tabs = model.active().map(|s| s.workspace.tabs.len()).unwrap_or(1); + let filas = filas_menu_tab(n_tabs, i); + let items = filas + .iter() + .map(|f| match &f.accion { + None => ContextMenuItem::separator(), + Some(_) => { + let mut it = ContextMenuItem::action(f.rotulo); + if let Some(a) = f.atajo { + it = it.with_shortcut(a); + } + if !f.habilitada { + it = it.disabled(); + } + it + } + }) + .collect::>(); + + // El `on_pick` resuelve contra la MISMA tabla que se pintó. + let acciones: Vec = (0..filas.len()).map(|k| accion_de_fila(&filas, k, i)).collect(); + let on_pick: Arc Msg + Send + Sync> = + Arc::new(move |k: usize| acciones.get(k).cloned().unwrap_or(Msg::CloseMenus)); + + let titulo = model + .active() + .map(|s| s.workspace.titulo_de(i)) + .filter(|t| !t.trim().is_empty()) + .unwrap_or_else(|| format!("Tab {}", i + 1)); + context_menu_view(ContextMenuSpec { + anchor: (x, y), + viewport: model.overlay_viewport(), + header: Some(titulo), + items, + active: usize::MAX, + on_pick, + on_dismiss: Msg::CloseMenus, + palette: ContextMenuPalette::from_theme(&model.theme), + }) +} + // ─── Ruteo de comandos del menú a Msg/acciones reales ────────────── /// Traduce el `command` string de un ítem de menú a una transición del @@ -262,11 +471,72 @@ pub(crate) fn handle_command(mut model: Model, cmd: &str) -> Model { return model; } - // Selector de tab: "view.tab.". - if let Some(rest) = cmd.strip_prefix("view.tab.") { + // ── Perfiles ── + // Abre el modal de gestión (crear/duplicar/renombrar/borrar). + if cmd == "prof.manage" { + model.perfiles_modal_open = true; + model.prof_name_focused = true; + return model; + } + // Atajos (global): conmuta el keymap activo y lo persiste. + if let Some(name) = cmd.strip_prefix("prof.sk.") { + if model.shortcuts.set_active(name).is_ok() { + model.pending_prefix = false; + if let Some(p) = crate::perfiles::shortcuts::ShortcutProfiles::default_path() { + let _ = model.shortcuts.save(&p); + } + } + return model; + } + // Apariencia de la sesión activa (la "ventana"): "~" = como el global. + if let Some(name) = cmd.strip_prefix("prof.aps.") { + let val = if name == "~" { None } else { Some(name.to_string()) }; + if let Some(s) = model.sessions.get_mut(model.active_session) { + s.appearance = val; + } + super::persist::save_sessions(&model); + crate::perfiles::apply_active_appearance(&mut model); + return model; + } + // Apariencia global (default de toda ventana). + if let Some(name) = cmd.strip_prefix("prof.ap.") { + if model.appearance.set_active(name).is_ok() { + if let Some(p) = crate::perfiles::appearance::AppearanceProfiles::default_path() { + let _ = model.appearance.save(&p); + } + crate::perfiles::apply_active_appearance(&mut model); + } + return model; + } + // Perfil de sesión (contexto tipo Firefox). + if cmd == "prof.sess.new" { + // Auto-nombre "contexto N" libre. + let mut n = model.session_profiles.names().len(); + let name = loop { + let candidate = format!("contexto {n}"); + if !model.session_profiles.contains(&candidate) { + break candidate; + } + n += 1; + }; + if model.session_profiles.create(&name).is_ok() { + if let Some(p) = crate::perfiles::sessions::SessionProfiles::default_path() { + let _ = model.session_profiles.save(&p); + } + model = super::switch_session_profile(model, &name); + } + return model; + } + if let Some(name) = cmd.strip_prefix("prof.sess.") { + model = super::switch_session_profile(model, name); + return model; + } + + // Selector de sesión: "view.session.". + if let Some(rest) = cmd.strip_prefix("view.session.") { if let Ok(i) = rest.parse::() { - if i < model.tabs.len() { - model.active_tab = i; + if i < model.sessions.len() { + model.active_session = i; } } return model; @@ -276,6 +546,12 @@ pub(crate) fn handle_command(mut model: Model, cmd: &str) -> Model { "app.quit" => { std::process::exit(0); } + "window.toggle-dock" => { + // Re-lanza en el modo opuesto y cierra esta instancia. La sesión no + // se migra viva (arranca limpia) — ver `respawn_mode`. + crate::respawn_mode(!model.dock_mode); + std::process::exit(0); + } "edit.paste" => route_to_shell(model, shell_paste_key()), "edit.clear-input" => { if let Some(focus) = focused_shell(&model) { @@ -283,10 +559,18 @@ pub(crate) fn handle_command(mut model: Model, cmd: &str) -> Model { } model } + "view.layouts" => { + model.layouts_modal_open = true; + model.layout_name_focused = true; // listo para tipear el nombre + model + } "term.clear" => route_to_shell(model, ModuleMsg::Shell(shuma_module_shell::Msg::Clear)), "term.cancel" => route_to_shell(model, ModuleMsg::Shell(shuma_module_shell::Msg::Cancel)), "help.about" => { - let line = format!("# shuma — shell soberano · {} tabs", model.tabs.len()); + let line = format!( + "# shuma — shell soberano · {} sesiones", + model.sessions.len() + ); route_to_shell( model, ModuleMsg::Shell(shuma_module_shell::Msg::InsertAtCursor(line)), @@ -308,11 +592,7 @@ fn route_to_shell(model: Model, msg: ModuleMsg) -> Model { /// Vacía la línea de comando del shell en `slot` mutando su `LineState` /// directamente (no hay un `Msg` de "limpiar entrada" en el módulo). fn clear_input(model: &mut Model, slot: &Slot) { - let inst = match slot { - Slot::Main => model.main.as_mut(), - Slot::Tab(i) => model.tabs.get_mut(*i), - _ => None, - }; + let inst = super::instance_for_slot_mut(model, slot); if let Some(inst) = inst { if let ModuleState::Shell(s) = &mut inst.state { s.input.clear(); @@ -361,9 +641,123 @@ pub(crate) fn intercept_key(model: &Model, e: &KeyEvent) -> Option { None } -/// Viewport para clampear los menús — shuma no trackea el tamaño de la -/// ventana, así que usamos el tamaño inicial (igual que `nada`). -fn viewport() -> (f32, f32) { - let (w, h) = ::initial_size(); - (w as f32, h as f32) +// El viewport para clampear los menús sale de [`Model::overlay_viewport`]. Antes +// se derivaba de `App::initial_size()` — un tamaño FIJO que ignoraba tanto el +// redimensionado de la ventana como el hospedaje en el drawer de pata, así que +// los menús se volteaban contra una caja que no era la de la pantalla. + +#[cfg(test)] +mod tests_menu_tab { + use super::*; + + /// Índice de la fila cuyo rótulo es `rotulo` (pánico si no está: el test + /// quiere fallar ruidosamente si alguien renombra una acción). + fn idx(filas: &[FilaTab], rotulo: &str) -> usize { + filas + .iter() + .position(|f| f.rotulo == rotulo) + .unwrap_or_else(|| panic!("no está la fila «{rotulo}»")) + } + + /// Toda fila que no es separador tiene rótulo y acción — nada de entradas + /// muertas que se pintan y no hacen nada. + #[test] + fn cada_fila_del_menu_de_tab_tiene_su_accion() { + for n in 1..=4 { + for i in 0..n { + for f in filas_menu_tab(n, i) { + if f.accion.is_none() { + assert_eq!(f.rotulo, "", "un separador no lleva rótulo"); + continue; + } + assert!(!f.rotulo.is_empty(), "fila sin rótulo con acción"); + } + } + } + } + + /// Cada fila habilitada dispara SU acción, sobre la tab del menú y con los + /// argumentos correctos. Es lo que se rompía cuando el menú era una lista y + /// un `match` sobre índices numéricos en paralelo: insertar una fila en el + /// medio corría todas las acciones sin que nada lo notara. `Msg` no es + /// `Debug`/`PartialEq`, así que se comprueba con `matches!` — a propósito + /// atado a los argumentos (`TabMove(1, true)`, no `TabMove(..)`). + #[test] + fn cada_fila_dispara_su_propia_accion() { + let f = filas_menu_tab(4, 1); + let a = |rotulo: &str| accion_de_fila(&f, idx(&f, rotulo), 1); + assert!(matches!(a("Nueva tab"), Msg::TabNew)); + assert!(matches!(a("Duplicar tab"), Msg::TabDuplicate(1))); + assert!(matches!(a("Renombrar…"), Msg::TabRenameOpen(1))); + assert!(matches!(a("Mover a la izquierda"), Msg::TabMove(1, false))); + assert!(matches!(a("Mover a la derecha"), Msg::TabMove(1, true))); + assert!(matches!(a("Cerrar tab"), Msg::TabClose(1))); + assert!(matches!(a("Cerrar las de la derecha"), Msg::TabCloseRight(1))); + assert!(matches!(a("Cerrar otras"), Msg::TabCloseOthers(1))); + // Y las de cerrar apuntan a la tab del MENÚ, no a la activa: con el + // índice equivocado, «Cerrar tab» te cerraría otra pestaña. + let g = filas_menu_tab(4, 3); + assert!(matches!(accion_de_fila(&g, idx(&g, "Cerrar tab"), 3), Msg::TabClose(3))); + } + + /// Dividir opera sobre el panel con foco: primero hay que ACTIVAR la tab + /// clickeada, o el split le cae a la que estabas mirando. + #[test] + fn dividir_activa_la_tab_primero() { + let filas = filas_menu_tab(3, 2); + for rotulo in ["Dividir ⇅", "Dividir ⇆"] { + let k = idx(&filas, rotulo); + match accion_de_fila(&filas, k, 2) { + Msg::TabSwitchThen(i, siguiente) => { + assert_eq!(i, 2, "activa la tab del menú"); + assert!(matches!(*siguiente, Msg::PaneSplit(_)), "y después divide"); + } + _ => panic!("«{rotulo}» debería activar la tab antes de dividir"), + } + } + } + + /// Con una sola pestaña no hay nada que cerrar ni a dónde moverla; y una + /// fila deshabilitada NO dispara su acción aunque se la elija (el widget + /// no debería dejar, pero el mapeo no puede depender de eso). + #[test] + fn con_una_sola_tab_no_se_cierra_ni_se_mueve() { + let filas = filas_menu_tab(1, 0); + for rotulo in [ + "Cerrar tab", + "Cerrar otras", + "Cerrar las de la derecha", + "Mover a la izquierda", + "Mover a la derecha", + ] { + let k = idx(&filas, rotulo); + assert!(!filas[k].habilitada, "«{rotulo}» no puede estar habilitada"); + assert!( + matches!(accion_de_fila(&filas, k, 0), Msg::CloseMenus), + "«{rotulo}» deshabilitada no puede disparar nada" + ); + } + // Lo que sí aplica con una sola pestaña. + for rotulo in ["Nueva tab", "Duplicar tab", "Renombrar…"] { + assert!(filas[idx(&filas, rotulo)].habilitada, "«{rotulo}» debería aplicar"); + } + } + + /// Los extremos: la primera no se mueve a la izquierda, la última no se + /// mueve a la derecha ni tiene «las de la derecha» que cerrar. + #[test] + fn los_extremos_deshabilitan_lo_que_no_aplica() { + let primera = filas_menu_tab(4, 0); + assert!(!primera[idx(&primera, "Mover a la izquierda")].habilitada); + assert!(primera[idx(&primera, "Mover a la derecha")].habilitada); + assert!(primera[idx(&primera, "Cerrar las de la derecha")].habilitada); + + let ultima = filas_menu_tab(4, 3); + assert!(ultima[idx(&ultima, "Mover a la izquierda")].habilitada); + assert!(!ultima[idx(&ultima, "Mover a la derecha")].habilitada); + assert!( + !ultima[idx(&ultima, "Cerrar las de la derecha")].habilitada, + "no hay nada a la derecha de la última" + ); + } } diff --git a/02_ruway/shuma/shuma-shell-llimphi/src/perfiles/appearance.rs b/02_ruway/shuma/shuma-shell-llimphi/src/perfiles/appearance.rs new file mode 100644 index 0000000..faa37e9 --- /dev/null +++ b/02_ruway/shuma/shuma-shell-llimphi/src/perfiles/appearance.rs @@ -0,0 +1,516 @@ +//! Perfiles de **apariencia** — coloración estilo konsole conmutable. +//! +//! Cada perfil es una foto de aspecto: tema base (preset de `llimphi-theme`), +//! override de acento, zoom de fuente, opacidad de fondo (transparencia) y +//! wallpaper opcional. Se aplican **por ventana de shuma**: hay un activo +//! global (el default de toda ventana nueva) y cada **sesión** puede fijar el +//! suyo ([`crate::types::SessionConfig::appearance`]), que gana cuando esa +//! sesión está activa. +//! +//! El perfil especial **`Sistema`** sigue el tema de `wawa-config` (el +//! comportamiento histórico): mientras esté activo, los cambios de tema del +//! sistema se propagan a shuma. Cualquier otro perfil fija el aspecto y deja de +//! seguir a wawa. +//! +//! Persistencia: `~/.config/shuma/appearance.ron`. + +use std::collections::BTreeMap; +use std::path::{Path, PathBuf}; + +use llimphi_theme::{Color, Theme}; +use serde::{Deserialize, Serialize}; + +/// El nombre del perfil que sigue el tema del sistema (wawa). +pub const SYSTEM_NAME: &str = "Sistema"; + +/// Una foto de apariencia. +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +pub struct Appearance { + /// Nombre del preset de `llimphi-theme` (`Theme::by_name`). Para `Sistema` + /// se ignora (se usa el tema de wawa). + pub theme: String, + /// Override de acento RGBA; `None` deja el del tema. + #[serde(default)] + pub accent: Option<[u8; 4]>, + /// Zoom de fuente por defecto de los shells de esta apariencia. + #[serde(default = "one")] + pub font_zoom: f32, + /// Opacidad del fondo de ventana (0.0 transparente … 1.0 opaco). + #[serde(default = "one")] + pub opacity: f32, + /// Ruta a una imagen de wallpaper, opcional. + #[serde(default)] + pub wallpaper: Option, + /// Fondo **procedural** propio de shuma: slug de un `mirada_procedural::Pattern` + /// (hoy sólo `"parpados"`). `None` = sin fondo procedural. Si además hay una + /// imagen en `wallpaper`, la imagen gana. Se anima mucho más lento y tenue que + /// en el compositor, para vivir en el fondo sin estorbar la lectura. + #[serde(default)] + pub background: Option, +} + +fn one() -> f32 { + 1.0 +} + +impl Default for Appearance { + fn default() -> Self { + Self { + theme: "Dark".to_string(), + accent: None, + font_zoom: 1.0, + opacity: 1.0, + wallpaper: None, + background: None, + } + } +} + +impl Appearance { + /// El patrón procedural de esta apariencia, si `background` nombra uno + /// conocido de `mirada_procedural`. `None` si no hay o el slug es inválido. + pub fn background_pattern(&self) -> Option { + self.background + .as_deref() + .map(str::trim) + .filter(|s| !s.is_empty()) + .and_then(mirada_procedural::Pattern::from_slug) + } + + /// Resuelve esta apariencia a un [`Theme`] concreto: parte del preset + /// nombrado, aplica el acento y la opacidad de fondo. Para `Sistema` use + /// [`super::apply_active_appearance`] (necesita wawa); aquí cae a `Dark`. + pub fn resolve(&self) -> Theme { + let mut t = Theme::by_name(&self.theme).unwrap_or_else(Theme::dark); + if let Some([r, g, b, a]) = self.accent { + t.accent = Color::from_rgba8(r, g, b, a); + t.border_focus = Color::from_rgba8(r, g, b, a); + } + // Con wallpaper, el fondo debe dejarlo ver: si el perfil quedó opaco, + // forzamos una translucidez mínima para que la imagen asome. + let has_wp = self + .wallpaper + .as_deref() + .map(|s| !s.trim().is_empty()) + .unwrap_or(false); + let eff_op = if has_wp { self.opacity.min(0.7) } else { self.opacity }; + apply_bg_opacity(&mut t, eff_op); + t + } +} + +/// Aplica una opacidad de fondo a un tema ya resuelto: baja el alfa de los tres +/// fondos (`bg_app`/`bg_panel`/`bg_panel_alt`) dejando el texto intacto. `op >= 1` +/// no hace nada. Lo usan tanto [`Appearance::resolve`] como el perfil `Sistema` +/// (que toma el tema de wawa pero igual honra la transparencia del perfil). +pub fn apply_bg_opacity(t: &mut Theme, op: f32) { + if op < 1.0 { + t.bg_app = with_opacity(t.bg_app, op); + t.bg_panel = with_opacity(t.bg_panel, op); + t.bg_panel_alt = with_opacity(t.bg_panel_alt, op); + } +} + +/// Devuelve `c` con la opacidad pedida (sustituye el canal alfa). +fn with_opacity(c: Color, op: f32) -> Color { + let k = c.components; + Color::from_rgba8( + (k[0] * 255.0).round() as u8, + (k[1] * 255.0).round() as u8, + (k[2] * 255.0).round() as u8, + (op.clamp(0.0, 1.0) * 255.0).round() as u8, + ) +} + +/// Los nombres de los presets de fábrica, en orden de presentación. +pub const PRESET_NAMES: &[&str] = &[ + SYSTEM_NAME, + "Oscuro", + "Claro", + "Tawa", + "Aurora", + "Atardecer", + "Translúcido", +]; + +/// `true` si `name` es un preset de fábrica. +pub fn is_builtin(name: &str) -> bool { + PRESET_NAMES.contains(&name) +} + +/// La apariencia de un preset de fábrica por nombre. `Sistema` devuelve un +/// placeholder (su tema lo provee wawa); el resto mapea a presets de +/// `llimphi-theme` con la opacidad indicada. +pub fn preset(name: &str) -> Option { + let ap = |theme: &str, opacity: f32| Appearance { + theme: theme.to_string(), + accent: None, + font_zoom: 1.0, + opacity, + wallpaper: None, + background: None, + }; + Some(match name { + // El perfil que sigue al sistema arranca **translúcido** y con su propio + // fondo procedural «párpados» por defecto: la transparencia es alta para + // que el fondo asome detrás sin estorbar el texto. La opacidad la aplica + // `resolve_named` sobre el tema de wawa; el patrón lo pinta `app_view`. + SYSTEM_NAME => Appearance { + theme: "Dark".to_string(), + opacity: 0.78, + background: Some("parpados".to_string()), + ..Appearance::default() + }, + "Oscuro" => ap("Dark", 1.0), + "Claro" => ap("Light", 1.0), + "Tawa" => ap("Tawa", 1.0), + "Aurora" => ap("Aurora", 1.0), + "Atardecer" => ap("Sunset", 1.0), + "Translúcido" => ap("Dark", 0.85), + _ => return None, + }) +} + +/// La biblioteca de perfiles de apariencia: el activo (default global) + todos +/// por nombre. +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +pub struct AppearanceProfiles { + active: String, + profiles: BTreeMap, +} + +impl Default for AppearanceProfiles { + fn default() -> Self { + let mut profiles = BTreeMap::new(); + for name in PRESET_NAMES { + if let Some(a) = preset(name) { + profiles.insert((*name).to_string(), a); + } + } + Self { + active: SYSTEM_NAME.to_string(), + profiles, + } + } +} + +impl AppearanceProfiles { + /// El nombre del perfil activo (default global). + pub fn active(&self) -> &str { + &self.active + } + + /// La apariencia de un perfil por nombre. + pub fn get(&self, name: &str) -> Option<&Appearance> { + self.profiles.get(name) + } + + /// La apariencia del activo. + pub fn active_appearance(&self) -> Appearance { + self.profiles + .get(&self.active) + .cloned() + .unwrap_or_default() + } + + /// Los nombres de todos los perfiles, en orden alfabético. + pub fn names(&self) -> Vec { + self.profiles.keys().cloned().collect() + } + + /// `true` si existe un perfil con ese nombre. + pub fn contains(&self, name: &str) -> bool { + self.profiles.contains_key(name) + } + + /// Conmuta el perfil activo (default global). Error si no existe. + pub fn set_active(&mut self, name: &str) -> Result<(), super::shortcuts::ProfileError> { + if self.profiles.contains_key(name) { + self.active = name.to_string(); + Ok(()) + } else { + Err(super::shortcuts::ProfileError::NotFound(name.to_string())) + } + } + + /// Crea un perfil nuevo. Error si ya existe o el nombre es vacío. + pub fn create(&mut self, name: &str, ap: Appearance) -> Result<(), super::shortcuts::ProfileError> { + let name = name.trim(); + if name.is_empty() { + return Err(super::shortcuts::ProfileError::EmptyName); + } + if self.profiles.contains_key(name) { + return Err(super::shortcuts::ProfileError::AlreadyExists(name.to_string())); + } + self.profiles.insert(name.to_string(), ap); + Ok(()) + } + + /// Fija (o quita, con `None`) el wallpaper de un perfil. Funciona también + /// sobre presets (queda un override en disco; `ensure_builtins` no lo pisa). + pub fn set_wallpaper(&mut self, name: &str, path: Option) -> Result<(), super::shortcuts::ProfileError> { + match self.profiles.get_mut(name) { + Some(ap) => { + ap.wallpaper = path.filter(|s| !s.trim().is_empty()); + Ok(()) + } + None => Err(super::shortcuts::ProfileError::NotFound(name.to_string())), + } + } + + /// El wallpaper del perfil activo (si lo tiene). + pub fn active_wallpaper(&self) -> Option { + self.profiles.get(&self.active).and_then(|ap| ap.wallpaper.clone()) + } + + /// Fija (o quita, con `None`) el fondo procedural de un perfil. Funciona + /// también sobre presets (queda un override en disco). El slug se guarda tal + /// cual; `background_pattern` decide si nombra un patrón válido. + pub fn set_background(&mut self, name: &str, slug: Option) -> Result<(), super::shortcuts::ProfileError> { + match self.profiles.get_mut(name) { + Some(ap) => { + ap.background = slug.filter(|s| !s.trim().is_empty()); + Ok(()) + } + None => Err(super::shortcuts::ProfileError::NotFound(name.to_string())), + } + } + + /// Duplica un perfil existente con un nombre nuevo. + pub fn duplicate(&mut self, src: &str, name: &str) -> Result<(), super::shortcuts::ProfileError> { + let ap = self + .profiles + .get(src) + .cloned() + .ok_or_else(|| super::shortcuts::ProfileError::NotFound(src.to_string()))?; + self.create(name, ap) + } + + /// Renombra un perfil propio (los presets no se renombran). Si se renombra + /// el activo, el activo sigue al nombre nuevo. + pub fn rename(&mut self, from: &str, to: &str) -> Result<(), super::shortcuts::ProfileError> { + use super::shortcuts::ProfileError; + let to = to.trim(); + if to.is_empty() { + return Err(ProfileError::EmptyName); + } + if is_builtin(from) { + return Err(ProfileError::BuiltinProtected(from.to_string())); + } + if !self.profiles.contains_key(from) { + return Err(ProfileError::NotFound(from.to_string())); + } + if self.profiles.contains_key(to) { + return Err(ProfileError::AlreadyExists(to.to_string())); + } + let ap = self.profiles.remove(from).expect("recién comprobado"); + self.profiles.insert(to.to_string(), ap); + if self.active == from { + self.active = to.to_string(); + } + Ok(()) + } + + /// Borra un perfil. Los presets de fábrica no se pueden borrar; si se borra + /// el activo, cae a `Sistema`. + pub fn remove(&mut self, name: &str) -> Result<(), super::shortcuts::ProfileError> { + if is_builtin(name) { + return Err(super::shortcuts::ProfileError::BuiltinProtected(name.to_string())); + } + if self.profiles.remove(name).is_none() { + return Err(super::shortcuts::ProfileError::NotFound(name.to_string())); + } + if self.active == name { + self.active = SYSTEM_NAME.to_string(); + } + Ok(()) + } + + fn ensure_builtins(&mut self) { + for name in PRESET_NAMES { + self.profiles + .entry((*name).to_string()) + .or_insert_with(|| preset(name).expect("preset de fábrica")); + } + if !self.profiles.contains_key(&self.active) { + self.active = SYSTEM_NAME.to_string(); + } + } + + // --- Disco -------------------------------------------------------- + + /// La ruta canónica: `~/.config/shuma/appearance.ron`. + pub fn default_path() -> Option { + directories::ProjectDirs::from("", "", "shuma") + .map(|d| d.config_dir().join("appearance.ron")) + } + + fn to_ron(&self) -> String { + ron::ser::to_string_pretty(self, ron::ser::PrettyConfig::default()) + .expect("AppearanceProfiles siempre serializa") + } + + fn from_ron(text: &str) -> Result { + let mut me: AppearanceProfiles = + ron::from_str(text).map_err(|e| format!("RON de apariencia inválido: {e}"))?; + me.ensure_builtins(); + Ok(me) + } + + pub fn save(&self, path: &Path) -> std::io::Result<()> { + if let Some(dir) = path.parent() { + std::fs::create_dir_all(dir)?; + } + std::fs::write(path, self.to_ron()) + } + + pub fn load_or_init(path: &Path) -> AppearanceProfiles { + if path.exists() { + match std::fs::read_to_string(path).map_err(|e| e.to_string()).and_then(|t| Self::from_ron(&t)) { + Ok(p) => { + // `ensure_builtins` (en `from_ron`) puede haber reseteado el + // activo a «Sistema» si apuntaba a un perfil borrado, o + // inyectado presets faltantes. Re-persistimos para que el + // disco coincida con lo que se aplica y se muestra — si no, + // el activo del archivo queda colgado y reaparece el desfase. + let _ = p.save(path); + p + } + Err(e) => { + eprintln!("shuma · apariencia «{}» inválida ({e}); uso la de fábrica.", path.display()); + AppearanceProfiles::default() + } + } + } else { + let p = AppearanceProfiles::default(); + if let Err(e) = p.save(path) { + eprintln!("shuma · no pude escribir la apariencia inicial: {e}"); + } + p + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn default_trae_los_presets_con_sistema_activo() { + let p = AppearanceProfiles::default(); + assert_eq!(p.active(), SYSTEM_NAME); + for n in PRESET_NAMES { + assert!(p.contains(n), "falta preset {n}"); + } + } + + #[test] + fn translucido_aplica_opacidad_al_fondo() { + let a = preset("Translúcido").unwrap(); + assert!(a.opacity < 1.0); + let t = a.resolve(); + // bg_app debe quedar con alfa < 255. + let alpha = (t.bg_app.components[3] * 255.0).round() as u8; + assert!(alpha < 255, "el fondo translúcido debe tener alfa parcial"); + } + + #[test] + fn resolve_aplica_acento_override() { + let mut a = preset("Oscuro").unwrap(); + a.accent = Some([200, 50, 50, 255]); + let t = a.resolve(); + assert_eq!(t.accent, Color::from_rgba8(200, 50, 50, 255)); + } + + #[test] + fn round_trip_por_ron() { + let mut p = AppearanceProfiles::default(); + p.duplicate("Oscuro", "Mío").unwrap(); + p.set_active("Mío").unwrap(); + let back = AppearanceProfiles::from_ron(&p.to_ron()).unwrap(); + assert_eq!(back.active(), "Mío"); + assert_eq!(back, p); + } + + #[test] + fn renombrar_respeta_presets() { + let mut p = AppearanceProfiles::default(); + assert!(p.rename("Oscuro", "x").is_err()); // de fábrica + p.duplicate("Oscuro", "Mío").unwrap(); + p.rename("Mío", "Tuyo").unwrap(); + assert!(p.contains("Tuyo") && !p.contains("Mío")); + } + + #[test] + fn no_se_borra_un_preset() { + let mut p = AppearanceProfiles::default(); + assert!(p.remove("Oscuro").is_err()); + assert!(p.contains("Oscuro")); + } + + #[test] + fn set_y_clear_wallpaper_en_el_activo() { + let mut p = AppearanceProfiles::default(); + p.duplicate("Oscuro", "Foto").unwrap(); + p.set_active("Foto").unwrap(); + assert_eq!(p.active_wallpaper(), None); + p.set_wallpaper("Foto", Some("/img/x.jpg".to_string())).unwrap(); + assert_eq!(p.active_wallpaper().as_deref(), Some("/img/x.jpg")); + // vacío cuenta como quitar. + p.set_wallpaper("Foto", Some(" ".to_string())).unwrap(); + assert_eq!(p.active_wallpaper(), None); + // perfil inexistente falla. + assert!(p.set_wallpaper("nope", Some("/y".to_string())).is_err()); + } + + #[test] + fn sistema_trae_parpados_y_alta_transparencia_por_defecto() { + let s = preset(SYSTEM_NAME).unwrap(); + assert_eq!(s.background.as_deref(), Some("parpados")); + assert_eq!(s.background_pattern(), Some(mirada_procedural::Pattern::Parpados)); + // La transparencia es alta: el fondo asoma detrás del texto. + assert!(s.opacity < 0.9, "el fondo por defecto debe ser bien translúcido"); + } + + #[test] + fn set_y_clear_background_en_cualquier_perfil() { + let mut p = AppearanceProfiles::default(); + // Aplica también a un perfil de fábrica (queda override en disco). + p.set_background("Oscuro", Some("parpados".to_string())).unwrap(); + assert_eq!( + p.get("Oscuro").unwrap().background_pattern(), + Some(mirada_procedural::Pattern::Parpados) + ); + // Slug vacío = quitar. + p.set_background("Oscuro", Some(" ".to_string())).unwrap(); + assert_eq!(p.get("Oscuro").unwrap().background_pattern(), None); + // Slug inválido = sin patrón (pero se guarda el texto). + p.set_background("Oscuro", Some("no-existe".to_string())).unwrap(); + assert_eq!(p.get("Oscuro").unwrap().background_pattern(), None); + // None = quitar. + p.set_background("Oscuro", None).unwrap(); + assert_eq!(p.get("Oscuro").unwrap().background, None); + // Perfil inexistente falla. + assert!(p.set_background("nope", Some("parpados".to_string())).is_err()); + } + + #[test] + fn background_sobrevive_round_trip_ron() { + let p = AppearanceProfiles::default(); + let back = AppearanceProfiles::from_ron(&p.to_ron()).unwrap(); + assert_eq!( + back.get(SYSTEM_NAME).unwrap().background_pattern(), + Some(mirada_procedural::Pattern::Parpados) + ); + } + + #[test] + fn con_wallpaper_el_fondo_se_vuelve_translucido_aunque_opacity_sea_1() { + let mut a = preset("Oscuro").unwrap(); + assert_eq!(a.opacity, 1.0); + a.wallpaper = Some("/img/x.jpg".to_string()); + let t = a.resolve(); + let alpha = (t.bg_app.components[3] * 255.0).round() as u8; + assert!(alpha < 255, "con wallpaper el fondo debe dejar ver la imagen"); + } +} diff --git a/02_ruway/shuma/shuma-shell-llimphi/src/perfiles/mod.rs b/02_ruway/shuma/shuma-shell-llimphi/src/perfiles/mod.rs new file mode 100644 index 0000000..71c4d15 --- /dev/null +++ b/02_ruway/shuma/shuma-shell-llimphi/src/perfiles/mod.rs @@ -0,0 +1,146 @@ +//! Perfiles de shuma — tres bibliotecas conmutables, al estilo de +//! `mirada-brain::profiles`: +//! +//! - [`shortcuts`] — **atajos** del workspace (globales, un clic): `shuma`, +//! `hyprland`, `tmux`, `zellij`, `vim`. +//! - [`appearance`] — **apariencia** estilo konsole (tema, acento, fuente, +//! transparencia, wallpaper). Activo global = default de toda ventana; cada +//! sesión puede fijar el suyo. +//! - [`sessions`] — **perfiles de sesión** estilo Firefox (contextos completos +//! con su propio juego de sesiones/workspaces, aislados por directorio). + +pub mod appearance; +pub mod sessions; +pub mod shortcuts; + +use llimphi_theme::Theme; + +use crate::types::Model; + +/// Aplica al `Model` la apariencia que corresponde ahora: la del perfil fijado +/// por la **sesión activa** si lo tiene, o el **default global** si no. El +/// perfil `Sistema` sigue el tema de `wawa-config`. +/// +/// Llamar tras conmutar de sesión, conmutar el perfil de apariencia global, o +/// recibir un cambio de tema del sistema. +pub(crate) fn apply_active_appearance(model: &mut Model) { + // ¿La sesión activa fija una apariencia propia? + let per_session = model + .sessions + .get(model.active_session) + .and_then(|s| s.appearance.clone()); + let name = per_session.unwrap_or_else(|| model.appearance.active().to_string()); + model.theme = resolve_named(model, &name); + + // Wallpaper efectivo: el de la apariencia activa (no aplica a «Sistema»). + let wp: Option = if name == appearance::SYSTEM_NAME { + None + } else { + model + .appearance + .get(&name) + .and_then(|ap| ap.wallpaper.clone()) + .filter(|s| !s.trim().is_empty()) + }; + // Re-decodificar sólo si el path cambió (decodificar es caro; la Image es + // clon barato por frame). + if wp != model.wallpaper_path { + model.wallpaper_path = wp.clone(); + model.wallpaper_img = wp.and_then(|p| { + match llimphi_image::load_path(std::path::Path::new(&p), 64 * 1024 * 1024) { + Ok(img) => Some(img), + Err(e) => { + eprintln!("shuma · no pude cargar el wallpaper «{p}»: {e}"); + None + } + } + }); + } + + // Fondo procedural propio de shuma (párpados): aplica a cualquier perfil, + // incluido «Sistema» (que lo trae por defecto). Se relee de la apariencia + // efectiva por nombre y se pinta un primer frame; el `Tick` lo re-anima. + let pat = model + .appearance + .get(&name) + .and_then(|ap| ap.background_pattern()); + if pat != model.bg_pattern { + model.bg_pattern = pat; + regen_procedural_bg(model); + } +} + +// --- Fondo procedural (párpados lento y tenue) ------------------------------ + +/// Resolución del buffer procedural: baja a propósito — son manchas suaves que +/// `ImageFit::Cover` estira sin que se noten los píxeles, y así regenerarlo cada +/// segundo es barato. +const BG_W: u32 = 480; +const BG_H: u32 = 300; +/// Semilla fija del patrón (misma composición entre arranques). +const BG_SEED: u64 = 0x5104_1ada; +/// Factor de tiempo: el compositor avanza `t` a tiempo real; aquí lo escalamos +/// para que los párpados deriven **mucho más lento** (períodos ~4× más largos). +const BG_SLOW: f32 = 0.25; +/// Atenuación hacia negro del fondo procedural, para que quede tenue y no compita +/// con el texto (0.0 negro … 1.0 sin atenuar). +const BG_DIM: f32 = 0.5; + +/// Regenera `model.bg_procedural_img` para el instante actual (derivado de +/// `tick_count`). No hace nada si no hay patrón. El resultado ya viene atenuado. +pub(crate) fn regen_procedural_bg(model: &mut Model) { + match model.bg_pattern { + Some(pat) => { + let t = model.tick_count as f32 * BG_SLOW; + let mut rgba = mirada_procedural::generate_rgba_at(pat, &[], BG_W, BG_H, BG_SEED, t); + for px in rgba.chunks_exact_mut(4) { + px[0] = (px[0] as f32 * BG_DIM) as u8; + px[1] = (px[1] as f32 * BG_DIM) as u8; + px[2] = (px[2] as f32 * BG_DIM) as u8; + // alfa intacto (opaco): la translucidez la aporta el fondo de los + // paneles encima, no el buffer. + } + model.bg_procedural_img = Some(llimphi_image::from_rgba8(rgba, BG_W, BG_H)); + } + None => model.bg_procedural_img = None, + } +} + +/// Resuelve un nombre de apariencia a un `Theme`. `Sistema` (o un nombre +/// desconocido que caiga ahí) toma el tema de wawa; el resto, su preset. +fn resolve_named(model: &Model, name: &str) -> Theme { + if name == appearance::SYSTEM_NAME { + let wawa = wawa_config::WawaConfig::load(); + let mut t = wawa_config_llimphi::theme_from_wawa(&wawa, &Theme::dark()); + // El perfil `Sistema` sigue el tema de wawa pero igual honra su opacidad + // (transparencia por defecto): baja el alfa del fondo, no del texto. + let op = model + .appearance + .get(appearance::SYSTEM_NAME) + .map(|a| a.opacity) + .unwrap_or(1.0); + appearance::apply_bg_opacity(&mut t, op); + return t; + } + match model.appearance.get(name) { + Some(ap) => ap.resolve(), + None => { + let wawa = wawa_config::WawaConfig::load(); + wawa_config_llimphi::theme_from_wawa(&wawa, &Theme::dark()) + } + } +} + +/// `true` si la apariencia efectiva ahora mismo sigue al sistema (`Sistema`): +/// entonces los cambios de `wawa-config` deben propagarse al tema. Si una +/// sesión o el default fijan un perfil concreto, wawa **no** debe pisarlo. +pub(crate) fn follows_system(model: &Model) -> bool { + let per_session = model + .sessions + .get(model.active_session) + .and_then(|s| s.appearance.clone()); + match per_session { + Some(name) => name == appearance::SYSTEM_NAME, + None => model.appearance.active() == appearance::SYSTEM_NAME, + } +} diff --git a/02_ruway/shuma/shuma-shell-llimphi/src/perfiles/sessions.rs b/02_ruway/shuma/shuma-shell-llimphi/src/perfiles/sessions.rs new file mode 100644 index 0000000..d5d503b --- /dev/null +++ b/02_ruway/shuma/shuma-shell-llimphi/src/perfiles/sessions.rs @@ -0,0 +1,289 @@ +//! Perfiles de **sesión** — al estilo de los perfiles de Firefox. +//! +//! Un perfil de sesión es un **contexto completo**: su propio juego de +//! sesiones, chrome, disposiciones y outputs persistidos. Sirve para separar +//! usuarios o contextos ("trabajo", "personal", "cliente-X") con todo su estado +//! aislado. +//! +//! ## Cómo aísla +//! +//! No duplica la lógica de persistencia: **redirige el directorio de datos**. +//! Toda `persist.rs` lee/escribe bajo [`active_data_dir`]: +//! +//! - el perfil **`default`** usa el directorio histórico `~/.config/shuma/` +//! (así los archivos existentes siguen funcionando sin migración); +//! - cualquier otro perfil `` usa `~/.config/shuma/profiles//`. +//! +//! Conmutar de perfil = guardar el estado actual, cambiar el directorio activo +//! y recargar el modelo desde el nuevo directorio. +//! +//! El índice de perfiles (nombres + activo) vive en +//! `~/.config/shuma/session-profiles.ron` (siempre en la raíz, fuera de los +//! subdirectorios por perfil). + +use std::path::{Path, PathBuf}; +use std::sync::RwLock; + +use serde::{Deserialize, Serialize}; + +/// El nombre del perfil por defecto (usa el directorio histórico). +pub const DEFAULT_NAME: &str = "default"; + +/// El perfil de sesión activo del proceso. `persist.rs` lo consulta para +/// resolver dónde leen/escriben los archivos de estado. Se fija en el arranque +/// ([`crate::new_model`]) y al conmutar de perfil. +static ACTIVE: RwLock> = RwLock::new(None); + +/// Fija el perfil de sesión activo del proceso. +pub fn set_active(name: &str) { + if let Ok(mut g) = ACTIVE.write() { + *g = Some(name.to_string()); + } +} + +/// El perfil de sesión activo del proceso (o `default` si no se fijó). +pub fn active() -> String { + ACTIVE + .read() + .ok() + .and_then(|g| g.clone()) + .unwrap_or_else(|| DEFAULT_NAME.to_string()) +} + +/// La raíz de config de shuma: `~/.config/shuma/`. +pub fn config_root() -> Option { + directories::BaseDirs::new().map(|b| b.config_dir().join("shuma")) +} + +/// El directorio de datos del perfil de sesión activo. `default` → la raíz +/// histórica; otro → `…/profiles//`. +pub fn active_data_dir() -> Option { + data_dir_for(&active()) +} + +/// El directorio de datos de un perfil concreto. +pub fn data_dir_for(name: &str) -> Option { + let root = config_root()?; + if name == DEFAULT_NAME { + Some(root) + } else { + let sane: String = name + .chars() + .map(|c| if c.is_alphanumeric() || c == '-' || c == '_' { c } else { '_' }) + .collect(); + Some(root.join("profiles").join(sane)) + } +} + +/// El índice de perfiles de sesión: el activo + los nombres conocidos. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct SessionProfiles { + active: String, + names: Vec, +} + +impl Default for SessionProfiles { + fn default() -> Self { + Self { + active: DEFAULT_NAME.to_string(), + names: vec![DEFAULT_NAME.to_string()], + } + } +} + +impl SessionProfiles { + /// El nombre del perfil activo. + pub fn active(&self) -> &str { + &self.active + } + + /// Los nombres de los perfiles, en su orden. + pub fn names(&self) -> &[String] { + &self.names + } + + /// `true` si existe un perfil con ese nombre. + pub fn contains(&self, name: &str) -> bool { + self.names.iter().any(|n| n == name) + } + + /// Conmuta el perfil activo. Error si no existe. + pub fn set_active(&mut self, name: &str) -> Result<(), super::shortcuts::ProfileError> { + if self.contains(name) { + self.active = name.to_string(); + Ok(()) + } else { + Err(super::shortcuts::ProfileError::NotFound(name.to_string())) + } + } + + /// Crea un perfil nuevo (no lo activa). Error si ya existe o el nombre es + /// vacío. + pub fn create(&mut self, name: &str) -> Result<(), super::shortcuts::ProfileError> { + let name = name.trim(); + if name.is_empty() { + return Err(super::shortcuts::ProfileError::EmptyName); + } + if self.contains(name) { + return Err(super::shortcuts::ProfileError::AlreadyExists(name.to_string())); + } + self.names.push(name.to_string()); + Ok(()) + } + + /// Borra un perfil. `default` no se puede borrar; si se borra el activo, cae + /// a `default`. (No borra el directorio en disco — el estado queda por si se + /// recrea.) + pub fn remove(&mut self, name: &str) -> Result<(), super::shortcuts::ProfileError> { + if name == DEFAULT_NAME { + return Err(super::shortcuts::ProfileError::BuiltinProtected(name.to_string())); + } + if !self.contains(name) { + return Err(super::shortcuts::ProfileError::NotFound(name.to_string())); + } + self.names.retain(|n| n != name); + if self.active == name { + self.active = DEFAULT_NAME.to_string(); + } + Ok(()) + } + + /// Renombra un perfil propio (`default` no se renombra). Si se renombra el + /// activo, el activo sigue al nombre nuevo. (El movimiento del directorio en + /// disco lo hace quien conmuta — ver `lib::rename_session_profile`.) + pub fn rename(&mut self, from: &str, to: &str) -> Result<(), super::shortcuts::ProfileError> { + use super::shortcuts::ProfileError; + let to = to.trim(); + if to.is_empty() { + return Err(ProfileError::EmptyName); + } + if from == DEFAULT_NAME { + return Err(ProfileError::BuiltinProtected(from.to_string())); + } + if !self.contains(from) { + return Err(ProfileError::NotFound(from.to_string())); + } + if self.contains(to) { + return Err(ProfileError::AlreadyExists(to.to_string())); + } + for n in &mut self.names { + if n == from { + *n = to.to_string(); + } + } + if self.active == from { + self.active = to.to_string(); + } + Ok(()) + } + + // --- Disco -------------------------------------------------------- + + /// La ruta canónica del índice: `~/.config/shuma/session-profiles.ron` + /// (siempre en la raíz). + pub fn default_path() -> Option { + config_root().map(|d| d.join("session-profiles.ron")) + } + + fn to_ron(&self) -> String { + ron::ser::to_string_pretty(self, ron::ser::PrettyConfig::default()) + .expect("SessionProfiles siempre serializa") + } + + fn from_ron(text: &str) -> Result { + let mut me: SessionProfiles = + ron::from_str(text).map_err(|e| format!("RON de perfiles de sesión inválido: {e}"))?; + // Garantizar el default y que el activo exista. + if !me.contains(DEFAULT_NAME) { + me.names.insert(0, DEFAULT_NAME.to_string()); + } + if !me.contains(&me.active) { + me.active = DEFAULT_NAME.to_string(); + } + Ok(me) + } + + pub fn save(&self, path: &Path) -> std::io::Result<()> { + if let Some(dir) = path.parent() { + std::fs::create_dir_all(dir)?; + } + std::fs::write(path, self.to_ron()) + } + + pub fn load_or_init(path: &Path) -> SessionProfiles { + if path.exists() { + match std::fs::read_to_string(path).map_err(|e| e.to_string()).and_then(|t| Self::from_ron(&t)) { + Ok(p) => p, + Err(e) => { + eprintln!("shuma · perfiles de sesión «{}» inválidos ({e}); uso default.", path.display()); + SessionProfiles::default() + } + } + } else { + let p = SessionProfiles::default(); + if let Err(e) = p.save(path) { + eprintln!("shuma · no pude escribir los perfiles de sesión iniciales: {e}"); + } + p + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn default_dir_es_la_raiz_otros_van_a_subdir() { + // No dependemos de HOME real: comprobamos la forma relativa. + if let (Some(root), Some(def), Some(otro)) = + (config_root(), data_dir_for(DEFAULT_NAME), data_dir_for("trabajo")) + { + assert_eq!(def, root); + assert_eq!(otro, root.join("profiles").join("trabajo")); + } + } + + #[test] + fn crear_conmutar_borrar() { + let mut p = SessionProfiles::default(); + assert_eq!(p.active(), DEFAULT_NAME); + p.create("trabajo").unwrap(); + assert!(p.contains("trabajo")); + assert!(p.create("trabajo").is_err()); + p.set_active("trabajo").unwrap(); + assert_eq!(p.active(), "trabajo"); + // default no se borra. + assert!(p.remove(DEFAULT_NAME).is_err()); + // borrar el activo cae a default. + p.remove("trabajo").unwrap(); + assert_eq!(p.active(), DEFAULT_NAME); + } + + #[test] + fn renombrar_protege_default_y_sigue_al_activo() { + let mut p = SessionProfiles::default(); + p.create("trabajo").unwrap(); + p.set_active("trabajo").unwrap(); + assert!(p.rename(DEFAULT_NAME, "x").is_err()); // protegido + p.rename("trabajo", "obra").unwrap(); + assert!(p.contains("obra") && !p.contains("trabajo")); + assert_eq!(p.active(), "obra"); + } + + #[test] + fn round_trip_por_ron_garantiza_default() { + let ron = r#"(active: "x", names: ["x"])"#; + let p = SessionProfiles::from_ron(ron).unwrap(); + assert!(p.contains(DEFAULT_NAME)); + assert_eq!(p.active(), "x"); + } + + #[test] + fn set_active_global_se_lee() { + set_active("zeta"); + assert_eq!(active(), "zeta"); + set_active(DEFAULT_NAME); + assert_eq!(active(), DEFAULT_NAME); + } +} diff --git a/02_ruway/shuma/shuma-shell-llimphi/src/perfiles/shortcuts.rs b/02_ruway/shuma/shuma-shell-llimphi/src/perfiles/shortcuts.rs new file mode 100644 index 0000000..d568977 --- /dev/null +++ b/02_ruway/shuma/shuma-shell-llimphi/src/perfiles/shortcuts.rs @@ -0,0 +1,1016 @@ +//! Perfiles de **atajos** — una biblioteca de keymaps del workspace conmutables +//! con un clic (globales en shuma). +//! +//! shuma no trae *un* keymap sino una **biblioteca**: presets de fábrica +//! (`shuma` nativo, `hyprland`, `tmux`, `zellij`, `vim`) más los que el usuario +//! cree. Sobre ella se puede **conmutar** el activo, **duplicar** y editar, etc. +//! — mismo patrón que `mirada-brain::profiles`. +//! +//! ## Modelo de acorde +//! +//! Un keymap es un `prefix` opcional + un mapa `acorde → acción`: +//! +//! - **directo** (`prefix: None`): el acorde dispara la acción al instante +//! (estilo hyprland/dwm: `Super+…`, `Alt+…`). Para no tragarse texto normal, +//! todos los binds directos llevan un modificador. +//! - **con prefijo** (`prefix: Some("Ctrl+b")`): primero se pulsa el prefijo +//! (entra en estado "pendiente") y la siguiente tecla dispara la acción +//! (estilo tmux/vim). Una tecla no ligada tras el prefijo lo cancela. +//! +//! El estado "pendiente" vive en `Model::pending_prefix` (transitorio, no se +//! persiste). +//! +//! ## Dos reglas de los binds DIRECTOS (las dos costaron un reporte) +//! +//! 1. **Alcanzables sin AltGr.** En un teclado español `[`, `]`, `\`, `{`, `}`, +//! `@`, `#`, `~` y `|` se tipean con **AltGr**, así que un `Alt+[` es +//! físicamente imposible: la tecla llega como muerta, `key_char()` da `None` +//! y [`chord_of`] ni siquiera arma un acorde. Los binds sobre esos glifos se +//! conservan (sirven en teclados US) pero **toda acción tiene además un +//! acorde alcanzable** — teclas nombradas, letras o dígitos. Lo vigila +//! `toda_accion_directa_es_alcanzable_sin_altgr`. +//! 2. **Escritos en la forma canónica de [`chord_of`]**: modificadores en orden +//! `Ctrl+Alt+Shift+Super+base`, base en minúscula, y `Shift` sólo con letras +//! o teclas nombradas (en símbolos/dígitos el glifo ya lo incorpora). Un bind +//! fuera de esa forma no matchea nunca. Lo vigila `los_presets_son_canonicos`. +//! +//! Persistencia: `~/.config/shuma/shortcuts.ron`. + +use std::collections::BTreeMap; +use std::path::{Path, PathBuf}; + +use serde::{Deserialize, Serialize}; + +use crate::types::{Model, Msg}; + +/// Una acción del workspace tipo zellij que un atajo puede disparar. Se traduce +/// al `Msg` concreto con [`ShortcutAction::to_concrete`] (que necesita el modelo +/// para resolver "siguiente/anterior/ir-a tab N"). +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +pub enum ShortcutAction { + /// Tab nueva (con un shell fresco). + NewTab, + /// Cierra la tab activa. + CloseTab, + /// Tab siguiente (con wrap). + NextTab, + /// Tab anterior (con wrap). + PrevTab, + /// Va a la tab N (1-based). + GotoTab(u8), + /// Parte el panel con foco lado a lado (Horizontal). + SplitH, + /// Parte el panel con foco apilado (Vertical). + SplitV, + /// Cierra el panel con foco. + ClosePane, + /// Cicla el foco al panel siguiente. + CycleNext, + /// Cicla el foco al panel anterior. + CyclePrev, + /// Enciende/apaga la capa de flotantes. + FloatToggle, + /// Agrega un panel flotante nuevo. + FloatNew, +} + +impl ShortcutAction { + /// Traduce la acción al `Msg` concreto del chasis, usando el modelo para + /// resolver las que dependen de la tab activa. + pub(crate) fn to_concrete(self, model: &Model) -> Option { + use llimphi_widget_panes::Axis; + let ws = model.active().map(|s| &s.workspace); + let active_tab = ws.map(|w| w.active_tab).unwrap_or(0); + let n_tabs = ws.map(|w| w.tabs.len().max(1)).unwrap_or(1); + Some(match self { + ShortcutAction::NewTab => Msg::TabNew, + ShortcutAction::CloseTab => Msg::TabClose(active_tab), + ShortcutAction::NextTab => Msg::TabSwitch((active_tab + 1) % n_tabs), + ShortcutAction::PrevTab => Msg::TabSwitch((active_tab + n_tabs - 1) % n_tabs), + ShortcutAction::GotoTab(n) => { + if n >= 1 { + Msg::TabSwitch((n as usize) - 1) + } else { + return None; + } + } + ShortcutAction::SplitH => Msg::PaneSplit(Axis::Horizontal), + ShortcutAction::SplitV => Msg::PaneSplit(Axis::Vertical), + ShortcutAction::ClosePane => Msg::PaneClose, + ShortcutAction::CycleNext => Msg::PaneCycle(true), + ShortcutAction::CyclePrev => Msg::PaneCycle(false), + ShortcutAction::FloatToggle => Msg::FloatToggle, + ShortcutAction::FloatNew => Msg::FloatNew, + }) + } +} + +/// Un keymap: prefijo opcional + binds `acorde → acción`. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct Keymap { + /// Acorde de prefijo (estilo tmux/vim). `None` = binds directos. + #[serde(default)] + pub prefix: Option, + /// `acorde normalizado → acción`. + pub binds: BTreeMap, +} + +impl Keymap { + fn from_pairs(prefix: Option<&str>, pairs: &[(&str, ShortcutAction)]) -> Self { + Keymap { + prefix: prefix.map(|s| s.to_string()), + binds: pairs + .iter() + .map(|(k, a)| ((*k).to_string(), *a)) + .collect(), + } + } + + /// Funde los binds de `fresh` que falten sin pisar los del usuario (espejo + /// de `mirada-brain::Keymap::merge_from`): un preset de fábrica viejo en + /// disco recibe los atajos nuevos. El prefijo del fresco gana si el guardado + /// no tiene. + fn merge_from(&mut self, fresh: &Keymap) { + if self.prefix.is_none() { + self.prefix = fresh.prefix.clone(); + } + for (k, a) in &fresh.binds { + self.binds.entry(k.clone()).or_insert(*a); + } + } +} + +use ShortcutAction::*; + +/// Los nombres de los presets de fábrica, en orden de presentación. +pub const PRESET_NAMES: &[&str] = &["shuma", "terminal", "hyprland", "tmux", "zellij", "vim"]; + +/// `true` si `name` es un preset de fábrica (protegido contra borrado/renombre). +pub fn is_builtin(name: &str) -> bool { + PRESET_NAMES.contains(&name) +} + +/// El keymap de un preset de fábrica por nombre. +pub fn preset(name: &str) -> Option { + Some(match name { + // Nativo de shuma: directo, prefijo `Alt`. Es exactamente lo que tenía + // `workspace_key` hardcoded antes de los perfiles. + "shuma" => Keymap::from_pairs( + None, + &[ + ("Alt+t", NewTab), + ("Alt+w", ClosePane), + ("Alt+\\", SplitH), + ("Alt+v", SplitH), + ("Alt+-", SplitV), + ("Alt+s", SplitV), + ("Alt+f", FloatToggle), + ("Alt+n", FloatNew), + ("Alt+[", PrevTab), + ("Alt+]", NextTab), + // Espejo alcanzable de `Alt+[`/`Alt+]`: en teclado español esos + // dos glifos exigen AltGr y el acorde no se puede tipear. + ("Alt+PageUp", PrevTab), + ("Alt+PageDown", NextTab), + ("Alt+Left", CyclePrev), + ("Alt+Right", CycleNext), + ("Alt+1", GotoTab(1)), + ("Alt+2", GotoTab(2)), + ("Alt+3", GotoTab(3)), + ("Alt+4", GotoTab(4)), + ("Alt+5", GotoTab(5)), + ("Alt+6", GotoTab(6)), + ("Alt+7", GotoTab(7)), + ("Alt+8", GotoTab(8)), + ("Alt+9", GotoTab(9)), + // Atajos acostumbrados de terminal, además de los Alt nativos. + // `Ctrl+Shift+…` (no `Ctrl+…` solo) para no comerse los códigos + // de control que el shell necesita (Ctrl+T = transpose, etc.). + ("Ctrl+Shift+t", NewTab), + ("Ctrl+Shift+w", CloseTab), + ("Ctrl+Tab", NextTab), + ("Ctrl+Shift+Tab", PrevTab), + ("Ctrl+PageDown", NextTab), + ("Ctrl+PageUp", PrevTab), + ("Ctrl+Shift+e", SplitH), + ("Ctrl+Shift+o", SplitV), + ("Ctrl+Shift+d", ClosePane), + ], + ), + // Terminal puro: sólo los acordes acostumbrados de emuladores + // (gnome-terminal/konsole/terminator), sin los Alt nativos de shuma. + "terminal" => Keymap::from_pairs( + None, + &[ + ("Ctrl+Shift+t", NewTab), + ("Ctrl+Shift+w", CloseTab), + ("Ctrl+Tab", NextTab), + ("Ctrl+Shift+Tab", PrevTab), + ("Ctrl+PageDown", NextTab), + ("Ctrl+PageUp", PrevTab), + ("Ctrl+Shift+Right", NextTab), + ("Ctrl+Shift+Left", PrevTab), + ("Ctrl+Shift+e", SplitH), + ("Ctrl+Shift+o", SplitV), + ("Ctrl+Shift+d", ClosePane), + ("Ctrl+Shift+f", FloatToggle), + ("Alt+1", GotoTab(1)), + ("Alt+2", GotoTab(2)), + ("Alt+3", GotoTab(3)), + ("Alt+4", GotoTab(4)), + ("Alt+5", GotoTab(5)), + ("Alt+6", GotoTab(6)), + ("Alt+7", GotoTab(7)), + ("Alt+8", GotoTab(8)), + ("Alt+9", GotoTab(9)), + ], + ), + // Hyprland: directo, prefijo `Super`. + "hyprland" => Keymap::from_pairs( + None, + &[ + ("Super+Return", NewTab), + ("Super+q", ClosePane), + ("Super+v", FloatToggle), + ("Super+s", SplitV), + ("Super+\\", SplitH), + // `\` pide AltGr en español: simétrico tipeable de `Super+s`. + // Escrito en la forma canónica de `chord_of` (Shift antes de + // Super), si no nunca matchearía. + ("Shift+Super+s", SplitH), + ("Super+Left", CyclePrev), + ("Super+Right", CycleNext), + ("Super+1", GotoTab(1)), + ("Super+2", GotoTab(2)), + ("Super+3", GotoTab(3)), + ("Super+4", GotoTab(4)), + ("Super+5", GotoTab(5)), + ("Super+6", GotoTab(6)), + ("Super+7", GotoTab(7)), + ("Super+8", GotoTab(8)), + ("Super+9", GotoTab(9)), + ], + ), + // tmux: prefijo `Ctrl+b`, luego una tecla. `%`=split lado-a-lado, + // `"`=split apilado (igual que tmux real). + "tmux" => Keymap::from_pairs( + Some("Ctrl+b"), + &[ + ("c", NewTab), + ("&", CloseTab), + ("x", ClosePane), + ("%", SplitH), + ("\"", SplitV), + ("n", NextTab), + ("p", PrevTab), + ("o", CycleNext), + ("z", FloatToggle), + ("1", GotoTab(1)), + ("2", GotoTab(2)), + ("3", GotoTab(3)), + ("4", GotoTab(4)), + ("5", GotoTab(5)), + ("6", GotoTab(6)), + ("7", GotoTab(7)), + ("8", GotoTab(8)), + ("9", GotoTab(9)), + ], + ), + // zellij (capa rápida tipo "locked"): directo, prefijo `Alt` — + // aproxima los defaults alt-based de zellij. + "zellij" => Keymap::from_pairs( + None, + &[ + ("Alt+n", SplitV), + ("Alt+t", NewTab), + ("Alt+w", ClosePane), + ("Alt+f", FloatToggle), + ("Alt+[", PrevTab), + ("Alt+]", NextTab), + // Ídem shuma: en teclado español `[`/`]` piden AltGr, así que + // la navegación de tabs necesita un acorde tipeable. + ("Alt+PageUp", PrevTab), + ("Alt+PageDown", NextTab), + ("Alt+h", CyclePrev), + ("Alt+l", CycleNext), + ("Alt+Left", CyclePrev), + ("Alt+Right", CycleNext), + ("Alt+1", GotoTab(1)), + ("Alt+2", GotoTab(2)), + ("Alt+3", GotoTab(3)), + ("Alt+4", GotoTab(4)), + ("Alt+5", GotoTab(5)), + ], + ), + // vim: prefijo `Ctrl+w` (mando de ventanas de vim). `s`=split horizontal + // (divisor horizontal → apilado → Vertical), `v`=vsplit (lado a lado → + // Horizontal). + "vim" => Keymap::from_pairs( + Some("Ctrl+w"), + &[ + ("s", SplitV), + ("v", SplitH), + ("c", ClosePane), + ("q", ClosePane), + ("w", CycleNext), + ("h", CyclePrev), + ("l", CycleNext), + ("j", CycleNext), + ("k", CyclePrev), + ("t", NewTab), + ("n", NewTab), + ("1", GotoTab(1)), + ("2", GotoTab(2)), + ("3", GotoTab(3)), + ("4", GotoTab(4)), + ("5", GotoTab(5)), + ], + ), + _ => return None, + }) +} + +/// Los acordes de la **aplicación**, no del dialecto: los que todo emulador de +/// terminal se queda para sí (gnome-terminal, konsole, kitty). Viven en una capa +/// APARTE del perfil activo, así valen con cualquier dialecto — elegir `zellij` +/// o `tmux` como perfil no debería costarte el `Ctrl+Shift+T` de siempre. +/// +/// **Todos llevan `Ctrl`** (con `Shift` o una tecla nombrada) y ninguno usa +/// `Alt` ni una tecla suelta: adentro del terminal puede estar corriendo un +/// zellij/tmux/vim **de verdad**, y esos usan justamente `Alt+…` y teclas +/// sueltas tras su prefijo. Robárselas rompería el programa hospedado. Lo +/// vigila `la_capa_universal_no_le_roba_teclas_a_los_tui`. +pub fn universal() -> Keymap { + Keymap::from_pairs( + None, + &[ + ("Ctrl+Shift+t", NewTab), + ("Ctrl+Shift+w", CloseTab), + ("Ctrl+Tab", NextTab), + ("Ctrl+Shift+Tab", PrevTab), + ("Ctrl+PageDown", NextTab), + ("Ctrl+PageUp", PrevTab), + ("Ctrl+Shift+Right", NextTab), + ("Ctrl+Shift+Left", PrevTab), + ("Ctrl+Shift+e", SplitH), + ("Ctrl+Shift+o", SplitV), + ("Ctrl+Shift+d", ClosePane), + ("Ctrl+Shift+f", FloatToggle), + ], + ) +} + +/// La biblioteca de perfiles de atajos: el activo + todos los keymaps por +/// nombre + la capa universal de la app (ver [`universal`]). +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct ShortcutProfiles { + active: String, + profiles: BTreeMap, + /// Acordes de la app que valen con cualquier perfil. `serde(default)` para + /// que un `shortcuts.ron` viejo (sin el campo) siga cargando: `ensure_builtins` + /// le funde los que falten. + #[serde(default = "universal")] + universal: Keymap, +} + +impl Default for ShortcutProfiles { + fn default() -> Self { + let mut profiles = BTreeMap::new(); + for name in PRESET_NAMES { + if let Some(km) = preset(name) { + profiles.insert((*name).to_string(), km); + } + } + Self { + active: "shuma".to_string(), + profiles, + universal: universal(), + } + } +} + +impl ShortcutProfiles { + /// El nombre del perfil activo. + pub fn active(&self) -> &str { + &self.active + } + + /// El keymap del perfil activo (fallback al nativo `shuma` si el activo no + /// existe por edición a mano). + pub fn active_keymap(&self) -> Keymap { + self.profiles + .get(&self.active) + .cloned() + .unwrap_or_else(|| preset("shuma").expect("preset de fábrica")) + } + + /// La capa universal de la app (acordes que valen con cualquier perfil). + pub fn universal_keymap(&self) -> &Keymap { + &self.universal + } + + /// Reemplaza la capa universal (el panel de control la edita como una tabla + /// más). El prefijo se ignora: la capa es siempre directa. + pub fn set_universal(&mut self, mut km: Keymap) { + km.prefix = None; + self.universal = km; + } + + /// Los nombres de todos los perfiles, en orden alfabético. + pub fn names(&self) -> Vec { + self.profiles.keys().cloned().collect() + } + + /// `true` si existe un perfil con ese nombre. + pub fn contains(&self, name: &str) -> bool { + self.profiles.contains_key(name) + } + + /// Conmuta el perfil activo. Error si no existe. + pub fn set_active(&mut self, name: &str) -> Result<(), ProfileError> { + if self.profiles.contains_key(name) { + self.active = name.to_string(); + Ok(()) + } else { + Err(ProfileError::NotFound(name.to_string())) + } + } + + /// Crea un perfil nuevo con el keymap dado. Error si ya existe o el nombre + /// es vacío. + pub fn create(&mut self, name: &str, km: Keymap) -> Result<(), ProfileError> { + let name = name.trim(); + if name.is_empty() { + return Err(ProfileError::EmptyName); + } + if self.profiles.contains_key(name) { + return Err(ProfileError::AlreadyExists(name.to_string())); + } + self.profiles.insert(name.to_string(), km); + Ok(()) + } + + /// Duplica un perfil existente con un nombre nuevo. + pub fn duplicate(&mut self, src: &str, name: &str) -> Result<(), ProfileError> { + let km = self + .profiles + .get(src) + .cloned() + .ok_or_else(|| ProfileError::NotFound(src.to_string()))?; + self.create(name, km) + } + + /// Renombra un perfil propio (los presets no se renombran). Si se renombra + /// el activo, el activo sigue al nombre nuevo. + pub fn rename(&mut self, from: &str, to: &str) -> Result<(), ProfileError> { + let to = to.trim(); + if to.is_empty() { + return Err(ProfileError::EmptyName); + } + if is_builtin(from) { + return Err(ProfileError::BuiltinProtected(from.to_string())); + } + if !self.profiles.contains_key(from) { + return Err(ProfileError::NotFound(from.to_string())); + } + if self.profiles.contains_key(to) { + return Err(ProfileError::AlreadyExists(to.to_string())); + } + let km = self.profiles.remove(from).expect("recién comprobado"); + self.profiles.insert(to.to_string(), km); + if self.active == from { + self.active = to.to_string(); + } + Ok(()) + } + + /// Borra un perfil. Los presets de fábrica no se pueden borrar. Si se borra + /// el activo, cae a `shuma`. + pub fn remove(&mut self, name: &str) -> Result<(), ProfileError> { + if is_builtin(name) { + return Err(ProfileError::BuiltinProtected(name.to_string())); + } + if self.profiles.remove(name).is_none() { + return Err(ProfileError::NotFound(name.to_string())); + } + if self.active == name { + self.active = "shuma".to_string(); + } + Ok(()) + } + + /// Re-siembra los presets de fábrica que falten y funde los binds nuevos en + /// los builtins guardados sin pisar los rebinds del usuario. Ídem la capa + /// universal: un RON viejo (o uno al que le sacaron acordes) recibe los que + /// falten sin perder los propios. + fn ensure_builtins(&mut self) { + self.universal.merge_from(&universal()); + self.universal.prefix = None; // la capa de app nunca lleva prefijo + for name in PRESET_NAMES { + let fresh = preset(name).expect("preset de fábrica"); + match self.profiles.get_mut(*name) { + Some(saved) => saved.merge_from(&fresh), + None => { + self.profiles.insert((*name).to_string(), fresh); + } + } + } + if !self.profiles.contains_key(&self.active) { + self.active = "shuma".to_string(); + } + } + + // --- Disco -------------------------------------------------------- + + /// La ruta canónica: `~/.config/shuma/shortcuts.ron`. + pub fn default_path() -> Option { + directories::ProjectDirs::from("", "", "shuma") + .map(|d| d.config_dir().join("shortcuts.ron")) + } + + fn to_ron(&self) -> String { + ron::ser::to_string_pretty(self, ron::ser::PrettyConfig::default()) + .expect("ShortcutProfiles siempre serializa") + } + + fn from_ron(text: &str) -> Result { + let mut me: ShortcutProfiles = + ron::from_str(text).map_err(|e| format!("RON de atajos inválido: {e}"))?; + me.ensure_builtins(); + Ok(me) + } + + /// Escribe la biblioteca, creando el directorio padre si falta. + pub fn save(&self, path: &Path) -> std::io::Result<()> { + if let Some(dir) = path.parent() { + std::fs::create_dir_all(dir)?; + } + std::fs::write(path, self.to_ron()) + } + + /// Carga con fallback amable: si no existe lo crea con los presets; si está + /// corrupto avisa por stderr y usa los de fábrica sin pisarlo. + pub fn load_or_init(path: &Path) -> ShortcutProfiles { + if path.exists() { + match std::fs::read_to_string(path).map_err(|e| e.to_string()).and_then(|t| Self::from_ron(&t)) { + Ok(p) => p, + Err(e) => { + eprintln!("shuma · atajos «{}» inválidos ({e}); uso los de fábrica.", path.display()); + ShortcutProfiles::default() + } + } + } else { + let p = ShortcutProfiles::default(); + if let Err(e) = p.save(path) { + eprintln!("shuma · no pude escribir los atajos iniciales: {e}"); + } + p + } + } +} + +/// Un fallo al operar sobre la biblioteca de perfiles. +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum ProfileError { + NotFound(String), + AlreadyExists(String), + EmptyName, + BuiltinProtected(String), +} + +impl std::fmt::Display for ProfileError { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match self { + ProfileError::NotFound(n) => write!(f, "no existe el perfil «{n}»"), + ProfileError::AlreadyExists(n) => write!(f, "ya existe el perfil «{n}»"), + ProfileError::EmptyName => f.write_str("el nombre del perfil no puede ser vacío"), + ProfileError::BuiltinProtected(n) => { + write!(f, "«{n}» es un preset de fábrica; duplicalo para editarlo") + } + } + } +} + +impl std::error::Error for ProfileError {} + +// ─── Normalización de acordes ─────────────────────────────────────── + +/// Normaliza un `KeyEvent` a un acorde canónico (`"Alt+t"`, `"Ctrl+b"`, +/// `"Super+Return"`, `"%"`). Orden de modificadores: Ctrl, Alt, Shift, Super. +/// El Shift se omite para símbolos/dígitos (su glifo ya viene resuelto); se +/// mantiene para letras y teclas con nombre. Devuelve `None` para teclas que no +/// modelamos como acorde. +pub(crate) fn chord_of(e: &llimphi_ui::KeyEvent) -> Option { + use llimphi_ui::{Key, NamedKey}; + let base: String = match &e.key { + Key::Character(c) => { + let s = c.as_str(); + if s.is_empty() { + return None; + } + s.to_lowercase() + } + Key::Named(nk) => match nk { + NamedKey::ArrowLeft => "Left".to_string(), + NamedKey::ArrowRight => "Right".to_string(), + NamedKey::ArrowUp => "Up".to_string(), + NamedKey::ArrowDown => "Down".to_string(), + NamedKey::Enter => "Return".to_string(), + NamedKey::Tab => "Tab".to_string(), + NamedKey::Space => "Space".to_string(), + NamedKey::PageUp => "PageUp".to_string(), + NamedKey::PageDown => "PageDown".to_string(), + _ => return None, + }, + _ => return None, + }; + // ¿el Shift es semánticamente relevante? Sí para letras y teclas con nombre; + // no para símbolos/dígitos (cuyo glifo ya incorpora el shift). + let is_named = matches!(&e.key, llimphi_ui::Key::Named(_)); + let is_letter = base.len() == 1 && base.chars().next().map(|c| c.is_ascii_alphabetic()).unwrap_or(false); + let keep_shift = is_named || is_letter; + let m = &e.modifiers; + let mut s = String::new(); + if m.ctrl { + s.push_str("Ctrl+"); + } + if m.alt { + s.push_str("Alt+"); + } + if m.shift && keep_shift { + s.push_str("Shift+"); + } + if m.meta { + s.push_str("Super+"); + } + s.push_str(&base); + Some(s) +} + +/// Resuelve una tecla contra el keymap activo. Devuelve el `Msg` a emitir: +/// `ShortcutFire` (acción directa o tras prefijo), `ShortcutEnterPrefix` +/// (entró al prefijo) o `ShortcutCancelPrefix` (tecla suelta tras prefijo). +/// `None` = la tecla no es un atajo, sigue su curso al shell. +pub(crate) fn resolve_key(model: &Model, e: &llimphi_ui::KeyEvent) -> Option { + // No actuar si la sesión activa está en el form de creación. + if model + .sessions + .get(model.active_session) + .map(|s| s.pending) + .unwrap_or(true) + { + return None; + } + let km = model.shortcuts.active_keymap(); + let chord = chord_of(e)?; + // 1) El perfil activo manda: puede rebindear un acorde de la capa universal. + let del_perfil = match &km.prefix { + Some(prefix) => { + if model.pending_prefix { + if let Some(act) = km.binds.get(&chord) { + Some(Msg::ShortcutFire(*act)) + } else { + // Tecla no ligada tras el prefijo: cancela (la consumimos). + // Incluidos los universales — apretaste el prefijo, mandás + // vos; el próximo `Ctrl+Shift+T` (ya sin pending) dispara. + Some(Msg::ShortcutCancelPrefix) + } + } else if &chord == prefix { + Some(Msg::ShortcutEnterPrefix) + } else { + None + } + } + None => km.binds.get(&chord).map(|a| Msg::ShortcutFire(*a)), + }; + if del_perfil.is_some() { + return del_perfil; + } + // 2) La capa de la APP: vale con cualquier dialecto activo, con prefijo o + // sin él (ver `universal`). + model + .shortcuts + .universal_keymap() + .binds + .get(&chord) + .map(|a| Msg::ShortcutFire(*a)) +} + +/// Diagnóstico legible de cómo se resolvería `e` contra el keymap activo: el +/// **chord** que se computa y si matchea un bind (o por qué no). Es la sonda para +/// el «sólo sirve Ctrl+Shift+C»: distingue los tres desenlaces posibles — +/// (a) los **modificadores no llegan** (el chord sale sin `Ctrl`/`Shift`), +/// (b) el chord se computa pero **no está ligado** en el perfil activo, o +/// (c) **matchea** (entonces el atajo SÍ dispara y el problema es aguas abajo). +/// No muta nada; sólo lo consume el diag de pata (por el centinela `/tmp/pata-diag`). +pub fn diag_shortcut(model: &Model, e: &llimphi_ui::KeyEvent) -> String { + let active = model.shortcuts.active().to_string(); + let km = model.shortcuts.active_keymap(); + let pref = km.prefix.clone().unwrap_or_default(); + let Some(chord) = chord_of(e) else { + return format!("atajo: chord=None (tecla no modelada) perfil='{active}'"); + }; + let universal = model.shortcuts.universal_keymap().binds.get(&chord); + match (km.binds.get(&chord), universal) { + (Some(a), _) => format!( + "atajo: chord='{chord}' → {a:?} perfil='{active}' prefix='{pref}' pending={}", + model.pending_prefix + ), + (None, Some(a)) => format!( + "atajo: chord='{chord}' → {a:?} (capa universal de app) perfil='{active}' prefix='{pref}' pending={}", + model.pending_prefix + ), + (None, None) => format!( + "atajo: chord='{chord}' → SIN BIND (ni en el perfil ni en la capa universal) \ + perfil='{active}' prefix='{pref}' pending={}", + model.pending_prefix + ), + } +} + +#[cfg(test)] +mod tests { + use super::*; + + /// Glifos que en un teclado español (y latam) sólo se tipean con **AltGr**: + /// combinarlos con Alt/Super es físicamente imposible. + const ALTGR_EN_ESPANOL: &[&str] = &["[", "]", "\\", "{", "}", "@", "#", "~", "|"]; + + /// Parte un acorde en `(modificadores, base)`. `"Ctrl+Shift+t"` → + /// `(["Ctrl","Shift"], "t")`; `"+"` como base se respeta (`"Alt++"`). + fn partir(chord: &str) -> (Vec<&str>, &str) { + match chord.rfind('+') { + // El `+` final es el separador salvo que la base SEA `+`. + Some(i) if i + 1 < chord.len() => (chord[..i].split('+').collect(), &chord[i + 1..]), + _ => (Vec::new(), chord), + } + } + + /// Toda acción de un preset DIRECTO tiene al menos un acorde tipeable en + /// teclado español. Los binds sobre glifos de AltGr pueden quedar (sirven en + /// US), pero no pueden ser el único camino a la acción. Es la regresión del + /// «`Alt+t` anda pero `Alt+[`/`Alt+]` no» (24-jul). + #[test] + fn toda_accion_directa_es_alcanzable_sin_altgr() { + for name in PRESET_NAMES { + let km = preset(name).expect("preset de fábrica"); + if km.prefix.is_some() { + continue; // tras un prefijo la tecla va sola: `%`, `"` son tipeables + } + let mut alcanzables: Vec = Vec::new(); + let mut todas: Vec = Vec::new(); + for (chord, act) in &km.binds { + let (_mods, base) = partir(chord); + if !todas.contains(act) { + todas.push(*act); + } + if !ALTGR_EN_ESPANOL.contains(&base) && !alcanzables.contains(act) { + alcanzables.push(*act); + } + } + for act in todas { + assert!( + alcanzables.contains(&act), + "preset '{name}': {act:?} sólo se puede disparar con un glifo de AltGr \ + (imposible en teclado español) — falta un acorde tipeable" + ); + } + } + } + + /// Los acordes de los presets están escritos como `chord_of` los computa: + /// orden `Ctrl+Alt+Shift+Super+base`, base en minúscula, y `Shift` sólo con + /// letras o teclas nombradas. Un bind fuera de esa forma no matchea nunca. + #[test] + fn los_presets_son_canonicos() { + const ORDEN: &[&str] = &["Ctrl", "Alt", "Shift", "Super"]; + let nombradas = ["Left", "Right", "Up", "Down", "Return", "Tab", "Space", "PageUp", "PageDown"]; + for name in PRESET_NAMES { + let km = preset(name).expect("preset de fábrica"); + let acordes = km.binds.keys().cloned().chain(km.prefix.clone()); + for chord in acordes { + let (mods, base) = partir(&chord); + // Orden canónico de los modificadores. + let pos: Vec = mods + .iter() + .map(|m| { + ORDEN + .iter() + .position(|o| o == m) + .unwrap_or_else(|| panic!("preset '{name}': modificador raro en '{chord}'")) + }) + .collect(); + assert!( + pos.windows(2).all(|w| w[0] < w[1]), + "preset '{name}': '{chord}' fuera del orden canónico Ctrl+Alt+Shift+Super" + ); + // Base en minúscula (chord_of lowercasea los caracteres). + let es_nombrada = nombradas.contains(&base); + assert!( + es_nombrada || base == base.to_lowercase(), + "preset '{name}': la base de '{chord}' debe ir en minúscula" + ); + // Shift sólo donde chord_of lo conserva: letras y nombradas. + if mods.contains(&"Shift") { + let es_letra = base.len() == 1 + && base.chars().next().is_some_and(|c| c.is_ascii_alphabetic()); + assert!( + es_letra || es_nombrada, + "preset '{name}': '{chord}' lleva Shift sobre un símbolo/dígito — \ + chord_of lo omite ahí, el bind nunca matchearía" + ); + } + } + } + } + + /// El invariante que pidió sergio: adentro del terminal puede correr un + /// zellij/tmux/vim de verdad, así que la capa de app **no** puede quedarse + /// con las teclas que esos programas usan (`Alt+…` y teclas sueltas tras su + /// prefijo). Todo acorde universal lleva `Ctrl`. + #[test] + fn la_capa_universal_no_le_roba_teclas_a_los_tui() { + let u = universal(); + assert!(u.prefix.is_none(), "la capa de app es directa, sin prefijo"); + for chord in u.binds.keys() { + let (mods, _base) = partir(chord); + assert!( + mods.contains(&"Ctrl"), + "'{chord}' no lleva Ctrl: se la robaría a un TUI hospedado" + ); + assert!( + !mods.contains(&"Alt"), + "'{chord}' usa Alt — es el modificador de zellij/tmux corriendo dentro" + ); + } + } + + /// La capa de app dispara con CUALQUIER perfil activo, incluidos los de + /// prefijo (sin tener que apretar `Ctrl+b` antes). Es el pedido: elegir el + /// dialecto no cuesta los acordes de terminal. + #[test] + fn los_universales_valen_con_cualquier_perfil() { + let p = ShortcutProfiles::default(); + for name in PRESET_NAMES { + let km = p.profiles.get(*name).expect("preset"); + for (chord, act) in &universal().binds { + // O lo liga el propio perfil, o lo cubre la capa universal: en + // ambos casos el acorde termina disparando la misma acción. + if let Some(propio) = km.binds.get(chord) { + assert_eq!( + propio, act, + "preset '{name}': '{chord}' contradice la capa de app" + ); + } + assert!( + p.universal_keymap().binds.contains_key(chord), + "'{chord}' no está en la capa universal" + ); + // Y con un keymap de prefijo el acorde NO queda atrapado detrás + // del prefijo: la capa se consulta aparte (ver `resolve_key`). + assert!(km.prefix.is_none() || !km.binds.contains_key(chord)); + } + } + } + + /// Un `shortcuts.ron` viejo (sin el campo `universal`) carga y recibe la + /// capa de fábrica; uno al que le sacaron acordes los recupera. + #[test] + fn un_ron_viejo_recibe_la_capa_universal() { + let viejo = r#"(active: "zellij", profiles: {})"#; + let p = ShortcutProfiles::from_ron(viejo).expect("RON viejo válido"); + assert_eq!(p.active(), "zellij"); + assert_eq!( + p.universal_keymap().binds.get("Ctrl+Shift+t"), + Some(&NewTab), + "la capa de app no se sembró en un RON viejo" + ); + // Round-trip con el campo ya presente. + let ida = p.to_ron(); + let vuelta = ShortcutProfiles::from_ron(&ida).expect("round-trip"); + assert_eq!(vuelta.universal_keymap(), p.universal_keymap()); + } + + #[test] + fn default_trae_los_presets_con_shuma_activo() { + let p = ShortcutProfiles::default(); + assert_eq!(p.active(), "shuma"); + for n in PRESET_NAMES { + assert!(p.contains(n), "falta preset {n}"); + } + } + + #[test] + fn switch_a_inexistente_falla() { + let mut p = ShortcutProfiles::default(); + assert!(p.set_active("nope").is_err()); + assert!(p.set_active("tmux").is_ok()); + assert_eq!(p.active(), "tmux"); + } + + #[test] + fn no_se_borra_un_preset_pero_si_un_propio() { + let mut p = ShortcutProfiles::default(); + assert!(matches!(p.remove("tmux"), Err(ProfileError::BuiltinProtected(_)))); + p.duplicate("tmux", "mío").unwrap(); + p.set_active("mío").unwrap(); + p.remove("mío").unwrap(); + assert!(!p.contains("mío")); + assert_eq!(p.active(), "shuma"); // el activo cae al nativo + } + + #[test] + fn renombrar_respeta_presets_y_sigue_al_activo() { + let mut p = ShortcutProfiles::default(); + assert!(p.rename("tmux", "x").is_err()); // de fábrica + p.duplicate("vim", "a").unwrap(); + p.set_active("a").unwrap(); + p.rename("a", "b").unwrap(); + assert!(p.contains("b") && !p.contains("a")); + assert_eq!(p.active(), "b"); + } + + #[test] + fn round_trip_por_ron_preserva_activo_y_perfiles() { + let mut p = ShortcutProfiles::default(); + p.duplicate("vim", "custom").unwrap(); + p.set_active("custom").unwrap(); + let back = ShortcutProfiles::from_ron(&p.to_ron()).unwrap(); + assert_eq!(back.active(), "custom"); + assert_eq!(back, p); + } + + #[test] + fn from_ron_resiembra_presets_faltantes() { + let ron = r#"(active: "shuma", profiles: { "solo": (prefix: None, binds: {}) })"#; + let p = ShortcutProfiles::from_ron(ron).unwrap(); + for n in PRESET_NAMES { + assert!(p.contains(n)); + } + assert!(p.contains("solo")); + } + + #[test] + fn terminal_trae_los_acordes_acostumbrados() { + let km = preset("terminal").expect("preset terminal"); + assert!(km.prefix.is_none()); + assert_eq!(km.binds.get("Ctrl+Shift+t"), Some(&NewTab)); + assert_eq!(km.binds.get("Ctrl+Shift+w"), Some(&CloseTab)); + assert_eq!(km.binds.get("Ctrl+Tab"), Some(&NextTab)); + assert_eq!(km.binds.get("Ctrl+Shift+Tab"), Some(&PrevTab)); + // shuma nativo también incorpora los de terminal sin perder los Alt. + let shuma = preset("shuma").unwrap(); + assert_eq!(shuma.binds.get("Ctrl+Shift+t"), Some(&NewTab)); + assert_eq!(shuma.binds.get("Alt+t"), Some(&NewTab)); + } + + #[test] + fn chord_de_ctrl_tab_y_pagedown() { + let tab = llimphi_ui::KeyEvent { + key: llimphi_ui::Key::Named(llimphi_ui::NamedKey::Tab), + state: llimphi_ui::KeyState::Pressed, + text: None, + modifiers: llimphi_ui::Modifiers { ctrl: true, alt: false, shift: false, meta: false }, + repeat: false, + }; + assert_eq!(chord_of(&tab).as_deref(), Some("Ctrl+Tab")); + let pgdn = llimphi_ui::KeyEvent { + key: llimphi_ui::Key::Named(llimphi_ui::NamedKey::PageDown), + state: llimphi_ui::KeyState::Pressed, + text: None, + modifiers: llimphi_ui::Modifiers { ctrl: true, alt: false, shift: false, meta: false }, + repeat: false, + }; + assert_eq!(chord_of(&pgdn).as_deref(), Some("Ctrl+PageDown")); + } + + #[test] + fn tmux_es_con_prefijo_y_hyprland_directo() { + assert_eq!(preset("tmux").unwrap().prefix.as_deref(), Some("Ctrl+b")); + assert_eq!(preset("vim").unwrap().prefix.as_deref(), Some("Ctrl+w")); + assert!(preset("hyprland").unwrap().prefix.is_none()); + assert!(preset("shuma").unwrap().prefix.is_none()); + } + + fn key_char(c: &str, ctrl: bool, alt: bool, shift: bool, meta: bool) -> llimphi_ui::KeyEvent { + llimphi_ui::KeyEvent { + key: llimphi_ui::Key::Character(c.into()), + state: llimphi_ui::KeyState::Pressed, + text: None, + modifiers: llimphi_ui::Modifiers { ctrl, alt, shift, meta }, + repeat: false, + } + } + + #[test] + fn chord_normaliza_modificadores_y_omite_shift_en_simbolos() { + assert_eq!(chord_of(&key_char("t", false, true, false, false)).as_deref(), Some("Alt+t")); + assert_eq!(chord_of(&key_char("b", true, false, false, false)).as_deref(), Some("Ctrl+b")); + // Shift se mantiene en letras… + assert_eq!(chord_of(&key_char("a", false, true, true, false)).as_deref(), Some("Alt+Shift+a")); + // …pero se omite en símbolos (el glifo ya viene shifteado). + assert_eq!(chord_of(&key_char("%", false, false, true, false)).as_deref(), Some("%")); + // Super (meta). + assert_eq!(chord_of(&key_char("q", false, false, false, true)).as_deref(), Some("Super+q")); + } +} diff --git a/02_ruway/shuma/shuma-shell-llimphi/src/persist.rs b/02_ruway/shuma/shuma-shell-llimphi/src/persist.rs new file mode 100644 index 0000000..9918aa9 --- /dev/null +++ b/02_ruway/shuma/shuma-shell-llimphi/src/persist.rs @@ -0,0 +1,238 @@ +//! Persistencia del chasis: sesiones, chrome, disposiciones y containers. +//! +//! Funciones para leer/guardar `sessions.json`, `chrome.json`, +//! `layouts.json` y `containers.json`. + +use crate::types::{ + ChromeState, ContainerCfg, LayoutSnapshot, Model, ModuleState, SessionConfig, SessionKind, +}; + +// ─── Output por sesión (flag «Persistir sesión») ──────────────────── + +/// Tope de líneas persistidas por sesión — suficiente historial visible +/// sin que el JSON crezca sin techo. +const PERSIST_MAX_LINES: usize = 2000; + +/// El directorio de datos del perfil de **sesión** activo (tipo Firefox). Todas +/// las rutas de estado cuelgan de aquí: el perfil `default` usa el directorio +/// histórico `~/.config/shuma/`; otro perfil `` usa `…/profiles//`. +fn data_dir() -> Option { + crate::perfiles::sessions::active_data_dir() +} + +/// `/agente.sled` — base de datos de agentes y conversaciones del chat. +pub(crate) fn agente_db_path() -> Option { + data_dir().map(|d| d.join("agente.sled")) +} + +/// `/outputs/.json`. +pub(crate) fn session_output_path(name: &str) -> Option { + let sane: String = name + .chars() + .map(|c| if c.is_alphanumeric() || c == '-' || c == '_' { c } else { '_' }) + .collect(); + data_dir().map(|d| d.join("outputs").join(format!("{sane}.json"))) +} + +/// Guarda el output de TODAS las sesiones con `persist` activo. Barato: +/// snapshot capeado + write atómico sólo si hay algo que decir. +pub(crate) fn save_session_outputs(m: &Model) { + for s in &m.sessions { + if !s.persist || s.pending || s.kind == SessionKind::Draft { + continue; + } + let ModuleState::Shell(st) = &s.shell().state else { + continue; + }; + let snap = st.output_snapshot(PERSIST_MAX_LINES); + if snap.lines.is_empty() { + continue; + } + let Some(path) = session_output_path(&s.name) else { + continue; + }; + if let Ok(json) = serde_json::to_string(&snap) { + if let Some(dir) = path.parent() { + let _ = std::fs::create_dir_all(dir); + } + let tmp = path.with_extension("json.tmp"); + if std::fs::write(&tmp, json).is_ok() { + let _ = std::fs::rename(&tmp, &path); + } + } + } +} + +/// Lee el output persistido de una sesión, si existe. +pub(crate) fn load_session_output(name: &str) -> Option { + let path = session_output_path(name)?; + let text = std::fs::read_to_string(path).ok()?; + serde_json::from_str(&text).ok() +} + +/// Lee un snapshot de salida desde una ruta arbitraria — el archivo de handoff +/// que escribe `pata` al desacoplar ("mover de verdad") una sesión a un shuma +/// standalone. Distinto de [`load_session_output`], que resuelve la ruta por +/// nombre de sesión persistida. +pub(crate) fn load_output_snapshot_file(path: &str) -> Option { + let text = std::fs::read_to_string(path).ok()?; + serde_json::from_str(&text).ok() +} + +/// Borra el output persistido (al apagar el flag o cerrar la sesión). +pub(crate) fn remove_session_output(name: &str) { + if let Some(p) = session_output_path(name) { + let _ = std::fs::remove_file(p); + } +} + +/// mtime de `env.json` — para detectar cambios hechos por el builtin +/// `:env` (u otra instancia) y recargar los grupos del Model. +pub(crate) fn env_groups_mtime() -> Option { + shuma_config::env_groups_path() + .and_then(|p| std::fs::metadata(p).ok()) + .and_then(|md| md.modified().ok()) +} + +// ─── Containers ──────────────────────────────────────────────────── + +pub(crate) fn containers_cfg_path() -> Option { + data_dir().map(|d| d.join("containers.json")) +} + +pub(crate) fn load_container_cfgs() -> Vec { + containers_cfg_path() + .and_then(|p| std::fs::read_to_string(p).ok()) + .and_then(|s| serde_json::from_str::>(&s).ok()) + .unwrap_or_default() +} + +pub(crate) fn save_container_cfgs(cfgs: &[ContainerCfg]) { + let Some(path) = containers_cfg_path() else { + return; + }; + if let Ok(json) = serde_json::to_string_pretty(cfgs) { + if let Some(dir) = path.parent() { + let _ = std::fs::create_dir_all(dir); + } + let _ = std::fs::write(&path, json); + } +} + +// ─── Sesiones ─────────────────────────────────────────────────────── + +/// `$XDG_CONFIG_HOME/shuma/sessions.json`. +pub(crate) fn sessions_path() -> Option { + data_dir().map(|d| d.join("sessions.json")) +} + +/// Guarda las sesiones reales (no la draft). +pub(crate) fn save_sessions(m: &Model) { + let Some(path) = sessions_path() else { + return; + }; + let cfgs: Vec = m + .sessions + .iter() + .filter(|s| s.kind != SessionKind::Draft && !s.pending) + .map(|s| s.to_config()) + .collect(); + if let Ok(json) = serde_json::to_string_pretty(&cfgs) { + if let Some(dir) = path.parent() { + let _ = std::fs::create_dir_all(dir); + } + let _ = std::fs::write(&path, json); + } +} + +/// Lee las sesiones persistidas. +pub(crate) fn load_sessions() -> Vec { + sessions_path() + .and_then(|p| std::fs::read_to_string(p).ok()) + .and_then(|s| serde_json::from_str::>(&s).ok()) + .unwrap_or_default() +} + +// ─── Chrome ───────────────────────────────────────────────────────── + +/// `$XDG_CONFIG_HOME/shuma/chrome.json`. +pub(crate) fn chrome_path() -> Option { + data_dir().map(|d| d.join("chrome.json")) +} + +/// Guarda el estado de chrome (paneles + pestaña activa). +pub(crate) fn save_chrome(m: &Model) { + let Some(path) = chrome_path() else { + return; + }; + let state = ChromeState { + active_tool: m.active_tool, + session_panel_open: m.session_panel_open, + active_session: m.active_session, + session_w: m.session_w, + monitors_width: m.monitors_width, + }; + if let Ok(json) = serde_json::to_string_pretty(&state) { + if let Some(dir) = path.parent() { + let _ = std::fs::create_dir_all(dir); + } + let _ = std::fs::write(&path, json); + } +} + +/// Lee el estado de chrome persistido. +pub(crate) fn load_chrome() -> ChromeState { + chrome_path() + .and_then(|p| std::fs::read_to_string(p).ok()) + .and_then(|s| serde_json::from_str::(&s).ok()) + .unwrap_or_default() +} + +// ─── Disposiciones ────────────────────────────────────────────────── + +/// `$XDG_CONFIG_HOME/shuma/layouts.json`. +pub(crate) fn layouts_path() -> Option { + data_dir().map(|d| d.join("layouts.json")) +} + +/// Lee las disposiciones guardadas. +pub(crate) fn load_layouts() -> Vec { + layouts_path() + .and_then(|p| std::fs::read_to_string(p).ok()) + .and_then(|s| serde_json::from_str::>(&s).ok()) + .unwrap_or_default() +} + +/// Persiste la lista de disposiciones. +pub(crate) fn save_layouts(layouts: &[LayoutSnapshot]) { + let Some(path) = layouts_path() else { + return; + }; + if let Ok(json) = serde_json::to_string_pretty(layouts) { + if let Some(dir) = path.parent() { + let _ = std::fs::create_dir_all(dir); + } + let _ = std::fs::write(&path, json); + } +} + +/// Snapshot del espacio de trabajo actual. +pub(crate) fn snapshot_workspace(m: &Model, name: String) -> LayoutSnapshot { + let sessions: Vec = m + .sessions + .iter() + .filter(|s| s.kind != SessionKind::Draft && !s.pending) + .map(|s| s.to_config()) + .collect(); + LayoutSnapshot { + name, + sessions, + chrome: ChromeState { + active_tool: m.active_tool, + session_panel_open: m.session_panel_open, + active_session: m.active_session, + session_w: m.session_w, + monitors_width: m.monitors_width, + }, + } +} diff --git a/02_ruway/shuma/shuma-shell-llimphi/src/semantic.rs b/02_ruway/shuma/shuma-shell-llimphi/src/semantic.rs new file mode 100644 index 0000000..245c189 --- /dev/null +++ b/02_ruway/shuma/shuma-shell-llimphi/src/semantic.rs @@ -0,0 +1,65 @@ +//! Índice semántico **persistido** por `scope` (comandos del historial, rutas del +//! explorer…). Es una fina fachada de dominio sobre el índice unificado de la suite +//! [`rimay_verbo_index::SemanticStore`] — el dueño del ciclo embeber→rankear→persistir +//! (el mismo que usan pluma y ayni). Aquí sólo vive lo específico de shuma: la ruta por +//! scope. + +use std::path::PathBuf; + +use rimay_verbo::{EmbeddingVector, ModelId, Provider}; +use rimay_verbo_index::SemanticStore; + +/// Un índice semántico vivo, respaldado por un archivo postcard (vía el +/// [`SemanticStore`] unificado, abierto en modo persistente). +pub(crate) struct SemanticIndex(SemanticStore); + +impl SemanticIndex { + /// La ruta canónica de un índice por `scope` (p.ej. `"history"`, `"files"`): + /// `$XDG_DATA_HOME/shuma/semantic/.idx` (o `~/.local/share/...`). + pub fn path_for(scope: &str) -> PathBuf { + let base = std::env::var_os("XDG_DATA_HOME") + .map(PathBuf::from) + .filter(|p| !p.as_os_str().is_empty()) + .or_else(|| std::env::var_os("HOME").map(|h| PathBuf::from(h).join(".local/share"))) + .unwrap_or_else(|| PathBuf::from(".")); + base.join("shuma").join("semantic").join(format!("{scope}.idx")) + } + + /// Carga (persistente) el índice de `path` para `model`. Si el archivo no existe, + /// está corrupto, o es de otro modelo, arranca vacío — se reconstruye solo. + pub fn load(path: PathBuf, model: ModelId) -> Self { + Self(SemanticStore::open(path, model)) + } + + /// Embebe con `provider` las entradas de `corpus` cuya clave todavía no esté + /// indexada (incremental) y las ingiere. `(clave, texto)`: la clave indexa, el + /// texto se embebe. Delega en [`SemanticStore::ensure`]. + pub async fn ensure( + &mut self, + provider: &dyn Provider, + corpus: &[(String, String)], + ) -> Result<(), String> { + self.0.ensure(provider, corpus).await.map(|_| ()).map_err(|e| e.to_string()) + } + + /// Descarta las claves que no estén en `keep` (poda lo que ya no existe). + pub fn retain(&mut self, keep: &[String]) { + self.0.retain(keep); + } + + /// Rankea `query` (ya embebido): top-k con score ≥ `min_score`, de mayor a menor. + pub fn search(&self, query: &EmbeddingVector, top_k: usize, min_score: f32) -> Vec<(String, f32)> { + self.0 + .search(query, top_k, min_score) + .into_iter() + .map(|h| (h.key, h.score)) + .collect() + } + + /// Persiste a disco si cambió (escritura atómica; delega en [`SemanticStore::save`]). + pub fn save(&mut self) -> std::io::Result<()> { + self.0 + .save() + .map_err(|e| std::io::Error::new(std::io::ErrorKind::Other, e.to_string())) + } +} diff --git a/02_ruway/shuma/shuma-shell-llimphi/src/types.rs b/02_ruway/shuma/shuma-shell-llimphi/src/types.rs new file mode 100644 index 0000000..a24c691 --- /dev/null +++ b/02_ruway/shuma/shuma-shell-llimphi/src/types.rs @@ -0,0 +1,1599 @@ +//! Tipos del chasis de shuma: enums, structs de estado y mensajes. +//! +//! Toda la definición de `Model`, `Msg`, `Session`, `Instance`, etc. +//! vive aquí para mantener `main.rs` como punto de entrada limpio. + +use std::collections::HashMap; + +use llimphi_motion::Tween; +use llimphi_theme::Theme; +use llimphi_widget_panes::{Axis, PaneId, Side}; +use llimphi_widget_text_input::{TextInputEvent, TextInputState}; +use shuma_module::{ModuleContributions, RemoteTransport, Source}; +use shuma_sysmon::{Snapshot, SystemSampler}; + +use crate::containers::{prepare_rootfs, rootfs_path_for, rootfs_listo}; +use crate::env::{default_shell_source, engine_preferido}; +use crate::hosts; +use crate::workspace::Workspace; + +// ─── Tipos de módulos conocidos ──────────────────────────────────── + +/// Qué `Kind` puede ocupar cada slot. Una variante por módulo compilado. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub(crate) enum Kind { + Launcher, + CommandBar, + Shell, + Matilda, + Minga, + Canvas, +} + +impl Kind { + /// `id` canónico. + #[allow(dead_code)] + pub(crate) fn id(self) -> &'static str { + match self { + Kind::Launcher => shuma_module_launcher::ID, + Kind::CommandBar => shuma_module_commandbar::ID, + Kind::Shell => shuma_module_shell::ID, + Kind::Matilda => shuma_module_matilda::ID, + Kind::Minga => shuma_module_minga::ID, + Kind::Canvas => shuma_module_canvas::ID, + } + } +} + +/// Cuál instancia-módulo de una sesión direcciona un `Slot` o un `Msg`. +/// `Shell` es el panel **con foco** del workspace tiling; `Pane(id)` +/// direcciona un panel concreto (tiled o flotante) de la tab activa. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub(crate) enum Which { + Shell, + Canvas, + Matilda, + Pane(PaneId), +} + +/// Dónde corre el shell de la sesión. +#[derive(Debug, Clone, Copy, PartialEq, Eq, serde::Serialize, serde::Deserialize)] +pub(crate) enum Isolation { + Local, + Remote, +} + +impl Isolation { + pub(crate) const ALL: [Isolation; 2] = [Isolation::Local, Isolation::Remote]; + #[allow(dead_code)] + pub(crate) fn label(self) -> &'static str { + match self { + Isolation::Local => "Local", + Isolation::Remote => "Remoto", + } + } +} + +/// Estado de conexión de la sesión. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub(crate) enum ConnState { + Pending, + Connected, + #[allow(dead_code)] + Disconnected, +} + +impl ConnState { + pub(crate) fn label(self) -> &'static str { + match self { + ConnState::Pending => "en espera", + ConnState::Connected => "conectado", + ConnState::Disconnected => "desconectado", + } + } +} + +/// La distro del aislamiento. +#[derive(Debug, Clone, Copy, PartialEq, Eq, serde::Serialize, serde::Deserialize)] +pub(crate) enum Distro { + Ubuntu, + Debian, + Alpine, + Arch, +} + +impl Distro { + pub(crate) const ALL: [Distro; 4] = [Distro::Ubuntu, Distro::Debian, Distro::Alpine, Distro::Arch]; + pub(crate) fn label(self) -> &'static str { + match self { + Distro::Ubuntu => "Ubuntu", + Distro::Debian => "Debian", + Distro::Alpine => "Alpine", + Distro::Arch => "Arch", + } + } + /// Imagen OCI fully-qualified para `podman run`. + pub(crate) fn image(self) -> &'static str { + match self { + Distro::Ubuntu => "docker.io/library/ubuntu:latest", + Distro::Debian => "docker.io/library/debian:latest", + Distro::Alpine => "docker.io/library/alpine:latest", + Distro::Arch => "docker.io/library/archlinux:latest", + } + } +} + +/// Distro a partir del nombre de un rootfs. +pub(crate) fn distro_from_name(name: &str) -> Option { + let n = name.to_lowercase(); + Distro::ALL.into_iter().find(|d| d.label().to_lowercase() == n) +} + +/// Campo del form de conexión remota con foco de teclado. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub(crate) enum RemoteField { + Host, + User, + Port, +} + +/// Campo del form de creación de sesión nueva con foco. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub(crate) enum PendingField { + Mount, +} + +/// Estado de un container listado en la ventana gestora. +#[derive(Debug, Clone)] +pub(crate) struct ContainerInfo { + pub name: String, + pub status: String, + pub image: String, + /// `true` = rootfs en disco (unshare/bwrap). + pub rootfs: bool, +} + +/// Una entrada del listado del Explorer (un archivo o directorio del cwd). +#[derive(Debug, Clone, PartialEq, Eq)] +pub(crate) struct ExplorerEntry { + pub is_dir: bool, + pub name: String, +} + +/// Estado del listado remoto del Explorer. +#[derive(Default)] +pub(crate) enum ExplorerState { + /// Nada pedido todavía. + #[default] + Idle, + /// Listado en curso (off-thread por SSH). + Loading, + /// Listado listo. + Loaded(Vec), + /// El listado falló (mensaje para mostrar). + Error(String), +} + +/// Cache del listado del Explorer para sesiones **remotas** (Remote / +/// RemoteContainer). `read_dir` local no alcanza al filesystem del host +/// remoto, así que el contenido se trae off-thread por SSH y se cachea aquí. +/// La `key` ata el contenido a una `(sesión, cwd)` concreta — al cambiar +/// cualquiera de los dos, el reconciliador dispara un listado nuevo. +#[derive(Default)] +pub(crate) struct ExplorerCache { + /// `(índice de sesión, cwd)` que refleja `state`; `None` = vacío. + pub key: Option<(usize, String)>, + pub state: ExplorerState, +} + +/// Resultados de `:buscar-archivos` para pintar en el panel del Explorer en vez +/// del listado normal: rutas relativas rankeadas por significado, clickeables. +/// `None` = sin búsqueda activa (el Explorer muestra el cwd). El botón ✕ del +/// panel la limpia. +pub(crate) struct FileSearch { + /// Sesión a la que pertenece (el panel sólo la muestra si es la activa). + pub session: usize, + /// La consulta, para el encabezado del panel. + pub query: String, + /// `(ruta relativa, score)` de mayor a menor parecido. + pub hits: Vec<(String, f32)>, +} + +/// Un directorio del host montado dentro del contenedor. +#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize, serde::Deserialize)] +pub(crate) struct Mount { + pub host: String, + pub target: String, + #[serde(default)] + pub readonly: bool, +} + +/// Config persistida de un contenedor. +#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)] +pub(crate) struct ContainerCfg { + pub name: String, + #[serde(default = "host_local")] + pub host: String, + pub engine: String, + pub distro: Distro, + #[serde(default)] + pub mounts: Vec, +} + +pub(crate) fn host_local() -> String { + "local".to_string() +} + +/// Columna de un mount con foco de teclado. +// `pub` (no `pub(crate)`): viaja en la variante pública `Msg::ContainerDraftMountCampo` +// (y en `ContainerDraftFocusMount`), así el `Msg` público no expone un tipo privado. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum MountCol { + Host, + Target, +} + +/// Una fila de mount en el editor. +#[derive(Debug, Clone)] +pub(crate) struct MountDraft { + pub host: TextInputState, + pub target: TextInputState, + pub readonly: bool, +} + +impl MountDraft { + pub(crate) fn new() -> Self { + Self { + host: TextInputState::new(), + target: TextInputState::new(), + readonly: false, + } + } + pub(crate) fn from_mount(m: &Mount) -> Self { + let mut host = TextInputState::new(); + host.set_text(m.host.clone()); + let mut target = TextInputState::new(); + target.set_text(m.target.clone()); + Self { host, target, readonly: m.readonly } + } + pub(crate) fn to_mount(&self) -> Option { + let host = self.host.text(); + let target = self.target.text(); + if host.trim().is_empty() || target.trim().is_empty() { + return None; + } + Some(Mount { host, target, readonly: self.readonly }) + } +} + +/// Editor de contenedor del gestor. +#[derive(Debug, Clone)] +pub(crate) struct ContainerDraft { + pub editing: Option, + pub host: String, + pub engine: String, + pub distro: Distro, + pub mounts: Vec, + pub focus: Option<(usize, MountCol)>, +} + +impl ContainerDraft { + pub(crate) fn new(host: String) -> Self { + Self { + editing: None, + host, + engine: engine_preferido().unwrap_or("unshare").to_string(), + distro: Distro::Ubuntu, + mounts: Vec::new(), + focus: None, + } + } + pub(crate) fn from_cfg(cfg: &ContainerCfg) -> Self { + Self { + editing: Some(cfg.name.clone()), + host: cfg.host.clone(), + engine: cfg.engine.clone(), + distro: cfg.distro, + mounts: cfg.mounts.iter().map(MountDraft::from_mount).collect(), + focus: None, + } + } + pub(crate) fn to_cfg(&self, name: String) -> ContainerCfg { + ContainerCfg { + name, + host: self.host.clone(), + engine: self.engine.clone(), + distro: self.distro, + mounts: self.mounts.iter().filter_map(MountDraft::to_mount).collect(), + } + } +} + +/// Form para crear/editar un host remoto. +#[derive(Debug, Clone)] +pub(crate) struct HostDraft { + pub name: TextInputState, + pub host: TextInputState, + pub user: TextInputState, + pub port: TextInputState, + pub use_password: bool, + pub pem_path: TextInputState, + /// Transporte del host: PTY sobre SSH (interactivos andan) vs `exec`. + pub pty: bool, + pub focused: Option, + pub editing: Option, +} + +impl HostDraft { + pub(crate) fn new() -> Self { + let mut port = TextInputState::new(); + port.set_text("22"); + Self { + name: TextInputState::new(), + host: TextInputState::new(), + user: TextInputState::new(), + port, + use_password: true, + pem_path: TextInputState::new(), + pty: true, + focused: Some(HostDraftField::Name), + editing: None, + } + } + + pub(crate) fn from_host(h: &hosts::RemoteHost) -> Self { + let mut name = TextInputState::new(); + name.set_text(h.name.clone()); + let mut host = TextInputState::new(); + host.set_text(h.host.clone()); + let mut user = TextInputState::new(); + user.set_text(h.user.clone()); + let mut port = TextInputState::new(); + port.set_text(h.port.to_string()); + let (use_password, pem) = match &h.auth { + hosts::HostAuth::Password => (true, String::new()), + hosts::HostAuth::Key { path } => (false, path.clone()), + }; + let mut pem_path = TextInputState::new(); + pem_path.set_text(pem); + Self { + name, + host, + user, + port, + use_password, + pem_path, + pty: h.pty, + focused: Some(HostDraftField::Name), + editing: Some(h.name.clone()), + } + } + + pub(crate) fn to_host(&self) -> Option { + let name = self.name.text(); + let host = self.host.text(); + let user = self.user.text(); + if name.trim().is_empty() || host.trim().is_empty() || user.trim().is_empty() { + return None; + } + let port: u16 = self.port.text().trim().parse().unwrap_or(22); + let auth = if self.use_password { + hosts::HostAuth::Password + } else { + let path = self.pem_path.text(); + hosts::HostAuth::Key { path } + }; + Some(hosts::RemoteHost { name, host, user, port, auth, pty: self.pty }) + } +} + +// `pub` (no `pub(crate)`): viaja en la variante pública `Msg::HostDraftCampo` +// (y en `HostDraftFocus`), así el `Msg` público no expone un tipo privado. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum HostDraftField { + Name, + Host, + User, + Port, + Pem, +} + +/// Cuál dropdown de la config de sesión está abierto. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub(crate) enum DropKind { + Isolation, + Distro, + Container, + Engine, + Host, +} + +/// El tipo de una sesión. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub(crate) enum SessionKind { + Draft, + Local, + #[allow(dead_code)] + Remote, +} + +/// Las herramientas de la sesión activa. +#[derive(Debug, Clone, Copy, PartialEq, Eq, serde::Serialize, serde::Deserialize)] +pub(crate) enum Tool { + History, + Monitor, + Explorer, + Matilda, + /// Panel de chat multi-agente (`shuma-module-agente`). + Agente, +} + +impl Tool { + pub(crate) const ALL: [Tool; 5] = + [Tool::History, Tool::Monitor, Tool::Explorer, Tool::Matilda, Tool::Agente]; + + pub(crate) fn label(self) -> &'static str { + match self { + Tool::History => "Historial", + Tool::Monitor => "Monitor", + Tool::Explorer => "Explorer", + Tool::Matilda => "Matilda", + Tool::Agente => "Agente", + } + } +} + +/// State vivo de un módulo. +pub(crate) enum ModuleState { + Launcher(shuma_module_launcher::State), + CommandBar(shuma_module_commandbar::State), + Shell(shuma_module_shell::State), + Matilda(Box), + Minga(shuma_module_minga::State), + Canvas(shuma_module_canvas::State), +} + +/// Una instancia activa de un módulo. +pub(crate) struct Instance { + pub kind: Kind, + #[allow(dead_code)] + pub label: String, + pub state: ModuleState, +} + +impl Instance { + pub(crate) fn launcher(state: shuma_module_launcher::State) -> Self { + Self { + kind: Kind::Launcher, + label: rimay_localize::t("shuma-label-launcher"), + state: ModuleState::Launcher(state), + } + } + + pub(crate) fn command_bar(state: shuma_module_commandbar::State) -> Self { + Self { + kind: Kind::CommandBar, + label: rimay_localize::t("shuma-label-command"), + state: ModuleState::CommandBar(state), + } + } + + pub(crate) fn shell(label: String, source: Source) -> Self { + Self { + kind: Kind::Shell, + label, + state: ModuleState::Shell(shuma_module_shell::State::new(source)), + } + } + + pub(crate) fn matilda(label: String, source: Source) -> Self { + Self::matilda_with_inventory(label, source, None) + } + + pub(crate) fn matilda_with_inventory( + label: String, + source: Source, + inventory: Option<&std::path::Path>, + ) -> Self { + use crate::update::{example_inventory_fallback, load_matilda_inventory}; + let state = match inventory { + Some(p) => { + let inv = load_matilda_inventory(p).unwrap_or_else(example_inventory_fallback); + shuma_module_matilda::State::with_inventory_path(source, inv, p.to_path_buf()) + } + None => shuma_module_matilda::State::new(source), + }; + Self { + kind: Kind::Matilda, + label, + state: ModuleState::Matilda(Box::new(state)), + } + } + + pub(crate) fn minga(label: String, source: Source) -> Self { + Self { + kind: Kind::Minga, + label, + state: ModuleState::Minga(shuma_module_minga::State::new(source)), + } + } + + pub(crate) fn canvas(label: String) -> Self { + Self { + kind: Kind::Canvas, + label, + state: ModuleState::Canvas(shuma_module_canvas::State::new()), + } + } +} + +// ─── Sesión de trabajo ────────────────────────────────────────────── + +pub(crate) struct Session { + pub name: String, + pub kind: SessionKind, + pub number: Option, + pub isolation: Isolation, + pub distro: Distro, + pub container: Option, + pub use_container: bool, + pub container_engine: String, + pub container_open: bool, + pub conn: ConnState, + pub host_label: Option, + pub host: TextInputState, + pub user: TextInputState, + pub port: TextInputState, + pub pending: bool, + pub mount: TextInputState, + pub pending_focus: Option, + /// Persistir el output del shell a disco y restaurarlo al reabrir. + pub persist: bool, + /// Para sesiones remotas: pedir **PTY** sobre el canal SSH en vez de un + /// `exec` por comando. Con PTY andan `vim`/`htop`/`claude` del otro + /// lado; sin PTY cada comando es un shell no interactivo (más barato, + /// pero mudo para todo lo de pantalla completa). + pub remote_pty: bool, + /// Perfil de **apariencia** propio de esta sesión (la "ventana"). `None` = + /// usa el default global. Gana sobre el global cuando la sesión está activa. + pub appearance: Option, + pub source: Source, + /// El layout tipo zellij de esta sesión: tabs + tiling + flotantes. Cada + /// panel es un shell vivo; `shell()` devuelve el panel con foco. + pub workspace: Workspace, + pub canvas: Instance, + pub matilda: Instance, +} + +impl Session { + pub(crate) fn build(name: String, kind: SessionKind, number: Option, source: Source) -> Self { + Self { + workspace: Workspace::single(Instance::shell(name.clone(), source.clone())), + canvas: Instance::canvas(rimay_localize::t("shuma-label-canvas")), + matilda: Instance::matilda(name.clone(), source.clone()), + name, + kind, + number, + isolation: Isolation::Local, + distro: Distro::Ubuntu, + container: None, + use_container: false, + container_engine: engine_preferido().unwrap_or("bwrap").to_string(), + container_open: false, + pending: false, + mount: TextInputState::new(), + pending_focus: None, + persist: false, + remote_pty: false, + appearance: None, + conn: ConnState::Connected, + host_label: None, + host: TextInputState::new(), + user: TextInputState::new(), + port: { + let mut p = TextInputState::new(); + p.set_text("22"); + p + }, + source, + } + } + + pub(crate) fn draft() -> Self { + Self::build("draft".to_string(), SessionKind::Draft, None, default_shell_source()) + } + + pub(crate) fn new_pending(n: u32) -> Self { + let mut s = Self::build( + format!("local {n}"), + SessionKind::Local, + Some(n), + Source::Local, + ); + s.pending = true; + s + } + + pub(crate) fn host_key(&self) -> String { + self.host_label.clone().unwrap_or_else(|| "local".to_string()) + } + + /// El shell con foco del workspace tiling — el que recibe el teclado y el + /// que el chasis trata como "el shell de la sesión". + pub(crate) fn shell(&self) -> &Instance { + self.workspace.focused_instance() + } + + pub(crate) fn shell_mut(&mut self) -> &mut Instance { + self.workspace.focused_instance_mut() + } + + pub(crate) fn active_data(&self) -> bool { + matches!(&self.shell().state, ModuleState::Shell(s) if s.is_running()) + } + + /// Estado de actividad del shell con foco — alimenta el color del LED del + /// diente (quieto / movimiento / claude). + pub(crate) fn activity(&self) -> shuma_module_shell::Activity { + match &self.shell().state { + ModuleState::Shell(s) => s.activity(), + _ => shuma_module_shell::Activity::Idle, + } + } + + /// A6 — comandos largos terminados pendientes de acuse en esta sesión (la + /// badge del diente). `0` si no es un shell o no hay nada pendiente. + pub(crate) fn long_alerts(&self) -> usize { + match &self.shell().state { + ModuleState::Shell(s) => s.long_alerts(), + _ => 0, + } + } + + /// A6 — el usuario miró esta sesión: limpia la badge de comando largo. + pub(crate) fn ack_long_alerts(&mut self) { + if let ModuleState::Shell(s) = &mut self.shell_mut().state { + s.ack_long_alerts(); + } + } + + pub(crate) fn port_num(&self) -> u16 { + self.port.text().trim().parse().unwrap_or(22) + } + + pub(crate) fn resolve_source(&self) -> Source { + match self.isolation { + Isolation::Local => match (self.use_container, self.container.clone()) { + (true, Some(name)) => Source::Container { + engine: self.container_engine.clone(), + name, + label: None, + }, + _ => Source::Local, + }, + Isolation::Remote => { + let host = self.host.text(); + let user = self.user.text(); + match (self.use_container, self.container.clone()) { + (true, Some(name)) => Source::RemoteContainer { + host, + user, + port: self.port_num(), + engine: self.container_engine.clone(), + name, + label: None, + }, + _ => Source::Remote { + host, + user, + port: self.port_num(), + label: None, + transporte: if self.remote_pty { + RemoteTransport::SshPty + } else { + RemoteTransport::SshExec + }, + }, + } + } + } + } + + pub(crate) fn apply_isolation(&mut self) { + if self.isolation == Isolation::Local && self.use_container { + if let Some(name) = self.container.clone() { + if matches!(self.container_engine.as_str(), "unshare" | "bwrap") { + prepare_rootfs(std::path::Path::new(&name)); + } + } + } + let source = self.resolve_source(); + self.conn = if self.use_container { + ConnState::Pending + } else { + match self.isolation { + Isolation::Local => ConnState::Connected, + Isolation::Remote => ConnState::Pending, + } + }; + *self.shell_mut() = Instance::shell(self.name.clone(), source.clone()); + self.matilda = Instance::matilda(self.name.clone(), source.clone()); + self.source = source; + } + + pub(crate) fn instance(&self, w: Which) -> &Instance { + match w { + Which::Shell => self.shell(), + Which::Canvas => &self.canvas, + Which::Matilda => &self.matilda, + Which::Pane(id) => self.workspace.pane(id).unwrap_or_else(|| self.shell()), + } + } + + pub(crate) fn instance_mut(&mut self, w: Which) -> &mut Instance { + match w { + Which::Shell => self.shell_mut(), + Which::Canvas => &mut self.canvas, + Which::Matilda => &mut self.matilda, + Which::Pane(id) => { + if self.workspace.pane(id).is_some() { + self.workspace.pane_mut(id).unwrap() + } else { + self.shell_mut() + } + } + } + } + + pub(crate) fn to_config(&self) -> SessionConfig { + SessionConfig { + name: self.name.clone(), + number: self.number, + isolation: self.isolation, + distro: self.distro, + container: self.container.clone(), + use_container: self.use_container, + container_engine: self.container_engine.clone(), + mount: self.mount.text(), + host_label: self.host_label.clone(), + host: self.host.text(), + user: self.user.text(), + port: self.port.text(), + persist: self.persist, + remote_pty: self.remote_pty, + appearance: self.appearance.clone(), + } + } + + pub(crate) fn from_config(c: SessionConfig) -> Self { + use crate::env::binary_disponible; + let kind = match c.isolation { + Isolation::Remote => SessionKind::Remote, + Isolation::Local => SessionKind::Local, + }; + let source = match c.isolation { + Isolation::Local => Source::Local, + Isolation::Remote => default_shell_source(), + }; + let mut s = Session::build(c.name, kind, c.number, source); + s.isolation = c.isolation; + s.distro = c.distro; + s.container = c.container; + s.use_container = c.use_container; + if !c.container_engine.is_empty() && binary_disponible(&c.container_engine) { + s.container_engine = c.container_engine; + } else if let Some(pref) = engine_preferido() { + s.container_engine = pref.to_string(); + } + s.mount.set_text(c.mount); + s.host_label = c.host_label; + s.host.set_text(c.host); + s.user.set_text(c.user); + if !c.port.is_empty() { + s.port.set_text(c.port); + } + s.persist = c.persist; + s.remote_pty = c.remote_pty; + s.appearance = c.appearance; + s.apply_isolation(); + s + } + + pub(crate) fn remote_field_mut(&mut self, f: RemoteField) -> &mut TextInputState { + match f { + RemoteField::Host => &mut self.host, + RemoteField::User => &mut self.user, + RemoteField::Port => &mut self.port, + } + } + + pub(crate) fn connect_remote(&mut self) { + if self.host.text().trim().is_empty() || self.user.text().trim().is_empty() { + return; + } + let source = self.resolve_source(); + *self.shell_mut() = Instance::shell(self.name.clone(), source.clone()); + self.matilda = Instance::matilda(self.name.clone(), source.clone()); + self.source = source; + self.conn = ConnState::Connected; + } + + pub(crate) fn reconnect(&mut self) { + if self.host_label.is_some() { + self.connect_remote(); + } else { + self.apply_isolation(); + } + } +} + +// ─── Config persistible ───────────────────────────────────────────── + +#[derive(serde::Serialize, serde::Deserialize, Clone)] +pub(crate) struct SessionConfig { + pub name: String, + #[serde(default)] + pub number: Option, + pub isolation: Isolation, + pub distro: Distro, + #[serde(default)] + pub container: Option, + #[serde(default)] + pub use_container: bool, + #[serde(default)] + pub container_engine: String, + #[serde(default)] + pub mount: String, + #[serde(default)] + pub host_label: Option, + #[serde(default)] + pub host: String, + #[serde(default)] + pub user: String, + #[serde(default)] + pub port: String, + #[serde(default)] + pub persist: bool, + /// Transporte remoto: `true` = canal SSH con PTY (interactivos andan). + #[serde(default)] + pub remote_pty: bool, + /// Perfil de apariencia propio de la sesión (la "ventana"). `None` = global. + #[serde(default)] + pub appearance: Option, +} + +/// Estado de chrome persistible. +#[derive(serde::Serialize, serde::Deserialize, Clone)] +pub(crate) struct ChromeState { + #[serde(default)] + pub active_tool: Option, + #[serde(default = "yes")] + pub session_panel_open: bool, + #[serde(default)] + pub active_session: usize, + #[serde(default = "default_session_w")] + pub session_w: f32, + #[serde(default = "default_monitors_width")] + pub monitors_width: f32, +} + +fn yes() -> bool { true } +fn default_session_w() -> f32 { 240.0 } +fn default_monitors_width() -> f32 { crate::MONITORS_INITIAL_WIDTH } + +impl Default for ChromeState { + fn default() -> Self { + Self { + active_tool: None, + session_panel_open: true, + active_session: 0, + session_w: default_session_w(), + monitors_width: default_monitors_width(), + } + } +} + +/// Una **disposición guardada** (estilo "sesión de tmux"). +#[derive(serde::Serialize, serde::Deserialize, Clone)] +pub(crate) struct LayoutSnapshot { + pub name: String, + pub sessions: Vec, + pub chrome: ChromeState, +} + +// ─── Mensajes del chasis ──────────────────────────────────────────── + +#[derive(Debug, Clone)] +pub(crate) enum ModuleMsg { + Launcher(shuma_module_launcher::Msg), + CommandBar(shuma_module_commandbar::Msg), + #[allow(dead_code)] + Shell(shuma_module_shell::Msg), + Matilda(shuma_module_matilda::Msg), + Minga(shuma_module_minga::Msg), + Canvas(shuma_module_canvas::Msg), +} + +/// Identifica de dónde viene un `ModuleMsg`. +#[derive(Debug, Clone)] +pub(crate) enum Slot { + TopBar, + BottomBar, + #[allow(dead_code)] + Main, + Session(usize, Which), +} + +// ─── Perfiles ─────────────────────────────────────────────────────── + +/// Cuál de las tres bibliotecas de perfiles está mirando/gestionando el modal. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum ProfKind { + /// Atajos del workspace (globales). + Shortcuts, + /// Apariencia (global + por sesión). + Appearance, + /// Perfiles de sesión (contextos tipo Firefox). + Sessions, +} + +// ─── Modelo ───────────────────────────────────────────────────────── + +/// En qué estado está una sesión **respecto de tus pestañas** — la pregunta que +/// el gestor tiene que contestar de un vistazo ("¿cuáles cerré y cuáles no?"). +/// El orden del enum es el orden en que se agrupan las filas. +#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord)] +pub enum EstadoTab { + /// Montada en una pestaña de ESTA ventana ahora mismo. La estás viendo (o + /// la ves con un clic). + EnPestana, + /// Viva y con alguien adjunto, pero no en esta ventana: otra ventana de + /// shuma, un `shuma pty attach` desde un tty, el móvil. + EnOtroCliente, + /// Viva y sin nadie adjunto: **la cerraste** y siguió corriendo. Es el + /// grupo que da sentido al gestor. + AlFondo, + /// El proceso terminó; la sesión queda en el registro hasta que se reapea. + Terminada, +} + +impl EstadoTab { + pub fn titulo_grupo(self) -> &'static str { + match self { + Self::EnPestana => "Abiertas en una pestaña", + Self::EnOtroCliente => "Abiertas en otro cliente", + Self::AlFondo => "Al fondo — las cerraste, siguen corriendo", + Self::Terminada => "Terminadas", + } + } +} + +/// Una fila del gestor de sesiones (taskmanager): instantánea de una sesión +/// persistente del daemon. La vista la lee sin hacer IO; se refresca al abrir +/// el gestor y tras cada acción. Ver las variantes `Msg::TaskManager*`. +#[derive(Clone)] +pub struct TaskRow { + /// ULID de la sesión, como string (para viajar en el `Msg`). + pub id: String, + pub label: String, + pub program: String, + /// Comando completo (`program` + args), para el subtítulo. + pub cmd: String, + pub cwd: String, + /// Cómo se llama la sesión **según el programa que corre adentro**: el + /// título OSC que puso (`nvim src/lib.rs`, `sergio@host: ~/dir`). `None` si + /// nunca puso ninguno — recién ahí se cae al comando. + pub titulo_osc: Option, + /// Las últimas filas de la pantalla, en texto: la **miniatura**. Vacío si el + /// daemon no contestó o la sesión no escribió nada. + pub preview: Vec, + pub alive: bool, + /// Código de salida si ya terminó. + pub exit_code: Option, + /// Conexiones adjuntas (>0 = abierta en alguna tab ahora mismo). + pub attached: u32, + /// Epoch ms de creación de la sesión en el daemon (antigüedad). + pub created_ms: u64, + /// Si está montada en una pestaña de esta ventana: (índice de sesión, + /// índice de tab, nombre de la sesión) — para el rótulo «pestaña 3 de + /// trabajo» y para el botón «Ir». + pub en_tab: Option<(usize, usize, String)>, +} + +impl TaskRow { + /// El rótulo de la fila: lo que el programa dice ser > el comando > el + /// label con que se creó. NUNCA se recorta acá — la vista decide dónde + /// poner los puntos suspensivos según el ancho que tenga. + pub fn titulo(&self) -> String { + if let Some(t) = self.titulo_osc.as_deref().map(str::trim).filter(|t| !t.is_empty()) { + return t.to_string(); + } + let cmd = self.cmd.trim(); + if !cmd.is_empty() { + return cmd.to_string(); + } + if !self.label.trim().is_empty() { + return self.label.trim().to_string(); + } + self.program.clone() + } + + pub fn estado(&self) -> EstadoTab { + if !self.alive { + EstadoTab::Terminada + } else if self.en_tab.is_some() { + EstadoTab::EnPestana + } else if self.attached > 0 { + EstadoTab::EnOtroCliente + } else { + EstadoTab::AlFondo + } + } +} + +pub struct Model { + pub theme: Theme, + + /// `true` cuando shuma corre como **barra dockeada** (superficie + /// wlr-layer-shell vía `llimphi-layer`), no como ventana. La fija `init` + /// según el env `SHUMA_DOCK`. En modo dock la vista es compacta (la + /// command-bar). Cuando shuma se hospeda en pata (módulo), queda `false`. + pub dock_mode: bool, + /// `true` cuando shuma se hospeda en el **drawer de pata**: se pinta SIN + /// chrome — sin menubar, sin rails/paneles laterales, sólo el canvas a todo + /// lo ancho. Lo fija el host (pata) tras `new()`. Por defecto `false`. + pub chromeless: bool, + /// `true` si, en modo ventana, al perder el foco shuma debe replegarse a la + /// barra dockeada (env `SHUMA_BAR_ON_BLUR`). Opt-in: por defecto `false`. + pub collapse_on_blur: bool, + + /// Perfiles de **atajos** del workspace (globales, conmutables con un clic). + pub shortcuts: crate::perfiles::shortcuts::ShortcutProfiles, + /// Perfiles de **apariencia** (default global; cada sesión puede fijar el suyo). + pub appearance: crate::perfiles::appearance::AppearanceProfiles, + /// Índice de **perfiles de sesión** (contextos tipo Firefox). + pub session_profiles: crate::perfiles::sessions::SessionProfiles, + /// `true` mientras se esperó el prefijo de un keymap con prefijo (tmux/vim). + /// Transitorio, no se persiste. + pub pending_prefix: bool, + /// Modal de gestión de perfiles abierto. + pub perfiles_modal_open: bool, + /// Pestaña activa del modal de perfiles. + pub perfiles_tab: ProfKind, + /// Campo de nombre del modal de perfiles (crear/duplicar/renombrar). + pub prof_name: TextInputState, + /// `true` si el campo de nombre del modal de perfiles tiene foco. + pub prof_name_focused: bool, + /// Wallpaper decodificado de la apariencia efectiva (cacheado; clon barato + /// por frame). `None` = sin wallpaper. Lo refresca `apply_active_appearance`. + pub wallpaper_img: Option, + /// Path del wallpaper cacheado — para no re-decodificar si no cambió. + pub wallpaper_path: Option, + /// Campo del modal de perfiles para escribir el path del wallpaper. + pub wp_path: TextInputState, + /// `true` si el campo de wallpaper tiene foco. + pub wp_path_focused: bool, + /// Patrón del fondo procedural propio de shuma (o `None`). Lo fija + /// `apply_active_appearance` desde la apariencia efectiva; sólo `parpados` + /// se anima. Cuando hay imagen en `wallpaper_img`, la imagen gana. + pub bg_pattern: Option, + /// El fondo procedural del frame actual, ya decodificado. Lo regenera el + /// `Tick` (mucho más lento que el compositor) si `bg_pattern` está animado. + pub bg_procedural_img: Option, + + pub topbar: Option, + pub bottombar: Option, + pub main: Option, + + pub sessions: Vec, + pub active_session: usize, + pub hovered_session: Option, + pub active_tool: Option, + /// Gestor de sesiones (taskmanager) abierto: el drawer pinta el gestor en + /// lugar de los panes de la tab activa. Transitorio (no se persiste). + pub taskmanager_open: bool, + /// Instantánea de las sesiones del daemon para el gestor (refrescada al + /// abrir y tras cada acción; la vista la lee sin hacer IO). + pub task_rows: Vec, + /// Hay un refresco del gestor en vuelo (el worker está hablando con el + /// daemon). La vista lo dice en el encabezado en vez de mentir con una + /// lista vieja o congelar el hilo de UI. + pub task_cargando: bool, + /// Desplazamiento vertical de la lista del gestor (px). Con miniaturas, tres + /// o cuatro tarjetas llenan el drawer: sin scroll el resto quedaba invisible. + pub task_scroll: f32, + pub session_panel_open: bool, + pub dropdown_open: Option, + pub containers: Vec, + pub remote_containers: Vec, + pub remote_new_distro: Distro, + pub containers_full: Vec, + pub container_cfgs: Vec, + pub focused_field: Option, + pub hosts: Vec, + pub host_draft: Option, + pub container_draft: Option, + pub hosts_modal_open: bool, + pub containers_modal_open: bool, + pub layouts: Vec, + pub layouts_modal_open: bool, + /// Listado del Explorer para sesiones remotas (off-thread por SSH). + pub explorer: ExplorerCache, + /// Resultados de `:buscar-archivos` a mostrar en el panel del Explorer (en + /// vez del cwd). `None` = sin búsqueda activa. + pub file_search: Option, + pub layout_name: TextInputState, + pub layout_name_focused: bool, + pub viewport: (f32, f32), + /// La caja donde el HOST ancla el overlay (menús contextuales y modales), + /// cuando no coincide con [`Self::viewport`]. En la ventana propia son lo + /// mismo y esto va en `None`; **hospedada en el drawer de pata**, en cambio, + /// el cuerpo de shuma ocupa una franja de la pantalla pero el overlay se + /// monta sobre la **surface entera** (que es donde caen las coordenadas del + /// puntero). Sin distinguirlas, el menú se posicionaba y se volteaba contra + /// una caja que no era la suya. Se lee con [`Model::overlay_viewport`]. + pub overlay_box: Option<(f32, f32)>, + + pub session_w: f32, + pub sysmon: SystemSampler, + pub last_snapshot: Option, + pub monitors_width: f32, + /// Estado del **sidebar unificado IZQUIERDO** (widget `rag-sidebar`): el + /// selector de sesiones. Guarda los 4 ejes de disposición + el buscador + + /// el control + el ancho del panel (`panel_w`, espejado a `session_w`). El + /// diente abierto/seleccionado lo fuerza la vista a `active_session`; el + /// panel se muestra según `session_panel_open`. + pub sidebar_left: llimphi_widget_rag_sidebar::RagSidebarState, + /// Estado del **sidebar unificado DERECHO** (widget `rag-sidebar`): el dock + /// de herramientas (History/Monitor/Explorer/Matilda/Agente). El diente + /// abierto lo fuerza la vista a `active_tool`; su `panel_w` se espeja a + /// `monitors_width`. + pub sidebar_right: llimphi_widget_rag_sidebar::RagSidebarState, + pub extra_history: HashMap>, + pub extra_display: HashMap, + pub _wawa_watcher: Option, + + pub menu_open: Option, + pub menu_active: usize, + pub menu_anim: Tween, + pub ctx_menu: Option<(f32, f32)>, + /// Menú contextual de una tab abierto: (índice de tab, x, y). + pub tab_ctx: Option<(usize, f32, f32)>, + /// Renombrado de una tab en curso: qué tab y el texto tipeado. `None` = + /// sin renombrado abierto. El nombre vive en `WsTab::name`; confirmarlo + /// vacío lo devuelve al título automático (programa/cwd). + pub tab_rename: Option<(usize, TextInputState)>, + + /// Grupos de environment (env.json) — el panel del sidebar los lista + /// y activa/desactiva en bloque; `:env` los alimenta desde el teclado. + pub env_groups: Vec, + /// mtime de env.json al último load — para recargar si el builtin + /// (u otra instancia) lo tocó. + pub env_groups_mtime: Option, + /// Fase del parpadeo de las pestañas, en pasos de `SHELL_TICK` (100 ms). + /// Sólo avanza cuando hay algo que animar (un aviso pendiente o caudal + /// vivo): un contador que corre siempre haría repintar la barra para + /// siempre, que es exactamente la clase de gasto que venimos podando. + pub pulso_fase: u64, + /// Contador de Msg::Tick (1 s) — debounce del autosave de outputs. + pub tick_count: u64, + + /// `true` cuando un host (pata) muestra el **input de la sesión activa en su + /// propia barra** (vía `active_input_view`): el canvas entonces pinta el + /// cuerpo del shell SIN su input, para no duplicarlo. Default `false` + /// (standalone: input dentro del canvas, como siempre). + pub hosted_bar: bool, + + pub _host: Option, + + /// Último diente activo reportado al rail hospedado de pata (índice de + /// `active_tool` en `Tool::ALL`, o `None`). Evita reenviar el mismo estado en + /// cada tick: sólo se manda `SetActive` cuando cambia. Inerte sin `_host`. + pub host_active_synced: Option, + + /// Estado del panel de chat multi-agente (diente `Tool::Agente`). Único para + /// el chasis (no por sesión). Lo alimenta `agente_almacen`. + pub agente: shuma_module_agente::State, + /// Almacén persistente de agentes y conversaciones; `None` si no se pudo + /// abrir (el panel sigue funcionando en memoria). + pub agente_almacen: Option, + + /// Runtime tokio dedicado a la **voz** (el bucle Elm no es tokio; la captura + /// + el lazo VAD→STT viven aquí). Vivo mientras el micrófono escucha. + pub _voz_rt: Option, + /// Guardia de la captura de voz: soltarla corta el micrófono y las tasks. + /// Se dropea **antes** que `_voz_rt` al apagar (orden explícito en `parar_voz`). + pub _voz_guardia: Option, + /// Runtime tokio dedicado a la **lectura TTS** de las respuestas. Es aparte + /// del de la captura porque leer funciona con el micrófono apagado; se crea + /// perezosamente la primera vez que hay algo que leer y persiste. + pub _voz_tts_rt: Option, + /// Locutor (TTS) del híbrido (mock/local/nube), construido una vez desde la + /// config del SO (`ai.voz.tts`) y reusado en cada lectura. + pub _voz_locutor: Option>, + /// A qué **superficie** apunta la captura de voz activa: el panel de chat, el + /// input del shell de una sesión, o la command-bar. Hay un solo micrófono a la + /// vez; este campo dice a quién enrutar los `EventoEscucha` (dictado + estado). + /// `None` = micrófono apagado. + pub voz_target: Option, + + /// Eventos recientes del centro willay (notificaciones y demás), fuente de la + /// **marquesina**: se narran en el input de la command-bar en reposo. Los + /// alimenta un hilo suscripto al daemon willay. + pub marquesina_eventos: Vec, + /// Qué evento narrable se muestra ahora (rota en cada tick). + pub marquesina_idx: usize, + /// Contador de fase para el parpadeo de los avisos urgentes (avanza por tick). + pub marquesina_fase: u8, + + /// Portapapeles del sistema, compartido por copiar/cortar/pegar en los + /// campos de texto de los modales (hosts/contenedor/layouts/perfiles). + /// Degrada a no-op sin display. Ver [`TextInputState::handle`]. + pub clipboard: llimphi_clipboard::SystemClipboard, +} + +/// A qué superficie enruta la captura de voz activa. Un solo micrófono a la vez; +/// el toggle de cada barra fija el destino. Ver [`Model::voz_target`]. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub(crate) enum VozTarget { + /// El panel de chat multi-agente (`shuma-module-agente`). + Agente, + /// El input del shell de la sesión `idx` (`shuma-module-shell`) — la barra + /// de mando que hospeda pata. + Shell(usize), + /// La command-bar (palette Cmd-P del BottomBar del chasis standalone). + CommandBar, +} + +impl Model { + /// La caja contra la que se posicionan y se voltean los overlays (menús + /// contextuales, modales). Es [`Self::overlay_box`] cuando el host la + /// declaró —hospedada en el drawer de pata, la surface entera— y si no el + /// [`Self::viewport`] de la ventana propia, donde ambas coinciden. + pub fn overlay_viewport(&self) -> (f32, f32) { + self.overlay_box.unwrap_or(self.viewport) + } + + pub(crate) fn active(&self) -> Option<&Session> { + self.sessions.get(self.active_session).or_else(|| self.sessions.first()) + } + + /// El [`shuma_module_shell::State`] del shell de la sesión ACTIVA, para que el + /// host (pata) pinte su completado flotante con los MISMOS datos que navega el + /// teclado. `None` si la sesión activa no hospeda un shell (launcher/matilda/…). + pub fn active_shell_state(&self) -> Option<&shuma_module_shell::State> { + match &self.active()?.shell().state { + ModuleState::Shell(s) => Some(s), + _ => None, + } + } + + /// Empuja el catálogo de **apps lanzables** del host al shell de la sesión + /// activa, si aún no lo tiene — espejo del `asegurar_apps` bare de pata. El + /// completado arma su tier 0 (candidatos-app con ícono) desde `State::apps`; + /// sin esto el modo full ofrecía sólo tokens/historial. El closure sólo se + /// evalúa cuando de verdad faltan (armar el catálogo recorre el registry). + pub fn asegurar_shell_apps(&mut self, apps: F) + where + F: FnOnce() -> Vec, + { + let idx = if self.sessions.get(self.active_session).is_some() { + self.active_session + } else { + 0 + }; + let Some(s) = self.sessions.get_mut(idx) else { return }; + if let ModuleState::Shell(st) = &mut s.shell_mut().state { + if st.apps.is_empty() { + st.apps = apps(); + } + } + } + + pub(crate) fn active_remote_target(&self) -> Option<(String, String, u16, String)> { + let s = self.active()?; + if s.isolation != Isolation::Remote { + return None; + } + let engine = if matches!(s.container_engine.as_str(), "podman" | "docker") { + s.container_engine.clone() + } else { + "podman".to_string() + }; + Some((s.host.text(), s.user.text(), s.port_num(), engine)) + } + + pub(crate) fn session_instance(&self, idx: usize, w: Which) -> Option<&Instance> { + self.sessions.get(idx).map(|s| s.instance(w)) + } + + pub(crate) fn session_instance_mut(&mut self, idx: usize, w: Which) -> Option<&mut Instance> { + self.sessions.get_mut(idx).map(|s| s.instance_mut(w)) + } +} + +// ─── Enum de mensajes de la app ───────────────────────────────────── + +#[derive(Clone)] +pub enum Msg { + Tick, + ShellTick, + Resized(f32, f32), + SelectSession(usize), + HoverSession(Option), + SelectTool(Tool), + /// Mensaje del **sidebar unificado IZQUIERDO** (sesiones): ejes de + /// disposición, buscador, control y resize. Se reduce con + /// `RagSidebarState::update`; el `Activate` de un diente NO llega por aquí + /// (la vista lo intercepta a `SelectSession`). + SidebarLeft(llimphi_widget_rag_sidebar::RagSidebarMsg), + /// Mensaje del **sidebar unificado DERECHO** (herramientas). Igual que el + /// izquierdo; `Activate` se intercepta a `SelectTool`. + SidebarRight(llimphi_widget_rag_sidebar::RagSidebarMsg), + /// Mensaje del panel de chat multi-agente (diente `Tool::Agente`). + Agente(shuma_module_agente::Msg), + ToggleDropdown(DropKind), + DismissDropdown, + SetIsolation(Isolation), + SetDistro(Distro), + Noop, + ToggleContainer, + FocusField(RemoteField), + RemoteKey(llimphi_ui::KeyEvent), + ConnectRemote, + ReconnectSession(usize), + CloseSession(usize), + /// Flag «Persistir sesión» del panel: guarda/restaura el output. + ToggleSessionPersist(usize), + /// Activa/desactiva un grupo de environment (índice en `env_groups`). + ToggleEnvGroup(usize), + OpenNewSessionForm, + ConfirmNewSession, + CancelNewSession, + FocusPendingField(PendingField), + PendingKey(llimphi_ui::KeyEvent), + RefreshContainers, + ContainersLoaded(Vec), + /// Resultado de listar el cwd de una sesión remota (off-thread por SSH). + ExplorerLoaded { + session: usize, + path: String, + result: Result, String>, + }, + /// Fuerza re-listar el cwd remoto del Explorer (botón ↻). + RefreshExplorer, + /// Resultado de `:buscar-archivos` (scope `files`): el chasis lo pinta en el + /// panel del Explorer. `slot` identifica la sesión que la pidió. + FileSearchResult { + slot: Slot, + query: String, + ok: bool, + hits: Vec<(String, f32)>, + }, + /// Limpia la búsqueda de archivos activa (botón ✕ del panel) — el Explorer + /// vuelve a mostrar el cwd. + ClearFileSearch, + /// Abre un archivo (ruta relativa al cwd de la sesión activa, o absoluta) con + /// el visor de la suite elegido **por su contenido** (shuma-discern + app-bus). + OpenFile(String), + RemoteContainersLoaded(Vec), + SubscribeContainer(usize), + PickRemoteContainer(String), + CreateContainer, + ToggleUseContainer, + SetEngine(String), + PickRootfs(Distro), + ContainerCreated(String), + ContainerFailed { name: String, reason: String }, + EnsureContainer(String), + + OpenContainersWindow, + CloseContainersModal, + ContainersFullLoaded(Vec), + RefreshContainersFull, + StartContainer(String), + StopContainer(String), + RemoveContainer(String), + RemoveRootfs(String), + + RefreshRemoteContainers, + SetRemoteNewDistro(Distro), + CreateRemoteContainer, + RemoteStart(String), + RemoteStop(String), + RemoteRemove(String), + + OpenHostsWindow, + CloseHostsModal, + HostDraftStart, + HostEdit(usize), + HostDraftCancel, + HostDraftSave, + HostDraftFocus(HostDraftField), + HostDraftKey(llimphi_ui::KeyEvent), + /// Evento de mouse de un campo del draft de host (click/arrastre). El campo + /// dice cuál; el `Press` además lo enfoca. Ver [`text_input_view_full`]. + HostDraftCampo(HostDraftField, TextInputEvent), + HostDraftToggleAuth, + /// Alterna el transporte del host en edición: PTY sobre SSH vs `exec`. + HostDraftTogglePty, + HostDelete(usize), + + OpenLayoutsModal, + CloseLayoutsModal, + LayoutNameFocus, + LayoutNameKey(llimphi_ui::KeyEvent), + /// Evento de mouse del campo de nombre de disposición (click/arrastre). + LayoutNameCampo(TextInputEvent), + SaveLayout, + RestoreLayout(usize), + DeleteLayout(usize), + + ContainerDraftNew, + ContainerDraftCancel, + ContainerEdit(usize), + ContainerDraftSetEngine(String), + ContainerDraftSetDistro(Distro), + ContainerDraftAddMount, + ContainerDraftRemoveMount(usize), + ContainerDraftToggleMountRo(usize), + ContainerDraftFocusMount(usize, MountCol), + ContainerDraftSave, + ContainerDraftKey(llimphi_ui::KeyEvent), + /// Evento de mouse de un campo de mount del draft de contenedor: fila `i`, + /// columna `col` (host/destino). El `Press` además lo enfoca. + ContainerDraftMountCampo(usize, MountCol, TextInputEvent), + PickHost(Option), + HostApply(usize), + + ReorderSession(usize, usize), + SetSessionWidth(f32), + SetToolWidth(f32), + RunFromHistory(String), + /// El enrolamiento del wake-word terminó (lo dispatcha la task de grabación): + /// cierra la captura y marca el wake-word como listo. + VozEnrolHecho, + /// Un evento de la captura de voz (`rimay-voz-host`): estado de escucha o + /// texto dictado. Lo dispatcha la task de la voz; el handler lo enruta a la + /// superficie activa según [`Model::voz_target`] (chat, shell o command-bar). + VozEvento(rimay_voz_host::EventoEscucha), + /// Eventos recientes del centro willay (los empuja el hilo de la marquesina). + WillayEventos(Vec), + RunFromHistoryNow(String), + Module(Slot, ModuleMsg), + ShortcutClicked(Slot, shuma_module::ShortcutAction), + WawaConfigChanged(Box), + + MenuOpen(Option), + MenuNav(i32), + MenuActivate, + MenuTick, + MenuCommand(String), + ContextMenuOpen(f32, f32), + CloseMenus, + + HostActivate(u32), + + // ─── Perfiles (atajos · apariencia · sesión) ──────────────────── + /// Una acción de atajo resuelta por el keymap activo (directa o tras prefijo). + ShortcutFire(crate::perfiles::shortcuts::ShortcutAction), + /// Se pulsó el prefijo de un keymap con prefijo (tmux/vim): entra en pendiente. + ShortcutEnterPrefix, + /// Tecla suelta tras el prefijo (o cancelación): sale de pendiente. + ShortcutCancelPrefix, + /// Conmuta el perfil de atajos activo (global). + SwitchShortcutProfile(String), + /// Conmuta el perfil de apariencia global (default de toda ventana). + SwitchAppearanceProfile(String), + /// Fija la apariencia de la sesión activa (`None` = como el global). + SetSessionAppearance(Option), + /// Conmuta el perfil de sesión activo (contexto tipo Firefox). + SwitchSessionProfile(String), + /// Abre el modal de gestión de perfiles. + OpenPerfilesModal, + /// Cierra el modal de gestión de perfiles. + ClosePerfilesModal, + /// Cambia la pestaña del modal de perfiles. + PerfilesTab(ProfKind), + /// Foca el campo de nombre del modal de perfiles. + ProfNameFocus, + /// Tecla en el campo de nombre del modal de perfiles. + ProfNameKey(llimphi_ui::KeyEvent), + /// Evento de mouse del campo de nombre del modal de perfiles (click/arrastre). + ProfNameCampo(TextInputEvent), + /// Activa un perfil (lo mismo que conmutarlo) desde el modal. + ProfUse(ProfKind, String), + /// Duplica un perfil con el nombre del campo (o ` copia` si vacío). + ProfDuplicate(ProfKind, String), + /// Renombra un perfil al nombre del campo (sólo perfiles propios). + ProfRename(ProfKind, String), + /// Borra un perfil propio. + ProfDelete(ProfKind, String), + /// Crea un perfil nuevo con el nombre del campo (desde una base sensata). + ProfCreate(ProfKind), + /// Foca el campo de path del wallpaper. + WpPathFocus, + /// Tecla en el campo de path del wallpaper. + WpPathKey(llimphi_ui::KeyEvent), + /// Evento de mouse del campo de path del wallpaper (click/arrastre). + WpPathCampo(TextInputEvent), + /// Fija el wallpaper del perfil de apariencia activo al path del campo. + SetWallpaperActive, + /// Quita el wallpaper del perfil de apariencia activo. + ClearWallpaperActive, + /// Fija el fondo procedural del perfil de apariencia activo (slug de patrón, + /// p.ej. `"parpados"`). + SetProceduralBg(String), + /// Quita el fondo procedural del perfil de apariencia activo. + ClearProceduralBg, + + // ─── Workspace tipo zellij (tabs · tiling · flotantes) ────────── + /// Parte el panel con foco (Horizontal = lado a lado · Vertical = apilado). + PaneSplit(Axis), + /// Pone el foco en un panel concreto (click en el panel). + PaneFocus(PaneId), + /// Cierra el panel con foco. + PaneClose, + /// Cicla el foco entre paneles tiled (true = siguiente). + PaneCycle(bool), + /// Arrastra un divisor del tiling: ajusta el ratio del split por `path`. + PaneResize(Vec, f32), + /// Tab nueva (con un shell fresco). + TabNew, + /// Activa la tab `i`. + TabSwitch(usize), + /// Activa la tab `i` y **después** aplica `msg`. Para las acciones del menú + /// contextual que operan sobre el panel con foco (dividir): sin activar + /// primero, el split le caía a la tab que estabas mirando, no a la que + /// clickeaste con el botón derecho. + TabSwitchThen(usize, Box), + /// Cierra la tab `i`. + TabClose(usize), + /// Cierra todas las tabs menos la `i`. + TabCloseOthers(usize), + /// Cierra las tabs a la DERECHA de la `i` (la `i` y las de su izquierda + /// quedan). Complementa a `TabCloseOthers`, que es más brusco. + TabCloseRight(usize), + /// Duplica la tab `i`: una tab nueva con un shell fresco **en el mismo + /// directorio** (no clona el scrollback, que es historia de la otra). + TabDuplicate(usize), + /// Mueve la tab `i` un lugar hacia la izquierda (`false`) o la derecha + /// (`true`). No-op en los extremos. + TabMove(usize, bool), + /// Abre el renombrado de la tab `i` (el campo arranca con su nombre + /// vigente, o vacío si nunca se le puso uno). + TabRenameOpen(usize), + /// Una tecla al campo de renombrado abierto. + TabRenameKey(llimphi_ui::KeyEvent), + /// Confirma el renombrado: nombre vacío = vuelve al título automático. + TabRenameCommit, + /// Cancela el renombrado sin tocar el nombre. + TabRenameCancel, + /// Abre el menú contextual de la tab `i` en (x, y). + TabCtxOpen(usize, f32, f32), + /// Agrega un panel flotante nuevo. + FloatNew, + /// Enciende/apaga la capa de paneles flotantes. + FloatToggle, + /// Mueve un panel flotante por (dx, dy) px. + FloatMove(PaneId, f32, f32), + + // ─── Gestor de sesiones (taskmanager) ────────────────────────── + /// Abre/cierra el gestor de sesiones en el drawer. + TaskManagerToggle, + /// Refresca la lista de sesiones del gestor (relee el daemon). + TaskManagerRefresh, + /// Restaura la sesión `id` (ULID) del fondo a una tab nueva. + TaskRestore(String), + /// Mata la sesión `id` (ULID) desde el gestor. + TaskKill(String), + /// Salta a la pestaña donde ya está montada la sesión `id` (ULID) — para + /// las filas del grupo «abiertas en una pestaña», donde restaurar sería + /// abrir un duplicado de algo que ya tenés. + TaskGoTo(String), + /// El worker terminó de leer el daemon: filas listas (con miniatura y + /// título). Llega fuera del hilo de UI, ver `refrescar_task_rows`. + TaskRowsReady(Vec), + /// Rueda/arrastre sobre la lista del gestor: delta en px a acumular. + TaskScrollBy(f32), +} diff --git a/02_ruway/shuma/shuma-shell-llimphi/src/update.rs b/02_ruway/shuma/shuma-shell-llimphi/src/update.rs index db37efb..ce195ba 100644 --- a/02_ruway/shuma/shuma-shell-llimphi/src/update.rs +++ b/02_ruway/shuma/shuma-shell-llimphi/src/update.rs @@ -2,6 +2,66 @@ use super::*; +/// La instancia-módulo que direcciona un `Slot` (compartida por todos los +/// lookups). `Slot::Session(i, w)` resuelve a la vista `w` de la sesión `i`. +pub(crate) fn instance_for_slot<'a>(m: &'a Model, slot: &Slot) -> Option<&'a Instance> { + match slot { + Slot::TopBar => m.topbar.as_ref(), + Slot::BottomBar => m.bottombar.as_ref(), + Slot::Main => m.main.as_ref(), + Slot::Session(i, w) => m.session_instance(*i, *w), + } +} + +pub(crate) fn instance_for_slot_mut<'a>(m: &'a mut Model, slot: &Slot) -> Option<&'a mut Instance> { + match slot { + Slot::TopBar => m.topbar.as_mut(), + Slot::BottomBar => m.bottombar.as_mut(), + Slot::Main => m.main.as_mut(), + Slot::Session(i, w) => m.session_instance_mut(*i, *w), + } +} + +/// Mantiene el cache del Explorer (`m.explorer`) coherente con la sesión y +/// el cwd actuales **cuando el panel está abierto sobre una sesión remota**. +/// Para sesiones locales limpia el cache (el panel cae a `read_dir` directo). +/// Idempotente: sólo dispara un listado off-thread cuando la clave +/// `(sesión, cwd)` cambia — se puede llamar en cada tick sin re-spawnear. +pub(crate) fn reconcile_explorer(m: &mut Model, handle: &Handle) { + if m.active_tool != Some(Tool::Explorer) { + return; + } + // ¿La sesión activa se lista por la vía remota (SSH)? Sólo si está + // conectada — si no, evitamos intentos de ssh inútiles. + let remoto = m.active().and_then(|s| { + if s.conn != ConnState::Connected { + return None; + } + match &s.shell().state { + ModuleState::Shell(sh) + if matches!(sh.source, Source::Remote { .. } | Source::RemoteContainer { .. }) => + { + Some((sh.source.clone(), sh.cwd.display().to_string())) + } + _ => None, + } + }); + let Some((source, cwd)) = remoto else { + // Local / no conectada: el panel usa `read_dir`; tiramos cache viejo. + if m.explorer.key.is_some() { + m.explorer = ExplorerCache::default(); + } + return; + }; + let key = (m.active_session, cwd.clone()); + if m.explorer.key.as_ref() == Some(&key) { + return; // ya cargado/cargando para esta (sesión, cwd) + } + m.explorer.key = Some(key); + m.explorer.state = ExplorerState::Loading; + spawn_explorer_list(handle, m.active_session, source, cwd); +} + /// Enruta un `ModuleMsg` al `update` del módulo correspondiente, y se /// encarga de interceptar mensajes que el chasis quiera promocionar /// (p. ej. el click en la command bar abre el drawer). @@ -16,43 +76,67 @@ pub(crate) fn apply_module_msg(mut m: Model, slot: Slot, msg: ModuleMsg) -> Mode // la enfocamos. La variante NO se propaga al canvas — el canvas // solo emite la intención. if let ModuleMsg::Canvas(shuma_module_canvas::Msg::InsertRef(text)) = &msg { - if let Some(target) = first_shell_slot(&m) { - let insert_msg = - ModuleMsg::Shell(shuma_module_shell::Msg::InsertAtCursor(text.clone())); - if let Slot::Tab(i) = &target { - m.active_tab = *i; - } - return apply_module_msg(m, target, insert_msg); - } - // Sin shell activo: el pedido se descarta silencioso. - return m; + // El shell de la sesión activa (el canvas) recibe la inserción. + let insert_msg = + ModuleMsg::Shell(shuma_module_shell::Msg::InsertAtCursor(text.clone())); + let target = Slot::Session(m.active_session, Which::Shell); + return apply_module_msg(m, target, insert_msg); } - match slot { - Slot::TopBar => { - if let Some(inst) = m.topbar.as_mut() { - route_to_instance(inst, msg); - } - } - Slot::BottomBar => { - if let Some(inst) = m.bottombar.as_mut() { - route_to_instance(inst, msg); - } - } - Slot::Main => { - if let Some(inst) = m.main.as_mut() { - route_to_instance(inst, msg); - } - } - Slot::Tab(idx) => { - if let Some(inst) = m.tabs.get_mut(idx) { - route_to_instance(inst, msg); - } + let mut nuevo_tab_cmd = None; + let mut nuevo_tab_source = None; + if let Some(inst) = instance_for_slot_mut(&mut m, &slot) { + route_to_instance(inst, msg); + // Hook: el shell pidió «ejecutar la selección en un tab nuevo» (pick del + // menú contextual del output). El módulo no tiene tabs — lo resolvemos + // acá, igual que el `InsertRef` de arriba. + if let ModuleState::Shell(s) = &mut inst.state { + nuevo_tab_cmd = s.take_new_tab_cmd(); + nuevo_tab_source = s.take_new_tab_source(); } } + if let Some(cmd) = nuevo_tab_cmd { + m = nueva_tab_con_comando(m, cmd); + } + if let Some((source, etiqueta)) = nuevo_tab_source { + m = nueva_tab_con_source(m, source, etiqueta); + } m } +/// Abre una tab de workspace fresca **contra otro origen** — la resolución de +/// `:ssh `. La tab nueva no hereda el `source` de la sesión (que es el +/// caso normal de `TabNew`): apunta al host pedido, así una sesión local puede +/// tener tabs remotas al lado sin crear una sesión aparte. +fn nueva_tab_con_source(mut m: Model, source: Source, etiqueta: String) -> Model { + if let Some(s) = m.sessions.get_mut(m.active_session) { + if s.pending { + return m; + } + s.workspace.new_tab(Instance::shell(etiqueta, source)); + } + m +} + +/// Abre una tab de workspace fresca en la sesión activa y corre `cmd` en ella +/// (mismo efecto que `Msg::TabNew` seguido de `Msg::RunLine`). La usa el hook de +/// «Ejecutar en nuevo tab» del menú contextual del output. No-op si la sesión +/// activa está en form de creación (pending). +fn nueva_tab_con_comando(mut m: Model, cmd: String) -> Model { + if let Some(s) = m.sessions.get_mut(m.active_session) { + if s.pending { + return m; + } + let inst = Instance::shell(s.name.clone(), s.source.clone()); + s.workspace.new_tab(inst); + } else { + return m; + } + // La nueva tab queda con foco: su shell es el `focused_instance` de la sesión. + let slot = Slot::Session(m.active_session, Which::Shell); + apply_module_msg(m, slot, ModuleMsg::Shell(shuma_module_shell::Msg::RunLine(cmd))) +} + /// Mapea una entrada genérica `SlotEntry` del shumarc a una `Instance`. /// `None` si el `module` no matchea ningún `Kind` compilado — se /// imprime warning en lugar de fallar para no romper el arranque. @@ -66,15 +150,6 @@ pub(crate) fn resolve_slot(entry: Option<&config::SlotEntry>) -> Option Option { - resolve_instance( - &entry.id, - entry.source.clone(), - entry.label.clone(), - entry.inventory.as_deref(), - ) -} - pub(crate) fn resolve_instance( id: &str, source: Source, @@ -161,8 +236,12 @@ pub(crate) fn collect_contributions(model: &Model) -> Vec<(Slot, ModuleContribut if let Some(inst) = &model.main { push(&mut out, Slot::Main, inst); } - for (i, inst) in model.tabs.iter().enumerate() { - push(&mut out, Slot::Tab(i), inst); + // Monitores/shortcuts de la sesión activa (sus tres vistas). + let i = model.active_session; + if let Some(s) = model.sessions.get(i) { + push(&mut out, Slot::Session(i, Which::Shell), s.shell()); + push(&mut out, Slot::Session(i, Which::Canvas), &s.canvas); + push(&mut out, Slot::Session(i, Which::Matilda), &s.matilda); } out } @@ -194,6 +273,29 @@ pub(crate) fn sample_extra_monitors(m: &mut Model) { /// Después de drenar, sincroniza el `intent_graph` de la primera shell /// encontrada hacia todas las instancias `Canvas` activas — el lienzo /// de contexto refleja en tiempo real los `%cN`/`%pN` del shell. +/// Publica una notificación de escritorio de una pestaña **no visible** al +/// centro willay (A1). El `origen` lleva el título de la pestaña para que el +/// usuario sepa de cuál vino («shuma · »). `emitir_silencioso` no-opea si +/// el daemon willay no está corriendo — no bloquea ni falla. +fn publicar_aviso_willay(titulo_tab: &str, n: shuma_module_shell::campana::Notificacion) { + // OSC 9 manda sólo cuerpo (sin título): en ese caso el cuerpo es la línea + // principal, y el `titulo` de la notif queda vacío. + let (titulo, cuerpo) = if n.titulo.is_empty() { + (n.cuerpo, String::new()) + } else { + (n.titulo, n.cuerpo) + }; + let ev = willay_core::Evento::nuevo( + willay_core::Clase::Notificacion, + willay_emit::ahora_usec(), + format!("shuma · {titulo_tab}"), + titulo, + cuerpo, + willay_core::Payload::Nada, + ); + willay_emit::emitir_silencioso(&ev); +} + pub(crate) fn drain_shell_instances(m: &mut Model) { fn tick_one(inst: &mut Instance) { if let ModuleState::Shell(s) = &mut inst.state { @@ -209,98 +311,432 @@ pub(crate) fn drain_shell_instances(m: &mut Model) { if let Some(inst) = m.main.as_mut() { tick_one(inst); } - for inst in m.tabs.iter_mut() { - tick_one(inst); - } - sync_canvas_from_primary_shell(m); -} - -/// Toma el `intent_graph` de la primera instancia `Shell` encontrada -/// (en orden: topbar, bottombar, main, drawer tabs) y lo empuja a cada -/// instancia `Canvas` activa vía `Msg::SyncGraph`. Si no hay shells, el -/// canvas mantiene lo último que tenía (incluyendo su grafo de demo). -pub(crate) fn sync_canvas_from_primary_shell(m: &mut Model) { - let snapshot = find_primary_shell_graph(m); - let Some(graph) = snapshot else { return }; - let sync_one = |inst: &mut Instance| { - if let ModuleState::Canvas(s) = &mut inst.state { - *s = shuma_module_canvas::update( - s.clone(), - shuma_module_canvas::Msg::SyncGraph(graph.clone()), + // Cada sesión drena TODOS sus paneles (tiling + flotantes, de toda tab — + // los de fondo siguen produciendo output) y sincroniza su lienzo desde el + // shell con foco. + let activa = m.active_session; + for (idx, s) in m.sessions.iter_mut().enumerate() { + s.workspace.for_each_pane_mut(tick_one); + // Avisos por pestaña, con los paneles ya drenados: la pestaña que el + // usuario está mirando acusa recibo; las demás levantan su aviso por + // flanco (campana / el asistente espera / terminó un comando). + let en_pantalla = idx == activa; + let ws_activa = s.workspace.active_tab; + for (t, tab) in s.workspace.tabs.iter_mut().enumerate() { + let visible = en_pantalla && t == ws_activa; + // A1 — las notificaciones de escritorio (OSC 9/777/99) que pidió un + // programa de una pestaña que NO se ve las despliega willay: el + // usuario no está mirando esa pestaña, así que el aviso va al centro + // de notificaciones. Las de la pestaña visible se descartan (ya se + // ven). En cualquier caso se DRENAN — si no, se acumulan sin fin. + let titulo_tab = tab.titulo(t); + for inst in tab.panes.values_mut() { + if let ModuleState::Shell(sh) = &mut inst.state { + let notifs = sh.tomar_notificaciones(); + if !visible { + for n in notifs { + publicar_aviso_willay(&titulo_tab, n); + } + } + } + } + tab.refrescar_aviso(visible); + } + let graph = match &s.shell().state { + ModuleState::Shell(sh) => Some(sh.intent_graph().clone()), + _ => None, + }; + if let (Some(graph), ModuleState::Canvas(c)) = (graph, &mut s.canvas.state) { + *c = shuma_module_canvas::update( + c.clone(), + shuma_module_canvas::Msg::SyncGraph(graph), ); } - }; - if let Some(inst) = m.topbar.as_mut() { - sync_one(inst); - } - if let Some(inst) = m.bottombar.as_mut() { - sync_one(inst); - } - if let Some(inst) = m.main.as_mut() { - sync_one(inst); - } - for inst in m.tabs.iter_mut() { - sync_one(inst); } } -/// Slot del primer `Shell` activo siguiendo el mismo orden que -/// `find_primary_shell_graph`. Lo usa el hook de `Msg::Canvas(InsertRef)` -/// para encontrar a quién enrutarle el `InsertAtCursor`. -pub(crate) fn first_shell_slot(m: &Model) -> Option { - if matches!( - m.topbar.as_ref().map(|i| &i.state), - Some(ModuleState::Shell(_)) - ) { - return Some(Slot::TopBar); - } - if matches!( - m.bottombar.as_ref().map(|i| &i.state), - Some(ModuleState::Shell(_)) - ) { - return Some(Slot::BottomBar); - } - if matches!( - m.main.as_ref().map(|i| &i.state), - Some(ModuleState::Shell(_)) - ) { - return Some(Slot::Main); - } - m.tabs.iter().enumerate().find_map(|(i, inst)| { - if matches!(inst.state, ModuleState::Shell(_)) { - Some(Slot::Tab(i)) - } else { - None + +/// M4 — polling de runtime de matilda. Si hay una instancia matilda +/// **Local** montada (slots fijos topbar/bottombar/main), re-observa el +/// runtime (`docker ps` + `systemctl`) en un thread y lo reenvía como +/// `Msg::SetRuntime`. Llamado desde el `Tick` a cadencia lenta (cada 5 s). +/// El remoto no se poll-ea todavía (necesita SSH por tick — futuro). +pub(crate) fn poll_matilda_runtime(m: &Model, handle: &Handle) { + for slot in [Slot::TopBar, Slot::BottomBar, Slot::Main] { + let inst = match slot { + Slot::TopBar => m.topbar.as_ref(), + Slot::BottomBar => m.bottombar.as_ref(), + Slot::Main => m.main.as_ref(), + _ => None, + }; + if let Some(inst) = inst { + if let ModuleState::Matilda(st) = &inst.state { + if !st.source.is_remote() { + let slot_back = slot.clone(); + handle.spawn(move || { + let rt = shuma_module_matilda::poll_runtime(); + Msg::Module( + slot_back, + ModuleMsg::Matilda(shuma_module_matilda::Msg::SetRuntimeQuiet(rt)), + ) + }); + // M2 — y las series CPU/mem, pero sólo si el operador está + // inspeccionando un contenedor (la sparkline sólo se pinta + // bajo el seleccionado, y `docker stats` es caro). + if st.selected_container.is_some() { + let slot_back = slot.clone(); + let source = st.source.clone(); + handle.spawn(move || { + let stats = + shuma_module_matilda::source_stats_remote_blocking(&source) + .unwrap_or_default(); + Msg::Module( + slot_back, + ModuleMsg::Matilda(shuma_module_matilda::Msg::SetStatsQuiet(stats)), + ) + }); + } + } + } } + } +} + +/// M5 — polling periódico de la flota. Si una instancia matilda ya tiene +/// flota poblada (el usuario pulsó «Fleet» al menos una vez), re-observa cada +/// host declarado por SSH en un thread y reenvía el resultado **silencioso** +/// (`SetHostRuntimeQuiet`/`SetHostErrorQuiet`) — refresca el semáforo sin +/// spamear el log ni parpadear a «consultando». Cadencia lenta (cada ~30 s, +/// llamado desde `Tick`). Un guard por host (`fleet_poll_inflight`) evita que +/// un host colgado acumule threads tick tras tick. +pub(crate) fn poll_matilda_fleet(m: &Model, handle: &Handle) { + for slot in [Slot::TopBar, Slot::BottomBar, Slot::Main] { + let inst = match slot { + Slot::TopBar => m.topbar.as_ref(), + Slot::BottomBar => m.bottombar.as_ref(), + Slot::Main => m.main.as_ref(), + _ => None, + }; + let Some(inst) = inst else { continue }; + let ModuleState::Matilda(st) = &inst.state else { continue }; + // Sólo se poll-ea una flota que el usuario ya activó (no abrir SSH + // de rutina sin que lo haya pedido pulsando «Fleet»). + if st.fleet.is_empty() { + continue; + } + let inflight = st.fleet_poll_inflight.clone(); + for host in st.desired.hosts().cloned() { + // Guard anti-apilamiento: si ya hay un fetch en vuelo para este + // host, lo saltamos este tick. + { + let mut set = match inflight.lock() { + Ok(g) => g, + Err(_) => continue, + }; + if !set.insert(host.name.clone()) { + continue; + } + } + let slot_back = slot.clone(); + let inflight = inflight.clone(); + handle.spawn(move || { + let msg = match shuma_module_matilda::host_runtime_remote_blocking(&host) { + Ok(runtime) => shuma_module_matilda::Msg::SetHostRuntimeQuiet { + host: host.name.clone(), + runtime, + }, + Err(error) => shuma_module_matilda::Msg::SetHostErrorQuiet { + host: host.name.clone(), + error, + }, + }; + if let Ok(mut set) = inflight.lock() { + set.remove(&host.name); + } + Msg::Module(slot_back, ModuleMsg::Matilda(msg)) + }); + } + } +} + +/// M4 — polling del runtime del Source montado **remoto**. El `poll_matilda_ +/// runtime` sólo cubre instancias locales (re-observar local es barato); las +/// remotas necesitan SSH, así que se poll-ean a la cadencia lenta (~30 s) y +/// silenciosas (`SetRuntimeQuiet`). Un guard atómico (`runtime_poll_inflight`) +/// evita apilar fetches si el host montado queda colgado. +pub(crate) fn poll_matilda_remote_runtime(m: &Model, handle: &Handle) { + use std::sync::atomic::Ordering; + for slot in [Slot::TopBar, Slot::BottomBar, Slot::Main] { + let inst = match slot { + Slot::TopBar => m.topbar.as_ref(), + Slot::BottomBar => m.bottombar.as_ref(), + Slot::Main => m.main.as_ref(), + _ => None, + }; + let Some(inst) = inst else { continue }; + let ModuleState::Matilda(st) = &inst.state else { continue }; + if !st.source.is_remote() { + continue; + } + let inflight = st.runtime_poll_inflight.clone(); + // compare_exchange false→true: si ya había un fetch en vuelo, saltamos. + if inflight + .compare_exchange(false, true, Ordering::AcqRel, Ordering::Acquire) + .is_err() + { + continue; + } + let source = st.source.clone(); + let want_stats = st.selected_container.is_some(); + let slot_back = slot.clone(); + handle.spawn(move || { + let msg = match shuma_module_matilda::source_runtime_remote_blocking(&source) { + Ok(rt) => shuma_module_matilda::Msg::SetRuntimeQuiet(rt), + // Un fallo de SSH no debe spamear: lo dejamos pasar silencioso + // (el próximo tick reintenta). LogLines vacío = no-op visible. + Err(_) => shuma_module_matilda::Msg::LogLines(Vec::new()), + }; + inflight.store(false, Ordering::Release); + Msg::Module(slot_back, ModuleMsg::Matilda(msg)) + }); + // M2 — series CPU/mem del Source remoto, sólo si hay un contenedor + // bajo inspección (la sparkline sólo se pinta bajo el seleccionado). + if want_stats { + let source = st.source.clone(); + let slot_back = slot.clone(); + handle.spawn(move || { + let stats = shuma_module_matilda::source_stats_remote_blocking(&source) + .unwrap_or_default(); + Msg::Module( + slot_back, + ModuleMsg::Matilda(shuma_module_matilda::Msg::SetStatsQuiet(stats)), + ) + }); + } + } +} + +/// E5 — cumple las peticiones LLM pendientes de los shells montados. El +/// módulo sólo expresa la intención (`State::llm_request`); aquí la tomamos, +/// corremos `pluma-llm` en un thread (con su propio runtime tokio) y +/// devolvemos `Msg::LlmResult`. Sin credenciales, `from_env` cae a Mock — el +/// `:?` funciona igual (respuesta canned), nunca se cuelga. +pub(crate) fn fulfill_llm_requests(m: &mut Model, handle: &Handle) { + fn llm_one(slot: Slot, st: &mut shuma_module_shell::State, handle: &Handle) { + let Some(req) = st.take_llm_request() else { + return; + }; + let kind = req.kind; + let slot_back = slot.clone(); + handle.spawn(move || { + let (ok, text) = match run_llm_blocking(&req) { + Ok(t) => (true, t), + Err(e) => (false, e), + }; + Msg::Module( + slot_back, + ModuleMsg::Shell(shuma_module_shell::Msg::LlmResult { kind, ok, text }), + ) + }); + } + if let Some(Instance { state: ModuleState::Shell(st), .. }) = m.topbar.as_mut() { + llm_one(Slot::TopBar, st, handle); + } + if let Some(Instance { state: ModuleState::Shell(st), .. }) = m.bottombar.as_mut() { + llm_one(Slot::BottomBar, st, handle); + } + if let Some(Instance { state: ModuleState::Shell(st), .. }) = m.main.as_mut() { + llm_one(Slot::Main, st, handle); + } + for (i, sess) in m.sessions.iter_mut().enumerate() { + if let ModuleState::Shell(st) = &mut sess.shell_mut().state { + llm_one(Slot::Session(i, Which::Shell), st, handle); + } + } +} + +/// E6 — despacha la petición del panel de chat (un turno de conversación) a un +/// thread: corre `shuma-agente-host::responder` (resuelve backend del agente o +/// el `[ai.llm]` global de fallback) y devuelve el resultado por `Msg::Agente`. +/// Mismo patrón que [`fulfill_llm_requests`]: el módulo expresó la intención +/// (`State::take_request`), aquí la cumplimos sin colgar el bucle Elm. +pub(crate) fn fulfill_agente_requests(m: &mut Model, handle: &Handle) { + let Some(req) = m.agente.take_request() else { + return; + }; + // Fallback global del SO: la IA resuelta para shuma (ruteo por app «shuma»; + // si no hay override, la principal que se edita en el wawapanel). + let fallback = wawa_config::WawaConfig::load().ai.resolve(None, Some("shuma")).clone(); + let conv_id = req.conv.id.clone(); + let h = handle.clone(); + handle.spawn(move || { + // Streaming: cada fragmento se despacha como Token; la UI lo va pintando. + let cid = conv_id.clone(); + let res = shuma_agente_host::responder_streaming( + &req.conv, + &req.agente, + &fallback, + |delta| { + h.dispatch(Msg::Agente(shuma_module_agente::Msg::Token { + conv_id: cid.clone(), + delta: delta.to_string(), + })); + }, + ); + let (ok, bloques, entrada, salida) = match res { + Ok(r) => (true, r.bloques, r.input_tokens, r.output_tokens), + Err(e) => (false, vec![shuma_agente::BloqueSalida::Error(e)], 0, 0), + }; + Msg::Agente(shuma_module_agente::Msg::Respuesta { conv_id, bloques, ok, entrada, salida }) + }); +} + +/// Corre una petición LLM de forma **bloqueante** (en un thread del chasis): +/// arma el `ChatRequest`, resuelve el backend por env (Mock si no hay +/// credenciales) y devuelve el texto o un mensaje de error. +pub fn run_llm_blocking(req: &shuma_module_shell::LlmRequest) -> Result { + use pluma_llm::pluma_llm_core::ChatRequest; + let rt = tokio::runtime::Builder::new_current_thread() + .enable_all() + .build() + .map_err(|e| format!("runtime: {e}"))?; + rt.block_on(async { + // Backend según `[ai.llm]` del shumarc (configurable en wawa-panel); si + // no se fijó uno, cae a la resolución por env (Mock sin credenciales). + let client = build_llm_client(&req.llm).map_err(|e| format!("sin backend LLM: {e}"))?; + let chat = ChatRequest::una_vuelta(req.prompt.clone(), req.max_tokens) + .con_sistema(req.system.clone()); + client + .complete(&chat) + .await + .map(|r| r.content) + .map_err(|e| format!("{e}")) }) } -pub(crate) fn find_primary_shell_graph(m: &Model) -> Option { - let pick = |inst: &Instance| match &inst.state { - ModuleState::Shell(s) => Some(s.intent_graph().clone()), - _ => None, +/// Construye el cliente LLM desde `[ai.llm]`. Si `backend` está vacío, resuelve +/// por env (`from_env`). Traduce los tipos planos de `shuma-config` al +/// `LlmConfig` de `pluma-llm` (mantiene a `shuma-config` liviano). +fn build_llm_client( + s: &wawa_config::LlmSettings, +) -> Result, String> { + use pluma_llm::{build_client, BackendKind, LlmConfig}; + if !s.is_set() { + return pluma_llm::from_env().map_err(|e| format!("{e}")); + } + let kind = match s.backend.trim().to_lowercase().as_str() { + "anthropic" => BackendKind::Anthropic, + "gemini" => BackendKind::Gemini, + "deepseek" => BackendKind::DeepSeek, + "cohere" => BackendKind::Cohere, + "ollama" => BackendKind::Ollama, + "claude-cli" | "claude-code" => BackendKind::ClaudeCli, + "mock" => BackendKind::Mock, + other => return Err(format!("backend LLM desconocido: «{other}»")), }; - if let Some(inst) = m.topbar.as_ref() { - if let Some(g) = pick(inst) { - return Some(g); + let none_if_empty = |v: &str| { + let v = v.trim(); + (!v.is_empty()).then(|| v.to_string()) + }; + let cfg = LlmConfig { + kind, + model: none_if_empty(&s.model), + api_key: none_if_empty(&s.api_key), + endpoint: none_if_empty(&s.endpoint), + }; + build_client(&cfg).map_err(|e| format!("{e}")) +} + +/// Cumple las búsquedas semánticas pendientes (`:buscar`) de los shells +/// montados. Mismo patrón que [`fulfill_llm_requests`]: el módulo expresa la +/// intención (`State::semantic_request`), aquí la corremos en un thread con +/// `rimay-verbo` (daemon o mock) y devolvemos `Msg::SemanticResult`. +pub(crate) fn fulfill_semantic_requests(m: &mut Model, handle: &Handle) { + fn one(slot: Slot, st: &mut shuma_module_shell::State, handle: &Handle) { + let Some(req) = st.take_semantic_request() else { + return; + }; + let slot_back = slot.clone(); + handle.spawn(move || { + let (ok, hits) = match run_semantic_blocking(&req) { + Ok(h) => (true, h), + Err(e) => (false, vec![(e, 0.0)]), + }; + // `files` va al panel del Explorer (chasis); `history` al output del + // shell (módulo). + if req.scope == "files" { + Msg::FileSearchResult { slot: slot_back, query: req.query.clone(), ok, hits } + } else { + Msg::Module( + slot_back, + ModuleMsg::Shell(shuma_module_shell::Msg::SemanticResult { ok, hits }), + ) + } + }); + } + if let Some(Instance { state: ModuleState::Shell(st), .. }) = m.topbar.as_mut() { + one(Slot::TopBar, st, handle); + } + if let Some(Instance { state: ModuleState::Shell(st), .. }) = m.bottombar.as_mut() { + one(Slot::BottomBar, st, handle); + } + if let Some(Instance { state: ModuleState::Shell(st), .. }) = m.main.as_mut() { + one(Slot::Main, st, handle); + } + for (i, sess) in m.sessions.iter_mut().enumerate() { + if let ModuleState::Shell(st) = &mut sess.shell_mut().state { + one(Slot::Session(i, Which::Shell), st, handle); } } - if let Some(inst) = m.bottombar.as_ref() { - if let Some(g) = pick(inst) { - return Some(g); +} + +/// Corre una búsqueda semántica bloqueante (en un thread del chasis): embebe la +/// consulta + candidatos con el daemon `rimay-verbo` (o un mock determinista si +/// no hay daemon), rankea por coseno y devuelve los mejores `(comando, score)`. +pub fn run_semantic_blocking( + req: &shuma_module_shell::SemanticRequest, +) -> Result, String> { + const TOP_K: usize = 10; + const MIN_SCORE: f32 = 0.20; + let rt = tokio::runtime::Builder::new_current_thread() + .enable_all() + .build() + .map_err(|e| format!("runtime: {e}"))?; + rt.block_on(async { + use crate::semantic::SemanticIndex; + use rimay_verbo::Provider; + let provider = if req.socket.trim().is_empty() { + rimay_verbo::conectar_o_mock(req.dim).await + } else { + rimay_verbo::conectar_o_mock_en(std::path::Path::new(req.socket.trim()), req.dim).await + }; + // Índice persistido por scope (history/files): sólo embebe lo nuevo, poda + // lo caído y se reconstruye solo si cambió el modelo de embeddings. + let mut idx = SemanticIndex::load(SemanticIndex::path_for(&req.scope), provider.model_id().clone()); + idx.ensure(&*provider, &req.candidates).await?; + let keys: Vec = req.candidates.iter().map(|(k, _)| k.clone()).collect(); + idx.retain(&keys); + if let Err(e) = idx.save() { + // No es fatal: la búsqueda igual responde, sólo no cacheó. + eprintln!("shuma · no pude persistir el índice semántico: {e}"); } - } - if let Some(inst) = m.main.as_ref() { - if let Some(g) = pick(inst) { - return Some(g); - } - } - for inst in &m.tabs { - if let Some(g) = pick(inst) { - return Some(g); - } - } - None + let q = provider + .embed(&req.query) + .await + .map_err(|e| format!("embeddings: {e}"))?; + let hits = idx.search(&q, TOP_K, MIN_SCORE); + // En `files` la clave es `mtime\0ruta` (para re-embeber al cambiar el + // archivo); mostramos sólo la ruta. + let hits = if req.scope == "files" { + hits + .into_iter() + .map(|(k, s)| (k.split_once('\0').map(|(_, p)| p.to_string()).unwrap_or(k), s)) + .collect() + } else { + hits + }; + Ok(hits) + }) } pub(crate) fn monitor_key(slot: &Slot, spec: &MonitorSpec) -> String { @@ -308,7 +744,7 @@ pub(crate) fn monitor_key(slot: &Slot, spec: &MonitorSpec) -> String { Slot::TopBar => "topbar", Slot::BottomBar => "bottombar", Slot::Main => "main", - Slot::Tab(i) => return format!("tab:{i}/{}", spec.id), + Slot::Session(i, w) => return format!("session:{i}:{w:?}/{}", spec.id), }; format!("{slot_label}/{}", spec.id) } @@ -331,21 +767,19 @@ pub(crate) fn handle_shortcut( ) -> Model { match action { ShortcutAction::Command { line } => { - // Hack temporario: lo agregamos al log del primer matilda - // que encontremos para que el usuario vea feedback. - if let Some(inst) = m - .tabs - .iter_mut() - .find(|i| matches!(i.state, ModuleState::Matilda(_))) - { - if let ModuleState::Matilda(s) = &mut inst.state { - s.log.push(format!("? command: {line}")); + // Lo agregamos al log del matilda de la sesión activa (feedback). + if let Some(s) = m.sessions.get_mut(m.active_session) { + if let ModuleState::Matilda(mat) = &mut s.matilda.state { + mat.log.push(format!("? command: {line}")); } } } ShortcutAction::FocusTab { target } => { - if let Some(i) = m.tabs.iter().position(|inst| inst.kind.id() == target) { - m.active_tab = i; + // Un shortcut que pide enfocar un módulo abre su herramienta a la + // derecha (matilda → panel Matilda). El shell es el canvas, no una + // herramienta, así que no aplica. + if target == shuma_module_matilda::ID { + m.active_tool = Some(Tool::Matilda); } } ShortcutAction::ModuleAction { action_id } => { @@ -367,11 +801,43 @@ pub(crate) fn handle_shortcut( m, slot, ModuleMsg::Matilda(shuma_module_matilda::Msg::LogLine( - "✘ sin inventory_path: agregá `inventory = …` al shumarc".into(), + "✘ sin inventory_path: agrega `inventory = …` al shumarc".into(), )), ); } } + // M5 — Fleet: consulta el runtime de cada host declarado por SSH, + // un thread por host, y reenvía SetHostRuntime/SetHostError. + if action_id == "matilda.fleet" { + let hosts: Vec = + match instance_for_slot(&m, &slot).map(|i| &i.state) { + Some(ModuleState::Matilda(st)) => st.desired.hosts().cloned().collect(), + _ => Vec::new(), + }; + // Marca cada host como Pending en el módulo. + m = apply_module_msg( + m, + slot.clone(), + ModuleMsg::Matilda(shuma_module_matilda::Msg::RefreshFleet), + ); + for host in hosts { + let slot_back = slot.clone(); + handle.spawn(move || { + let msg = match shuma_module_matilda::host_runtime_remote_blocking(&host) { + Ok(runtime) => shuma_module_matilda::Msg::SetHostRuntime { + host: host.name.clone(), + runtime, + }, + Err(error) => shuma_module_matilda::Msg::SetHostError { + host: host.name.clone(), + error, + }, + }; + Msg::Module(slot_back, ModuleMsg::Matilda(msg)) + }); + } + return m; + } // Hooks remotos: ciertas acciones de matilda necesitan // SSH + tokio. Las delegamos a un thread (`Handle::spawn`) // que al volver dispatcha un Msg con el resultado. @@ -448,7 +914,7 @@ pub(crate) fn handle_shortcut( } } // Minga refresh: el módulo es "declarativo" en update (no - // toca sled) — el load real lo hacemos acá en un thread y + // toca sled) — el load real lo hacemos aquí en un thread y // reenviamos el snapshot como SnapshotReady. if action_id == "minga.refresh" { if let Some(repo_path) = minga_repo_path(&slot, &m) { @@ -500,12 +966,7 @@ pub(crate) fn handle_shortcut( /// Path del repo Minga de un slot que aloje el módulo minga. pub(crate) fn minga_repo_path(slot: &Slot, model: &Model) -> Option { - let inst = match slot { - Slot::TopBar => model.topbar.as_ref()?, - Slot::BottomBar => model.bottombar.as_ref()?, - Slot::Main => model.main.as_ref()?, - Slot::Tab(i) => model.tabs.get(*i)?, - }; + let inst = instance_for_slot(model, slot)?; match &inst.state { ModuleState::Minga(s) => Some(s.repo_path.clone()), _ => None, @@ -519,12 +980,7 @@ pub(crate) fn minga_visible_alphas( slot: &Slot, model: &Model, ) -> Option> { - let inst = match slot { - Slot::TopBar => model.topbar.as_ref()?, - Slot::BottomBar => model.bottombar.as_ref()?, - Slot::Main => model.main.as_ref()?, - Slot::Tab(i) => model.tabs.get(*i)?, - }; + let inst = instance_for_slot(model, slot)?; match &inst.state { ModuleState::Minga(s) => s .snapshot @@ -540,6 +996,7 @@ pub(crate) fn minga_visible_alphas( /// Rutea la rueda del mouse al shell focado (mismo orden de prioridad /// que las teclas). `dpx` ya viene en px (positivo = ver historial). pub(crate) fn forward_wheel_to_focused_shell(model: &Model, dpx: f32) -> Option { + // El slot Main como shell gana (config wrapper de una sola app). if let Some(inst) = model.main.as_ref() { if matches!(inst.state, ModuleState::Shell(_)) { return Some(Msg::Module( @@ -548,21 +1005,67 @@ pub(crate) fn forward_wheel_to_focused_shell(model: &Model, dpx: f32) -> Option< )); } } - if let Some(inst) = model.tabs.get(model.active_tab) { - if matches!(inst.state, ModuleState::Shell(_)) { - return Some(Msg::Module( - Slot::Tab(model.active_tab), - ModuleMsg::Shell(shuma_module_shell::Msg::Scroll(dpx)), - )); - } - } - None + // Si no, la rueda recorre el shell de la sesión activa (siempre tiene uno). + Some(Msg::Module( + Slot::Session(model.active_session, Which::Shell), + ModuleMsg::Shell(shuma_module_shell::Msg::Scroll(dpx)), + )) } pub(crate) fn forward_key_to_focused_shell(model: &Model, e: &KeyEvent) -> Option { - // 1) Slot Main siempre gana — si está configurado como shell, las - // teclas van ahí. Permite al usuario poner el shell como módulo - // principal de la ventana. + // Si una ventana secundaria tiene un draft con foco de campo, las + // teclas van al draft (no al shell). El runtime de Llimphi dispatcha + // on_key tanto para primary como para secondary, así que modelamos + // el "foco" por estado de la app. + if let Some(d) = model.host_draft.as_ref() { + if d.focused.is_some() { + return Some(Msg::HostDraftKey(e.clone())); + } + } + if let Some(d) = model.container_draft.as_ref() { + if d.focus.is_some() { + return Some(Msg::ContainerDraftKey(e.clone())); + } + } + // Con un modal bloqueante abierto (containers/hosts) y sin campo del draft + // focado, Esc lo cierra. Es el único escape de teclado ahora que el clic + // en el scrim ya no descarta el modal (`on_dismiss: Noop`). + if matches!(&e.key, llimphi_ui::Key::Named(llimphi_ui::NamedKey::Escape)) { + if model.containers_modal_open { + return Some(Msg::CloseContainersModal); + } + if model.hosts_modal_open { + return Some(Msg::CloseHostsModal); + } + } + // Ctrl+= / Ctrl++ → zoom in · Ctrl+- → zoom out · Ctrl+0 → reset. + // Atajos universales: aplican al shell de la sesión activa sin + // importar si está focado en el input o un TUI; los chequeamos + // antes de las ramas main/pending. Detectamos por tres caminos + // (winit puede entregar la tecla por uno u otro según layout/IME): + // 1. `Key::Character` con el char. + // 2. `Key::Named(NamedKey::Equal/Minus/...)` (si winit las + // promueve a Named en algunos backends). + // 3. `e.text` (string ya resuelto con modifiers). + if e.modifiers.ctrl { + let ch: Option<&str> = match &e.key { + llimphi_ui::Key::Character(c) => Some(c.as_str()), + _ => e.text.as_deref(), + }; + let zoom_msg: Option = match ch { + Some("+" | "=") => Some(shuma_module_shell::Msg::ZoomBy(1.1)), + Some("-" | "_") => Some(shuma_module_shell::Msg::ZoomBy(1.0 / 1.1)), + Some("0") => Some(shuma_module_shell::Msg::ZoomReset), + _ => None, + }; + if let Some(z) = zoom_msg { + return Some(Msg::Module( + Slot::Session(model.active_session, Which::Shell), + ModuleMsg::Shell(z), + )); + } + } + // 1) Slot Main siempre gana — si está configurado como shell. if let Some(inst) = model.main.as_ref() { if matches!(inst.state, ModuleState::Shell(_)) { return Some(Msg::Module( @@ -571,26 +1074,31 @@ pub(crate) fn forward_key_to_focused_shell(model: &Model, e: &KeyEvent) -> Optio )); } } - // 2) Tab activo, si es un shell. - if let Some(inst) = model.tabs.get(model.active_tab) { - if matches!(inst.state, ModuleState::Shell(_)) { - return Some(Msg::Module( - Slot::Tab(model.active_tab), - ModuleMsg::Shell(shuma_module_shell::Msg::Key(e.clone())), - )); + // 2) Si la sesión activa está en form de creación (pending), las teclas + // van al form, no al shell oculto. + if let Some(s) = model.sessions.get(model.active_session) { + if s.pending { + // Escape sin foco de campo cancela toda la creación. + if matches!( + &e.key, + llimphi_ui::Key::Named(llimphi_ui::NamedKey::Escape) + ) && s.pending_focus.is_none() + { + return Some(Msg::CancelNewSession); + } + return Some(Msg::PendingKey(e.clone())); } } - None + // 3) Las teclas van al shell de la sesión activa (es el canvas principal). + Some(Msg::Module( + Slot::Session(model.active_session, Which::Shell), + ModuleMsg::Shell(shuma_module_shell::Msg::Key(e.clone())), + )) } /// Path del inventario JSON de un slot de matilda, si lo tiene cargado. pub(crate) fn matilda_inventory_path(slot: &Slot, model: &Model) -> Option { - let inst = match slot { - Slot::TopBar => model.topbar.as_ref()?, - Slot::BottomBar => model.bottombar.as_ref()?, - Slot::Main => model.main.as_ref()?, - Slot::Tab(i) => model.tabs.get(*i)?, - }; + let inst = instance_for_slot(model, slot)?; let state = match &inst.state { ModuleState::Matilda(s) => s.as_ref(), _ => return None, @@ -605,12 +1113,7 @@ pub(crate) fn remote_matilda_inputs( slot: &Slot, model: &Model, ) -> Option<(Source, matilda_core::Inventory)> { - let inst = match slot { - Slot::TopBar => model.topbar.as_ref()?, - Slot::BottomBar => model.bottombar.as_ref()?, - Slot::Main => model.main.as_ref()?, - Slot::Tab(i) => model.tabs.get(*i)?, - }; + let inst = instance_for_slot(model, slot)?; let state = match &inst.state { ModuleState::Matilda(s) => s.as_ref(), _ => return None, @@ -622,13 +1125,41 @@ pub(crate) fn remote_matilda_inputs( } } -pub(crate) fn dispatch_to_module(slot: &Slot, model: &Model, action_id: &str) -> Option { - let inst = match slot { - Slot::TopBar => model.topbar.as_ref()?, - Slot::BottomBar => model.bottombar.as_ref()?, - Slot::Main => model.main.as_ref()?, - Slot::Tab(i) => model.tabs.get(*i)?, +/// Si `slot` aloja un matilda, busca un host de la flota por nombre en su +/// inventario deseado. Clonado para que el thread SSH lo consuma sin tomar +/// prestado del modelo. Usado por las acciones de flota (M5). +pub(crate) fn matilda_host_by_name( + slot: &Slot, + model: &Model, + name: &str, +) -> Option { + let inst = instance_for_slot(model, slot)?; + let state = match &inst.state { + ModuleState::Matilda(s) => s.as_ref(), + _ => return None, }; + state.desired.hosts().find(|h| h.name == name).cloned() +} + +/// M2 — inputs para el thread del live-tail: el `Source`, el contenedor y la +/// bandera `stop` que el módulo acaba de crear en su `log_stream`. `None` si el +/// slot no es matilda o no hay stream activo (no debería pasar tras un +/// `StartLogStream` aplicado). Clonados para que el thread no tome prestado. +pub(crate) fn matilda_log_stream_inputs( + slot: &Slot, + model: &Model, +) -> Option<(Source, String, std::sync::Arc)> { + let inst = instance_for_slot(model, slot)?; + let state = match &inst.state { + ModuleState::Matilda(s) => s.as_ref(), + _ => return None, + }; + let ls = state.log_stream.as_ref()?; + Some((state.source.clone(), ls.container.clone(), ls.stop.clone())) +} + +pub(crate) fn dispatch_to_module(slot: &Slot, model: &Model, action_id: &str) -> Option { + let inst = instance_for_slot(model, slot)?; match inst.kind { Kind::Launcher => shuma_module_launcher::dispatch(action_id).map(ModuleMsg::Launcher), Kind::CommandBar => shuma_module_commandbar::dispatch(action_id).map(ModuleMsg::CommandBar), diff --git a/02_ruway/shuma/shuma-shell-llimphi/src/view.rs b/02_ruway/shuma/shuma-shell-llimphi/src/view.rs deleted file mode 100644 index fb25c30..0000000 --- a/02_ruway/shuma/shuma-shell-llimphi/src/view.rs +++ /dev/null @@ -1,447 +0,0 @@ -//! Render del chasis: topbar, tabs, área principal, monitores. - -use super::*; - -// ─── Render de cada slot ──────────────────────────────────────────── - -pub(crate) fn render_topbar(model: &Model, theme: &Theme) -> View { - match &model.topbar { - Some(inst) => match (inst.kind, &inst.state) { - (Kind::Launcher, ModuleState::Launcher(state)) => { - shuma_module_launcher::view::(state, theme, |m| { - Msg::Module(Slot::TopBar, ModuleMsg::Launcher(m)) - }) - } - _ => empty_bar(theme, 40.0), - }, - None => empty_bar(theme, 40.0), - } -} - -pub(crate) fn render_bottombar(model: &Model, theme: &Theme) -> View { - match &model.bottombar { - Some(inst) => match (inst.kind, &inst.state) { - (Kind::CommandBar, ModuleState::CommandBar(state)) => { - shuma_module_commandbar::view::(state, theme, |m| { - Msg::Module(Slot::BottomBar, ModuleMsg::CommandBar(m)) - }) - } - _ => empty_bar(theme, 28.0), - }, - None => empty_bar(theme, 28.0), - } -} - -pub(crate) fn empty_bar(theme: &Theme, height: f32) -> View { - View::new(Style { - size: Size { - width: percent(1.0_f32), - height: length(height), - }, - ..Default::default() - }) - .fill(theme.bg_panel) -} - -/// Área central. Si el shumarc declara `[main]`, ese módulo ocupa todo -/// el espacio (sin tabs ni monitores). Si no, se renderizan las tabs + -/// monitor stack a la derecha vía splitter. -pub(crate) fn render_main_area(model: &Model, theme: &Theme) -> View { - let body = match &model.main { - Some(inst) => render_main_full(inst, theme), - None => render_tabs_with_monitors(model, theme), - }; - View::new(Style { - size: Size { - width: percent(1.0_f32), - height: percent(1.0_f32), - }, - flex_grow: 1.0, - ..Default::default() - }) - .fill(theme.bg_app) - .children(vec![body]) -} - -/// Render full-bleed del slot `main` cuando el shumarc lo configura. -/// Sin tabs ni monitores — útil para wrappers de una sola app. -pub(crate) fn render_main_full(inst: &Instance, theme: &Theme) -> View { - match (inst.kind, &inst.state) { - (Kind::Shell, ModuleState::Shell(state)) => shuma_module_shell::view::( - state, - theme, - |m| Msg::Module(Slot::Main, ModuleMsg::Shell(m)), - ), - (Kind::Matilda, ModuleState::Matilda(state)) => { - shuma_module_matilda::view::(state.as_ref(), theme, |m| { - Msg::Module(Slot::Main, ModuleMsg::Matilda(m)) - }) - } - (Kind::Minga, ModuleState::Minga(state)) => { - shuma_module_minga::view::(state, theme, |m| { - Msg::Module(Slot::Main, ModuleMsg::Minga(m)) - }) - } - (Kind::Canvas, ModuleState::Canvas(state)) => { - shuma_module_canvas::view::(state, theme, |m| { - Msg::Module(Slot::Main, ModuleMsg::Canvas(m)) - }) - } - _ => placeholder(theme, &rimay_localize::t("shuma-empty-main-incompat")), - } -} - -/// Layout normal: tira de tabs arriba con toolbar de shortcuts del -/// tab activo, splitter horizontal con (contenido | monitores). -pub(crate) fn render_tabs_with_monitors(model: &Model, theme: &Theme) -> View { - let tabs_palette = TabsPalette::from_theme(theme); - let splitter_palette = SplitterPalette::from_theme(theme); - - let toolbar = tabs_toolbar(model, theme); - let content = tab_content(model, theme); - - let labels: Vec = model.tabs.iter().map(|inst| inst.label.clone()).collect(); - - // El panel de monitores se oculta en modo delegado hasta que el rail de - // pata lo despliega (`monitors_visible`). Oculto → el contenido toma todo - // el ancho, sin splitter (puro lienzo). - let tab_body = if model.monitors_visible { - splitter_two( - Direction::Row, - content, - PaneSize::Flex, - monitor_stack(model, theme), - PaneSize::Fixed(model.monitors_width), - |phase, dx| match phase { - DragPhase::Move => Some(Msg::ResizeMonitors(dx)), - DragPhase::End => None, - }, - &splitter_palette, - ) - } else { - content - }; - - let tabs = tabs_view(TabsSpec { - labels, - active: model.active_tab, - on_select: Msg::SelectTab, - content: tab_body, - tab_height: 32.0, - palette: tabs_palette, - tab_width: None, - }); - - View::new(Style { - flex_direction: FlexDirection::Column, - size: Size { - width: percent(1.0_f32), - height: percent(1.0_f32), - }, - ..Default::default() - }) - .children(vec![toolbar, tabs]) -} - -/// Toolbar de la tira de tabs: pinta los `ShortcutSpec` del tab activo -/// como botones que disparan `Msg::ShortcutClicked`. Si el tab activo -/// no aporta shortcuts, la barra queda vacía (alto 0 — colapsa). -pub(crate) fn tabs_toolbar(model: &Model, theme: &Theme) -> View { - use llimphi_ui::llimphi_layout::taffy::prelude::Dimension; - use llimphi_ui::llimphi_text::Alignment; - - let Some(inst) = model.tabs.get(model.active_tab) else { - return empty_bar(theme, 0.0); - }; - let slot = Slot::Tab(model.active_tab); - let contribs = match &inst.state { - ModuleState::Launcher(s) => shuma_module_launcher::contributions(s), - ModuleState::CommandBar(s) => shuma_module_commandbar::contributions(s), - ModuleState::Shell(s) => shuma_module_shell::contributions(s), - ModuleState::Matilda(s) => shuma_module_matilda::contributions(s), - ModuleState::Minga(s) => shuma_module_minga::contributions(s), - ModuleState::Canvas(s) => shuma_module_canvas::contributions(s), - }; - - if contribs.shortcuts.is_empty() { - return empty_bar(theme, 0.0); - } - - let mut buttons: Vec> = contribs - .shortcuts - .into_iter() - .map(|spec| shortcut_button(slot.clone(), spec, theme)) - .collect(); - - // Label izquierdo: el nombre del tab activo. - let label = View::new(Style { - size: Size { - width: Dimension::auto(), - height: percent(1.0_f32), - }, - flex_grow: 1.0, - padding: Rect { - left: length(14.0_f32), - right: length(8.0_f32), - top: length(0.0_f32), - bottom: length(0.0_f32), - }, - align_items: Some(llimphi_ui::llimphi_layout::taffy::AlignItems::Center), - ..Default::default() - }) - .text_aligned(inst.label.clone(), 12.0, theme.fg_text, Alignment::Start); - - let mut row = vec![label]; - row.append(&mut buttons); - - View::new(Style { - flex_direction: FlexDirection::Row, - size: Size { - width: percent(1.0_f32), - height: length(34.0_f32), - }, - padding: Rect { - left: length(4.0_f32), - right: length(8.0_f32), - top: length(0.0_f32), - bottom: length(0.0_f32), - }, - align_items: Some(llimphi_ui::llimphi_layout::taffy::AlignItems::Center), - ..Default::default() - }) - .fill(theme.bg_panel) - .children(row) -} - -pub(crate) fn shortcut_button(slot: Slot, spec: ShortcutSpec, theme: &Theme) -> View { - use llimphi_ui::llimphi_layout::taffy::{prelude::Dimension, AlignItems, JustifyContent}; - use llimphi_ui::llimphi_text::Alignment; - - View::new(Style { - size: Size { - width: Dimension::auto(), - height: length(26.0_f32), - }, - padding: Rect { - left: length(12.0_f32), - right: length(12.0_f32), - top: length(0.0_f32), - bottom: length(0.0_f32), - }, - margin: Rect { - left: length(4.0_f32), - right: length(0.0_f32), - top: length(0.0_f32), - bottom: length(0.0_f32), - }, - align_items: Some(AlignItems::Center), - justify_content: Some(JustifyContent::Center), - ..Default::default() - }) - .fill(theme.bg_button) - .hover_fill(theme.bg_button_hover) - .radius(4.0) - .text_aligned(spec.label.clone(), 12.0, theme.fg_text, Alignment::Center) - .on_click(Msg::ShortcutClicked(slot, spec.action)) -} - -pub(crate) fn tab_content(model: &Model, theme: &Theme) -> View { - let Some(inst) = model.tabs.get(model.active_tab) else { - return placeholder(theme, &rimay_localize::t("shuma-empty-no-tabs")); - }; - let idx = model.active_tab; - match (inst.kind, &inst.state) { - (Kind::Shell, ModuleState::Shell(state)) => { - shuma_module_shell::view::(state, theme, move |m| { - Msg::Module(Slot::Tab(idx), ModuleMsg::Shell(m)) - }) - } - (Kind::Matilda, ModuleState::Matilda(state)) => { - shuma_module_matilda::view::(state.as_ref(), theme, move |m| { - Msg::Module(Slot::Tab(idx), ModuleMsg::Matilda(m)) - }) - } - (Kind::Minga, ModuleState::Minga(state)) => { - shuma_module_minga::view::(state, theme, move |m| { - Msg::Module(Slot::Tab(idx), ModuleMsg::Minga(m)) - }) - } - (Kind::Canvas, ModuleState::Canvas(state)) => { - shuma_module_canvas::view::(state, theme, move |m| { - Msg::Module(Slot::Tab(idx), ModuleMsg::Canvas(m)) - }) - } - // Otros Kinds (Launcher/CommandBar) no tienen sentido como tab; - // mostramos un placeholder informativo. - _ => placeholder(theme, &rimay_localize::t("shuma-empty-no-tabs-compat")), - } -} - -// ─── Monitor stack ───────────────────────────────────────────────── - -pub(crate) fn monitor_stack(model: &Model, theme: &Theme) -> View { - let palette = StatCardPalette::from_theme(theme); - - let (cpu_value, mem_value) = match model.last_snapshot { - Some(s) if s.valid => (s.cpu_percent, s.mem_percent), - _ => (0.0, 0.0), - }; - - let cpu_card = monitor_card( - "CPU", - format!("{cpu_value:>3.0}%"), - match model.last_snapshot { - Some(s) if s.valid => format!( - "{} de {} muestras", - model.sysmon.cpu_history().len(), - HISTORY - ), - _ => rimay_localize::t("shuma-empty-no-data-linux"), - }, - Color::from_rgb8(0x82, 0xCF, 0xF2), - model.sysmon.cpu_history().values(), - &palette, - ); - - let mem_card = monitor_card( - "MEM", - format!("{mem_value:>3.0}%"), - match model.last_snapshot { - Some(s) if s.valid => format!("{} MB de {} MB", s.mem_used_mb, s.mem_total_mb), - _ => rimay_localize::t("shuma-empty-no-data"), - }, - Color::from_rgb8(0xF7, 0xC8, 0x7A), - model.sysmon.mem_history().values(), - &palette, - ); - - let mut children = vec![cpu_card, mem_card]; - - // Stat-cards extra: una por cada `MonitorSpec` aportado por los - // módulos vivos. El historial vive en `model.extra_history`. - for (slot, contribs) in collect_contributions(model) { - for spec in &contribs.monitors { - let key = monitor_key(&slot, spec); - let history = model - .extra_history - .get(&key) - .cloned() - .unwrap_or_default(); - let display = model - .extra_display - .get(&key) - .cloned() - .unwrap_or_else(|| "—".into()); - let accent = Color::from_rgb8(spec.accent.r, spec.accent.g, spec.accent.b); - children.push(monitor_card( - spec.label.as_str(), - display, - rimay_localize::t_args( - "shuma-stat-samples", - &[ - ("have", history.len().to_string().into()), - ("total", HISTORY.to_string().into()), - ], - ), - accent, - history, - &palette, - )); - } - } - - View::new(Style { - flex_direction: FlexDirection::Column, - size: Size { - width: percent(1.0_f32), - height: percent(1.0_f32), - }, - padding: Rect { - left: length(10.0_f32), - right: length(10.0_f32), - top: length(10.0_f32), - bottom: length(10.0_f32), - }, - gap: Size { - width: length(0.0_f32), - height: length(10.0_f32), - }, - ..Default::default() - }) - .fill(theme.bg_panel_alt) - .children(children) -} - -pub(crate) fn monitor_card( - label: &str, - value: String, - description: String, - accent: Color, - history: Vec, - palette: &StatCardPalette, -) -> View { - let card = stat_card_view::(label, value, description.as_str(), accent, &[], palette); - let curve = curve_view(history, accent); - - View::new(Style { - flex_direction: FlexDirection::Column, - size: Size { - width: percent(1.0_f32), - height: Dimension::auto(), - }, - gap: Size { - width: length(0.0_f32), - height: length(6.0_f32), - }, - ..Default::default() - }) - .children(vec![card, curve]) -} - -pub(crate) fn curve_view(history: Vec, accent: Color) -> View { - View::new(Style { - size: Size { - width: percent(1.0_f32), - height: length(56.0_f32), - }, - ..Default::default() - }) - .paint_with(move |scene, _ts, rect: PaintRect| { - if history.len() < 2 { - return; - } - let n = history.len() as f32; - let dx = if n > 1.0 { rect.w / (n - 1.0) } else { rect.w }; - let mut path = BezPath::new(); - for (i, v) in history.iter().enumerate() { - let x = rect.x + dx * i as f32; - let y = rect.y + rect.h - (v.clamp(0.0, 100.0) / 100.0) * rect.h; - let p = Point::new(x as f64, y as f64); - if i == 0 { - path.push(PathEl::MoveTo(p)); - } else { - path.push(PathEl::LineTo(p)); - } - } - scene.stroke(&Stroke::new(1.5), Affine::IDENTITY, accent, None, &path); - }) -} - -pub(crate) fn placeholder(theme: &Theme, text: &str) -> View { - use llimphi_ui::llimphi_text::Alignment; - View::new(Style { - size: Size { - width: percent(1.0_f32), - height: percent(1.0_f32), - }, - padding: Rect { - left: length(24.0_f32), - right: length(24.0_f32), - top: length(20.0_f32), - bottom: length(20.0_f32), - }, - ..Default::default() - }) - .fill(theme.bg_app) - .text_aligned(text.to_string(), 13.0, theme.fg_muted, Alignment::Start) -} diff --git a/02_ruway/shuma/shuma-shell-llimphi/src/view/chrome.rs b/02_ruway/shuma/shuma-shell-llimphi/src/view/chrome.rs new file mode 100644 index 0000000..9ed9d45 --- /dev/null +++ b/02_ruway/shuma/shuma-shell-llimphi/src/view/chrome.rs @@ -0,0 +1,404 @@ +//! Render de topbar, bottombar y área principal del chasis. Los DOS sidebars +//! (sesiones a la izquierda, herramientas a la derecha) los pinta el widget +//! unificado `rag-sidebar`: rail de dientes + panel + cabezal + buscador + +//! control, en una sola forma por lado. + +use std::sync::Arc; + +use super::super::*; +use super::session::*; +use super::tools::*; +use super::widgets::placeholder; +use llimphi_ui::llimphi_layout::taffy::prelude::{auto, length, percent, Position, Style}; +use llimphi_ui::llimphi_layout::taffy::{FlexDirection, Rect, Size}; +use llimphi_ui::View; +use llimphi_theme::Theme; +use llimphi_widget_rag_sidebar::{ + rag_multiselect_view, rag_sidebar_view, RagOptions, RagSidebarMsg, RagSidebarPalette, + RagSidebarView, RagSide, +}; + +/// Ancho del rail de dientes (derecha y izquierda), en px — mismo que el resto +/// de la suite (cosmos/nakui/media/agora). +pub(super) const RAIL_W: f32 = 44.0; + +/// `y` (px de ventana) al que se arriman las cards de disposición de los +/// sidebars: bajo el menubar (30) + topbar (40) + la barra del panel del widget. +const MULTI_TOP: f32 = 30.0 + 40.0 + 32.0; +/// Ancho de la card del multiselect de disposición (sigue a `rag-sidebar`). +const MULTI_CARD_W: f32 = 238.0; + +pub(crate) fn render_topbar(model: &Model, theme: &Theme) -> View { + match &model.topbar { + Some(inst) => match (inst.kind, &inst.state) { + (Kind::Launcher, ModuleState::Launcher(state)) => { + shuma_module_launcher::view::(state, theme, |m| { + Msg::Module(Slot::TopBar, ModuleMsg::Launcher(m)) + }) + } + _ => empty_bar(theme, 40.0), + }, + None => empty_bar(theme, 40.0), + } +} + +pub(crate) fn render_bottombar(model: &Model, theme: &Theme) -> View { + match &model.bottombar { + Some(inst) => match (inst.kind, &inst.state) { + (Kind::CommandBar, ModuleState::CommandBar(state)) => { + shuma_module_commandbar::view::(state, theme, |m| { + Msg::Module(Slot::BottomBar, ModuleMsg::CommandBar(m)) + }) + } + _ => status_bar(model, theme), + }, + None => status_bar(model, theme), + } +} + +pub(crate) fn empty_bar(theme: &Theme, height: f32) -> View { + View::new(Style { + size: Size { width: percent(1.0_f32), height: length(height) }, + ..Default::default() + }) + .fill(theme.bg_panel) +} + +/// Barra de estado inferior cuando no hay módulo CommandBar. +pub(crate) fn status_bar(model: &Model, theme: &Theme) -> View { + use llimphi_ui::llimphi_layout::taffy::{AlignItems, JustifyContent}; + use llimphi_ui::llimphi_text::Alignment; + let bar = View::new(Style { + size: Size { width: percent(1.0_f32), height: length(28.0_f32) }, + align_items: Some(AlignItems::Center), + justify_content: Some(JustifyContent::Center), + ..Default::default() + }) + .fill(theme.bg_panel); + match model.hovered_session.and_then(|i| model.sessions.get(i)) { + Some(s) => { + let label = match s.number { + Some(n) => format!("#{n} {}", s.name), + None => s.name.clone(), + }; + bar.text_aligned(label, 12.0, theme.fg_text, Alignment::Center) + } + None => bar, + } +} + +/// Área central: si el shumarc declara `[main]`, ese módulo ocupa todo el +/// espacio. Si no, se renderizan las tabs + monitor stack a la derecha. +pub(crate) fn render_main_area(model: &Model, theme: &Theme) -> View { + let body = if model.chromeless { + // Sólo el canvas, a todo lo ancho: sin rail-sesión, sin rail-tool, sin + // paneles laterales resizables. Si el shumarc declara `[main]`, ese + // módulo ya es full-bleed. + match &model.main { + Some(inst) => render_main_full(inst, theme), + None => canvas_view(model, theme), + } + } else { + match &model.main { + Some(inst) => render_main_full(inst, theme), + None => render_tabs_with_monitors(model, theme), + } + }; + View::new(Style { + size: Size { width: percent(1.0_f32), height: percent(1.0_f32) }, + flex_grow: 1.0, + ..Default::default() + }) + .fill(theme.bg_app) + .children(vec![body]) +} + +/// Render full-bleed del slot `main` cuando el shumarc lo configura. +pub(crate) fn render_main_full(inst: &Instance, theme: &Theme) -> View { + match (inst.kind, &inst.state) { + (Kind::Shell, ModuleState::Shell(state)) => shuma_module_shell::view::( + state, + theme, + |m| Msg::Module(Slot::Main, ModuleMsg::Shell(m)), + ), + (Kind::Matilda, ModuleState::Matilda(state)) => { + shuma_module_matilda::view::(state.as_ref(), theme, |m| { + Msg::Module(Slot::Main, ModuleMsg::Matilda(m)) + }) + } + (Kind::Minga, ModuleState::Minga(state)) => { + shuma_module_minga::view::(state, theme, |m| { + Msg::Module(Slot::Main, ModuleMsg::Minga(m)) + }) + } + (Kind::Canvas, ModuleState::Canvas(state)) => { + shuma_module_canvas::view::(state, theme, |m| { + Msg::Module(Slot::Main, ModuleMsg::Canvas(m)) + }) + } + _ => placeholder(theme, &rimay_localize::t("shuma-empty-main-incompat")), + } +} + +/// Layout normal: `[sidebar-sesiones · canvas · sidebar-herramientas]`, con los +/// DOS sidebars unificados (`rag-sidebar`). Cada uno reserva su columna cuando +/// está Fijo (sin autohide); con Flota/Autoesconde flota como overlay sobre el +/// canvas, con un backdrop de click-away que lo guarda. +pub(crate) fn render_tabs_with_monitors(model: &Model, theme: &Theme) -> View { + let left_fixed = model.sidebar_left.reserves_space(); + let right_fixed = model.sidebar_right.reserves_space(); + + let canvas = View::new(Style { + flex_direction: FlexDirection::Row, + flex_grow: 1.0, + size: Size { width: length(0.0_f32), height: percent(1.0_f32) }, + min_size: Size { width: length(0.0_f32), height: length(0.0_f32) }, + ..Default::default() + }) + .children(vec![canvas_view(model, theme)]); + + let mut row: Vec> = Vec::with_capacity(3); + if left_fixed { + row.push(session_sidebar(model, theme)); + } + row.push(canvas); + if right_fixed { + row.push(tool_sidebar(model, theme)); + } + let base = View::new(Style { + flex_direction: FlexDirection::Row, + size: Size { width: percent(1.0_f32), height: percent(1.0_f32) }, + ..Default::default() + }) + .children(row); + + // Sidebars flotantes (Flota / Autoesconde): overlay pegado al borde + un + // backdrop de click-away cuando su panel está desplegado. + let mut layers: Vec> = vec![base]; + if !left_fixed { + if model.session_panel_open { + layers.push(float_backdrop(Msg::SidebarLeft(RagSidebarMsg::Reveal(false)))); + } + layers.push(float_overlay(session_sidebar(model, theme), true)); + } + if !right_fixed { + if model.active_tool.is_some() { + layers.push(float_backdrop(Msg::SidebarRight(RagSidebarMsg::Reveal(false)))); + } + layers.push(float_overlay(tool_sidebar(model, theme), false)); + } + + if layers.len() == 1 { + layers.into_iter().next().unwrap_or_else(|| View::new(Style::default())) + } else { + View::new(Style { + position: Position::Relative, + flex_direction: FlexDirection::Row, + size: Size { width: percent(1.0_f32), height: percent(1.0_f32) }, + ..Default::default() + }) + .children(layers) + } +} + +// ─── Sidebars unificados (rag-sidebar) ────────────────────────────── + +/// El **sidebar de sesiones** (izquierdo) completo: rail de dientes + panel + +/// cabezal/buscador/control, pintado por el widget unificado. El diente +/// abierto/seleccionado se fuerza a `active_session`; el panel se muestra según +/// `session_panel_open`. `Activate` → `SelectSession` (interceptado); el resto → +/// `Msg::SidebarLeft`. La encía «+» al final abre el form de sesión nueva. +pub(crate) fn session_sidebar(model: &Model, theme: &Theme) -> View { + let palette = RagSidebarPalette::from_theme(theme); + let teeth = session_teeth(model, theme); + let body = session_body(model, theme); + + let mut sb = model.sidebar_left.clone(); + sb.selected = Some(model.active_session as u64); + sb.open = model.session_panel_open.then_some(model.active_session as u64); + + let map: Arc Msg + Send + Sync> = Arc::new(|rm| match rm { + RagSidebarMsg::Activate(id) => Msg::SelectSession(id as usize), + other => Msg::SidebarLeft(other), + }); + let spec = RagSidebarView { + teeth, + body, + accessory: None, + search_hits: !model.sidebar_left.search.trim().is_empty(), + t: 0.0, + rail_w: RAIL_W, + map, + on_drop: None, + // Reorden por arrastre: los dientes de sesión llevan id = índice; soltar el + // diente arrastrado sobre otro reordena a esa posición. Repone el drag-reorder + // que el rail a mano tenía (ahora sale del widget, para todos). + on_reorder: Some(Arc::new(|payload, target| { + (payload != target).then_some(Msg::ReorderSession(payload as usize, target as usize)) + })), + grow: Some(("+".to_string(), "Nueva sesión".to_string(), Msg::OpenNewSessionForm)), + }; + rag_sidebar_view(&sb, spec, RagSide::Left, &palette) +} + +/// El **sidebar de herramientas** (derecho) completo. El diente abierto se fuerza +/// a `active_tool` (la fuente de verdad del chasis); `Activate` → `SelectTool` +/// (que alterna abrir/colapsar), el resto → `Msg::SidebarRight`. El chat (Agente) +/// pide un piso de ancho de 480px. +pub(crate) fn tool_sidebar(model: &Model, theme: &Theme) -> View { + let palette = RagSidebarPalette::from_theme(theme); + let teeth = tool_teeth(model, theme); + let open_id = model.active_tool.and_then(tool_id); + let body = match model.active_tool { + Some(t) => tool_body(model, t, theme), + None => View::new(Style { + size: Size { width: percent(1.0_f32), height: percent(1.0_f32) }, + ..Default::default() + }), + }; + + let mut sb = model.sidebar_right.clone(); + sb.open = open_id; + sb.selected = open_id; + if model.active_tool == Some(Tool::Agente) { + sb.panel_w = sb.panel_w.max(480.0); + } + + let map: Arc Msg + Send + Sync> = Arc::new(|rm| match rm { + RagSidebarMsg::Activate(id) => { + Msg::SelectTool(Tool::ALL.get(id as usize).copied().unwrap_or(Tool::History)) + } + other => Msg::SidebarRight(other), + }); + let spec = RagSidebarView { + teeth, + body, + accessory: None, + search_hits: !model.sidebar_right.search.trim().is_empty(), + t: 0.0, + rail_w: RAIL_W, + map, + on_drop: None, + // Las herramientas tienen orden fijo (Tool::ALL): no se reordenan. + on_reorder: None, + grow: None, + }; + rag_sidebar_view(&sb, spec, RagSide::Right, &palette) +} + +/// El id de diente (índice en `Tool::ALL`) de una herramienta. +fn tool_id(t: Tool) -> Option { + Tool::ALL.iter().position(|x| *x == t).map(|i| i as u64) +} + +// ─── Multiselect de disposición (overlay a nivel raíz) ────────────── + +/// La card del multiselect de disposición del sidebar de **sesiones**, como +/// overlay con backdrop de click-away, arrimada al borde derecho del panel +/// izquierdo. `None` si su control ⚙ está cerrado. La monta `overlay_view`. +pub(crate) fn session_multiselect_overlay(model: &Model, theme: &Theme) -> Option> { + if !model.sidebar_left.control_open { + return None; + } + let palette = RagSidebarPalette::from_theme(theme); + let card = rag_multiselect_view( + &model.sidebar_left, + RagOptions::default(), + Arc::new(Msg::SidebarLeft), + &palette, + ); + let left = (RAIL_W + model.sidebar_left.panel_w - MULTI_CARD_W).max(RAIL_W + 4.0); + Some(multiselect_layer( + card, + left, + Msg::SidebarLeft(RagSidebarMsg::ControlToggle), + )) +} + +/// La card del multiselect de disposición del sidebar de **herramientas**, +/// arrimada al borde izquierdo del panel derecho. `None` si su control está +/// cerrado. +pub(crate) fn tool_multiselect_overlay(model: &Model, theme: &Theme) -> Option> { + if !model.sidebar_right.control_open { + return None; + } + let palette = RagSidebarPalette::from_theme(theme); + let card = rag_multiselect_view( + &model.sidebar_right, + RagOptions::default(), + Arc::new(Msg::SidebarRight), + &palette, + ); + let (vw, _) = model.viewport; + let left = (vw - RAIL_W - model.sidebar_right.panel_w + 4.0).max(4.0); + Some(multiselect_layer( + card, + left, + Msg::SidebarRight(RagSidebarMsg::ControlToggle), + )) +} + +/// Ensambla un overlay de multiselect: backdrop de click-away a pantalla +/// completa + la card posicionada en `(left, MULTI_TOP)`. +fn multiselect_layer(card: View, left: f32, dismiss: Msg) -> View { + let backdrop = View::new(Style { + position: Position::Absolute, + inset: Rect { + left: length(0.0_f32), + right: length(0.0_f32), + top: length(0.0_f32), + bottom: length(0.0_f32), + }, + size: Size { width: percent(1.0_f32), height: percent(1.0_f32) }, + ..Default::default() + }) + .on_click(dismiss); + let card_pos = View::new(Style { + position: Position::Absolute, + inset: Rect { left: length(left), right: auto(), top: length(MULTI_TOP), bottom: auto() }, + ..Default::default() + }) + .children(vec![card]); + View::new(Style { + position: Position::Relative, + size: Size { width: percent(1.0_f32), height: percent(1.0_f32) }, + ..Default::default() + }) + .children(vec![backdrop, card_pos]) +} + +// ─── Overlays de sidebar flotante ─────────────────────────────────── + +/// Backdrop de click-away a pantalla completa: al clickear fuera del sidebar +/// flotante, emite `msg` (típicamente `Reveal(false)`) para guardarlo. +fn float_backdrop(msg: Msg) -> View { + View::new(Style { + position: Position::Absolute, + inset: Rect { + left: length(0.0_f32), + right: length(0.0_f32), + top: length(0.0_f32), + bottom: length(0.0_f32), + }, + size: Size { width: percent(1.0_f32), height: percent(1.0_f32) }, + ..Default::default() + }) + .on_click(msg) +} + +/// Coloca un sidebar flotante como overlay pegado a un borde vertical +/// (`left_side` = izquierda), de alto completo. +fn float_overlay(inner: View, left_side: bool) -> View { + let inset = if left_side { + Rect { left: length(0.0_f32), right: auto(), top: length(0.0_f32), bottom: length(0.0_f32) } + } else { + Rect { left: auto(), right: length(0.0_f32), top: length(0.0_f32), bottom: length(0.0_f32) } + }; + View::new(Style { + position: Position::Absolute, + inset, + size: Size { width: auto(), height: percent(1.0_f32) }, + ..Default::default() + }) + .children(vec![inner]) +} diff --git a/02_ruway/shuma/shuma-shell-llimphi/src/view/mod.rs b/02_ruway/shuma/shuma-shell-llimphi/src/view/mod.rs new file mode 100644 index 0000000..94703f0 --- /dev/null +++ b/02_ruway/shuma/shuma-shell-llimphi/src/view/mod.rs @@ -0,0 +1,154 @@ +//! Render del chasis de shuma: topbar, área central, tabs, monitores. +//! +//! El módulo raíz orquesta los sub-módulos y re-exporta lo que `main.rs` +//! necesita: `render_topbar`, `render_bottombar`, `render_main_area`, +//! `dropdown_overlay`, y los modales. + +mod chrome; +mod modals; +mod monitors; +mod session; +mod tools; +mod widgets; + +// Re-exportaciones públicas hacia main.rs (Shell App). +pub(crate) use chrome::{ + empty_bar, render_bottombar, render_main_area, render_topbar, session_multiselect_overlay, + status_bar, tool_multiselect_overlay, +}; +pub(crate) use modals::{containers_modal, hosts_modal, layouts_modal, perfiles_modal}; +pub(crate) use session::tabs_animadas; +pub use session::tarjeta_de_sesion_para_test; +pub(crate) use monitors::{curve_view, monitor_card, monitor_stack}; +pub(crate) use widgets::placeholder; + +use super::*; +use llimphi_ui::View; +use llimphi_theme::Theme; +use llimphi_widget_select::{ + select_menu_view, SelectItem, SelectMenuSpec, SelectPalette, SelectPhase, +}; + +/// Alto del disparador del select (debe seguir a `llimphi-widget-select`). +const TRIGGER_H: f32 = 34.0; + +/// Ítems del dropdown de engine de aislamiento. +pub(crate) fn engine_items() -> Vec { + let mut out: Vec = Vec::new(); + if unshare_disponible() { + out.push( + SelectItem::new("unshare".to_string()) + .with_sublabel("util-linux + chroot — sin instalar nada (recomendado)"), + ); + } + if bwrap_disponible() { + out.push( + SelectItem::new("bwrap".to_string()) + .with_sublabel("bubblewrap — sandbox liviano"), + ); + } + if podman_disponible() { + out.push( + SelectItem::new("podman".to_string()) + .with_sublabel("OCI completo (con storage.conf)"), + ); + } + if out.is_empty() { + out.push( + SelectItem::new("(ninguno)".to_string()).with_sublabel( + "instala util-linux + coreutils, bubblewrap o podman", + ), + ); + } + out +} + +/// Ítems del dropdown de aislamiento. +fn iso_items() -> Vec { + vec![ + SelectItem::new("Local").with_sublabel("Directo en esta máquina."), + SelectItem::new("Remoto (SSH)").with_sublabel("En otra máquina por SSH."), + ] +} + +fn iso_index(iso: Isolation) -> usize { + Isolation::ALL.iter().position(|x| *x == iso).unwrap_or(0) +} + +/// `y` aproximado del disparador de un dropdown dentro del panel de sesión. +fn cfg_trigger_y(is_draft: bool, kind: DropKind) -> f32 { + let iso_y = if is_draft { 134.0 } else { 92.0 }; + match kind { + DropKind::Isolation => iso_y, + DropKind::Engine => iso_y + 50.0, + DropKind::Distro => iso_y + 98.0, + DropKind::Container => iso_y + 98.0 + 64.0, + DropKind::Host => iso_y, + } +} + +/// El menú del dropdown de config abierto (para `App::view_overlay`). +pub(crate) fn dropdown_overlay(model: &Model) -> Option> { + let kind = model.dropdown_open?; + let session = model.active()?; + if session.pending { + return None; + } + let is_draft = session.kind == SessionKind::Draft; + let pal = SelectPalette::from_theme(&model.theme); + + let (items, selected_vec): (Vec, Vec) = match kind { + DropKind::Isolation => (iso_items(), vec![iso_index(session.isolation)]), + DropKind::Host | DropKind::Container | DropKind::Distro | DropKind::Engine => { + return None + } + }; + let visible: Vec = (0..items.len()).collect(); + let anchor = (12.0, cfg_trigger_y(is_draft, kind) + TRIGGER_H + 4.0); + let width = (model.session_w - 24.0).max(140.0); + + let n_containers = model.containers.len(); + let on_pick: std::sync::Arc Msg + Send + Sync> = match kind { + DropKind::Isolation => { + std::sync::Arc::new(|i| Msg::SetIsolation(Isolation::ALL[i.min(1)])) + } + DropKind::Distro => std::sync::Arc::new(|i| Msg::SetDistro(Distro::ALL[i.min(3)])), + DropKind::Engine => { + let items_clone = engine_items(); + std::sync::Arc::new(move |i| { + let label = items_clone + .get(i) + .map(|it| it.label.clone()) + .unwrap_or_default(); + Msg::SetEngine(label) + }) + } + DropKind::Container => std::sync::Arc::new(move |i| { + if i < n_containers { + Msg::SubscribeContainer(i) + } else { + Msg::CreateContainer + } + }), + DropKind::Host => std::sync::Arc::new(|_| Msg::DismissDropdown), + }; + + Some(select_menu_view(SelectMenuSpec { + anchor, + viewport: (1280.0, 800.0), + width, + phase: SelectPhase::Ready(&items), + visible: &visible, + active: usize::MAX, + selected: &selected_vec, + query: "", + searchable: false, + empty_text: "", + appear: 1.0, + on_pick, + on_hover: None, + on_dismiss: Msg::DismissDropdown, + on_retry: None, + palette: &pal, + })) +} diff --git a/02_ruway/shuma/shuma-shell-llimphi/src/view/modals.rs b/02_ruway/shuma/shuma-shell-llimphi/src/view/modals.rs new file mode 100644 index 0000000..565ac10 --- /dev/null +++ b/02_ruway/shuma/shuma-shell-llimphi/src/view/modals.rs @@ -0,0 +1,1057 @@ +//! Modales: gestores de containers, hosts y disposiciones. + +use super::super::*; +use super::widgets::*; +use llimphi_ui::llimphi_layout::taffy::prelude::{length, percent, Dimension, Style}; +use llimphi_ui::llimphi_layout::taffy::{AlignItems, FlexDirection, JustifyContent, Rect, Size}; +use llimphi_ui::View; +use llimphi_theme::Theme; +use llimphi_widget_text_input::{text_input_view_full, TextInputPalette}; + +// ─── Containers modal ─────────────────────────────────────────────── + +/// Diálogo bloqueante de containers. +pub(crate) fn containers_modal(model: &Model, theme: &Theme) -> View { + use llimphi_widget_modal::{modal_view, ModalButton, ModalPalette, ModalSpec}; + modal_view(ModalSpec { + title: "Containers".to_string(), + body: containers_modal_body(model, theme), + buttons: vec![ModalButton::cancel("Listo", Msg::CloseContainersModal)], + size: (560.0, 600.0), + viewport: model.viewport, + on_dismiss: Msg::Noop, + palette: ModalPalette::from_theme(theme), + body_scroll: None, + }) +} + +fn containers_modal_body(model: &Model, theme: &Theme) -> View { + use llimphi_ui::llimphi_text::Alignment; + + if let Some((host, user, port, engine)) = model.active_remote_target() { + return remote_containers_body(model, &host, &user, port, &engine, theme); + } + + let sub = View::new(Style { + size: Size { width: percent(1.0_f32), height: length(18.0_f32) }, + ..Default::default() + }) + .text_aligned( + "Elige uno de la lista para editarlo, o «Nuevo». Los mounts se aplican al correr." + .to_string(), + 11.0, + theme.fg_muted, + Alignment::Start, + ); + + let nuevo_btn = action_button_small("+ Nuevo", Msg::ContainerDraftNew, theme); + let editor: Option> = + model.container_draft.as_ref().map(|d| container_draft_form(d, theme)); + let refresh = action_button_small("⟳ Refrescar lista", Msg::RefreshContainersFull, theme); + + let editing_name: Option<&str> = model + .container_draft + .as_ref() + .and_then(|d| d.editing.as_deref()); + + let mut rows: Vec> = Vec::new(); + if !model.containers_full.is_empty() { + rows.push(panel_label("Existentes", theme)); + for (i, c) in model.containers_full.iter().enumerate() { + let selected = editing_name == Some(c.name.as_str()); + rows.push(container_row(i, c, selected, theme)); + } + } + + View::new(Style { + flex_direction: FlexDirection::Column, + size: Size { width: percent(1.0_f32), height: Dimension::auto() }, + gap: Size { width: length(0.0_f32), height: length(6.0_f32) }, + ..Default::default() + }) + .children({ + let mut all = vec![sub, nuevo_btn]; + if let Some(ed) = editor { + all.push(ed); + } + all.push(refresh); + all.extend(rows); + all + }) +} + +/// Cuerpo del gestor cuando la sesión activa es **remota**. +fn remote_containers_body( + model: &Model, + host: &str, + user: &str, + port: u16, + engine: &str, + theme: &Theme, +) -> View { + use llimphi_ui::llimphi_text::Alignment; + + let sub = panel_note( + &format!("Gestionando {user}@{host}:{port} ({engine}) por SSH."), + theme, + ); + + let mut distro_btns: Vec> = Vec::new(); + for d in Distro::ALL { + let active = model.remote_new_distro == d; + distro_btns.push( + View::new(Style { + flex_grow: 1.0, + size: Size { width: Dimension::auto(), height: length(28.0_f32) }, + align_items: Some(AlignItems::Center), + justify_content: Some(JustifyContent::Center), + ..Default::default() + }) + .fill(if active { theme.accent } else { theme.bg_button }) + .hover_fill(if active { theme.accent } else { theme.bg_button_hover }) + .radius(4.0) + .text_aligned( + d.label().to_string(), + 11.0, + if active { theme.bg_app } else { theme.fg_muted }, + Alignment::Center, + ) + .on_click(Msg::SetRemoteNewDistro(d)), + ); + } + let distros = View::new(Style { + flex_direction: FlexDirection::Row, + size: Size { width: percent(1.0_f32), height: length(28.0_f32) }, + gap: Size { width: length(6.0_f32), height: length(0.0_f32) }, + ..Default::default() + }) + .children(distro_btns); + + let crear = action_button_small("+ Crear en el host", Msg::CreateRemoteContainer, theme); + let refresh = action_button_small("⟳ Refrescar lista", Msg::RefreshRemoteContainers, theme); + + let mut rows: Vec> = Vec::new(); + if model.remote_containers.is_empty() { + rows.push(panel_note( + "Sin contenedores en el host (o no respondió aún). Refresca.", + theme, + )); + } else { + rows.push(panel_label("En el host remoto", theme)); + for name in &model.remote_containers { + rows.push(remote_container_row(name, theme)); + } + } + + View::new(Style { + flex_direction: FlexDirection::Column, + size: Size { width: percent(1.0_f32), height: Dimension::auto() }, + gap: Size { width: length(0.0_f32), height: length(6.0_f32) }, + ..Default::default() + }) + .children({ + let mut all = + vec![sub, panel_label("Crear nuevo", theme), distros, crear, refresh]; + all.extend(rows); + all + }) +} + +/// Una fila del gestor remoto: nombre + ▶ start · ■ stop · 🗑 rm. +fn remote_container_row(name: &str, theme: &Theme) -> View { + use llimphi_ui::llimphi_text::Alignment; + let display = View::new(Style { + size: Size { width: Dimension::auto(), height: length(18.0_f32) }, + flex_grow: 1.0, + ..Default::default() + }) + .text_aligned(name.to_string(), 12.0, theme.fg_text, Alignment::Start); + let start = action_button_small("▶", Msg::RemoteStart(name.to_string()), theme); + let stop = action_button_small("■", Msg::RemoteStop(name.to_string()), theme); + let rm = action_button_small("🗑", Msg::RemoteRemove(name.to_string()), theme); + View::new(Style { + flex_direction: FlexDirection::Row, + size: Size { width: percent(1.0_f32), height: length(30.0_f32) }, + align_items: Some(AlignItems::Center), + gap: Size { width: length(6.0_f32), height: length(0.0_f32) }, + padding: Rect { + left: length(8.0_f32), + right: length(8.0_f32), + top: length(0.0_f32), + bottom: length(0.0_f32), + }, + ..Default::default() + }) + .radius(4.0) + .hover_fill(theme.bg_row_hover) + .children(vec![display, start, stop, rm]) +} + +/// Editor de contenedor: engine + distro + directorios montados. +fn container_draft_form(d: &ContainerDraft, theme: &Theme) -> View { + use llimphi_ui::llimphi_text::Alignment; + let tpal = TextInputPalette::from_theme(theme); + let editing = d.editing.is_some(); + + let mk_radio = |label: String, active: bool, msg: Msg| { + let v = View::new(Style { + flex_grow: 1.0, + size: Size { width: Dimension::auto(), height: length(28.0_f32) }, + align_items: Some(AlignItems::Center), + justify_content: Some(JustifyContent::Center), + ..Default::default() + }) + .fill(if active { theme.accent } else { theme.bg_button }) + .radius(4.0) + .text_aligned( + label, + 11.0, + if active { theme.bg_app } else { theme.fg_muted }, + Alignment::Center, + ); + if editing { + v + } else { + v.hover_fill(if active { theme.accent } else { theme.bg_button_hover }) + .on_click(msg) + } + }; + + let mut engine_btns: Vec> = Vec::new(); + for (avail, name) in [ + (unshare_disponible(), "unshare"), + (bwrap_disponible(), "bwrap"), + (podman_disponible(), "podman"), + ] { + if avail && (!editing || d.engine == name) { + engine_btns.push(mk_radio( + name.to_string(), + d.engine == name, + Msg::ContainerDraftSetEngine(name.to_string()), + )); + } + } + if engine_btns.is_empty() { + engine_btns.push( + View::new(Style { + flex_grow: 1.0, + size: Size { width: Dimension::auto(), height: length(28.0_f32) }, + ..Default::default() + }) + .text_aligned("—".to_string(), 11.0, theme.fg_muted, Alignment::Center), + ); + } + let engine_row = View::new(Style { + flex_direction: FlexDirection::Row, + size: Size { width: percent(1.0_f32), height: length(30.0_f32) }, + gap: Size { width: length(6.0_f32), height: length(0.0_f32) }, + ..Default::default() + }) + .children(engine_btns); + + let distro_row = View::new(Style { + flex_direction: FlexDirection::Row, + size: Size { width: percent(1.0_f32), height: length(30.0_f32) }, + gap: Size { width: length(6.0_f32), height: length(0.0_f32) }, + ..Default::default() + }) + .children( + [Distro::Ubuntu, Distro::Debian, Distro::Alpine, Distro::Arch] + .into_iter() + .filter(|dd| !editing || d.distro == *dd) + .map(|dd| { + mk_radio( + dd.label().to_string(), + d.distro == dd, + Msg::ContainerDraftSetDistro(dd), + ) + }) + .collect::>(), + ); + + // Filas de mount. + let mut mount_rows: Vec> = Vec::new(); + for (i, md) in d.mounts.iter().enumerate() { + let host_in = text_input_view_full( + &md.host, + "/home/usuario/proyecto", + d.focus == Some((i, MountCol::Host)), + &tpal, + move |ev| Msg::ContainerDraftMountCampo(i, MountCol::Host, ev), + ); + let arrow = View::new(Style { + size: Size { width: length(16.0_f32), height: length(28.0_f32) }, + flex_shrink: 0.0, + ..Default::default() + }) + .text_aligned("→".to_string(), 12.0, theme.fg_muted, Alignment::Center); + let tgt_in = text_input_view_full( + &md.target, + "/work", + d.focus == Some((i, MountCol::Target)), + &tpal, + move |ev| Msg::ContainerDraftMountCampo(i, MountCol::Target, ev), + ); + let ro_label = if md.readonly { "ro" } else { "rw" }; + let ro_btn = View::new(Style { + size: Size { width: length(34.0_f32), height: length(28.0_f32) }, + flex_shrink: 0.0, + align_items: Some(AlignItems::Center), + justify_content: Some(JustifyContent::Center), + ..Default::default() + }) + .fill(if md.readonly { theme.bg_button } else { theme.accent }) + .hover_fill(theme.bg_button_hover) + .radius(4.0) + .text_aligned( + ro_label.to_string(), + 11.0, + if md.readonly { theme.fg_text } else { theme.bg_app }, + Alignment::Center, + ) + .on_click(Msg::ContainerDraftToggleMountRo(i)); + let rm_btn = action_button_small("🗑", Msg::ContainerDraftRemoveMount(i), theme); + mount_rows.push( + View::new(Style { + flex_direction: FlexDirection::Row, + size: Size { width: percent(1.0_f32), height: length(30.0_f32) }, + align_items: Some(AlignItems::Center), + gap: Size { width: length(5.0_f32), height: length(0.0_f32) }, + ..Default::default() + }) + .children(vec![host_in, arrow, tgt_in, ro_btn, rm_btn]), + ); + } + let add_mount = action_button_small("+ agregar directorio", Msg::ContainerDraftAddMount, theme); + + let save_label = if editing { "Guardar (Enter)" } else { "Crear (Enter)" }; + let buttons = View::new(Style { + flex_direction: FlexDirection::Row, + size: Size { width: percent(1.0_f32), height: length(36.0_f32) }, + gap: Size { width: length(8.0_f32), height: length(0.0_f32) }, + margin: Rect { + left: length(0.0_f32), + right: length(0.0_f32), + top: length(10.0_f32), + bottom: length(0.0_f32), + }, + ..Default::default() + }) + .children(vec![ + action_button_small(save_label, Msg::ContainerDraftSave, theme), + action_button_small("Cancelar (Esc)", Msg::ContainerDraftCancel, theme), + ]); + + let titulo = panel_label( + if editing { "Editar contenedor" } else { "Nuevo contenedor" }, + theme, + ); + let host_lbl = if d.host == "local" { + "Host: Local".to_string() + } else { + format!("Host: {}", d.host) + }; + let mut children = vec![ + titulo, + panel_note(&host_lbl, theme), + panel_label("Engine", theme), + engine_row, + panel_label("Distro", theme), + distro_row, + panel_label("Directorios montados (host → destino)", theme), + ]; + children.extend(mount_rows); + children.push(add_mount); + children.push(buttons); + + View::new(Style { + flex_direction: FlexDirection::Column, + size: Size { width: percent(1.0_f32), height: Dimension::auto() }, + padding: Rect { + left: length(12.0_f32), + right: length(12.0_f32), + top: length(12.0_f32), + bottom: length(12.0_f32), + }, + gap: Size { width: length(0.0_f32), height: length(6.0_f32) }, + margin: Rect { + left: length(0.0_f32), + right: length(0.0_f32), + top: length(6.0_f32), + bottom: length(6.0_f32), + }, + ..Default::default() + }) + .fill(theme.bg_panel) + .radius(6.0) + .children(children) +} + +fn container_row(idx: usize, c: &ContainerInfo, selected: bool, theme: &Theme) -> View { + use llimphi_ui::llimphi_text::Alignment; + let running = c.status.starts_with("Up"); + let name_view = View::new(Style { + size: Size { width: length(180.0_f32), height: length(18.0_f32) }, + flex_shrink: 0.0, + ..Default::default() + }) + .text_aligned(c.name.clone(), 12.0, theme.fg_text, Alignment::Start); + let status_view = View::new(Style { + size: Size { width: Dimension::auto(), height: length(18.0_f32) }, + flex_grow: 1.0, + ..Default::default() + }) + .text_aligned( + format!("{} · {}", c.status, c.image), + 11.0, + if running { theme.accent } else { theme.fg_muted }, + Alignment::Start, + ); + let mut children = vec![name_view, status_view]; + if c.rootfs { + children.push(action_button_small("🗑", Msg::RemoveRootfs(c.name.clone()), theme)); + } else { + children.push(action_button_small("▶", Msg::StartContainer(c.name.clone()), theme)); + children.push(action_button_small("■", Msg::StopContainer(c.name.clone()), theme)); + children.push(action_button_small("🗑", Msg::RemoveContainer(c.name.clone()), theme)); + } + let mut row = View::new(Style { + flex_direction: FlexDirection::Row, + size: Size { width: percent(1.0_f32), height: length(32.0_f32) }, + align_items: Some(AlignItems::Center), + gap: Size { width: length(6.0_f32), height: length(0.0_f32) }, + padding: Rect { + left: length(8.0_f32), + right: length(8.0_f32), + top: length(0.0_f32), + bottom: length(0.0_f32), + }, + ..Default::default() + }) + .radius(4.0) + .hover_fill(theme.bg_row_hover) + .children(children); + if selected { + row = row.fill(theme.bg_panel_alt); + } + if c.rootfs { + row = row.on_click(Msg::ContainerEdit(idx)); + } + row +} + +// ─── Hosts modal ──────────────────────────────────────────────────── + +/// Diálogo bloqueante de hosts remotos. +pub(crate) fn hosts_modal(model: &Model, theme: &Theme) -> View { + use llimphi_widget_modal::{modal_view, ModalButton, ModalPalette, ModalSpec}; + modal_view(ModalSpec { + title: "Hosts remotos".to_string(), + body: hosts_modal_body(model, theme), + buttons: vec![ModalButton::cancel("Listo", Msg::CloseHostsModal)], + size: (520.0, 560.0), + viewport: model.viewport, + on_dismiss: Msg::Noop, + palette: ModalPalette::from_theme(theme), + body_scroll: None, + }) +} + +fn hosts_modal_body(model: &Model, theme: &Theme) -> View { + use llimphi_ui::llimphi_text::Alignment; + + let sub = View::new(Style { + size: Size { width: percent(1.0_f32), height: length(18.0_f32) }, + ..Default::default() + }) + .text_aligned( + "Se guardan en ~/.config/shuma/hosts.json.".to_string(), + 11.0, + theme.fg_muted, + Alignment::Start, + ); + + let nuevo_btn = action_button_small("+ Nuevo", Msg::HostDraftStart, theme); + let editor: Option> = + model.host_draft.as_ref().map(|d| host_draft_form(d, theme)); + let editing_name: Option<&str> = model + .host_draft + .as_ref() + .and_then(|d| d.editing.as_deref()); + + let mut rows: Vec> = Vec::new(); + if !model.hosts.is_empty() { + rows.push(panel_label("Guardados", theme)); + for (i, h) in model.hosts.iter().enumerate() { + let selected = editing_name == Some(h.name.as_str()); + rows.push(host_row(i, h, selected, theme)); + } + } + + View::new(Style { + flex_direction: FlexDirection::Column, + size: Size { width: percent(1.0_f32), height: Dimension::auto() }, + gap: Size { width: length(0.0_f32), height: length(8.0_f32) }, + ..Default::default() + }) + .children({ + let mut all = vec![sub, nuevo_btn]; + if let Some(ed) = editor { + all.push(ed); + } + all.extend(rows); + all + }) +} + +fn host_row(idx: usize, h: &hosts::RemoteHost, selected: bool, theme: &Theme) -> View { + use llimphi_ui::llimphi_text::Alignment; + let display = View::new(Style { + size: Size { width: Dimension::auto(), height: length(18.0_f32) }, + flex_grow: 1.0, + ..Default::default() + }) + .text_aligned( + format!("{} · {}", h.display(), h.auth.label()), + 12.0, + theme.fg_text, + Alignment::Start, + ); + let rm_btn = action_button_small("🗑", Msg::HostDelete(idx), theme); + let mut row = View::new(Style { + flex_direction: FlexDirection::Row, + size: Size { width: percent(1.0_f32), height: length(30.0_f32) }, + align_items: Some(AlignItems::Center), + gap: Size { width: length(6.0_f32), height: length(0.0_f32) }, + padding: Rect { + left: length(8.0_f32), + right: length(8.0_f32), + top: length(0.0_f32), + bottom: length(0.0_f32), + }, + ..Default::default() + }) + .radius(4.0) + .hover_fill(theme.bg_row_hover) + .children(vec![display, rm_btn]) + .on_click(Msg::HostEdit(idx)); + if selected { + row = row.fill(theme.bg_panel_alt); + } + row +} + +fn host_draft_form(d: &HostDraft, theme: &Theme) -> View { + use llimphi_ui::llimphi_text::Alignment; + let tpal = TextInputPalette::from_theme(theme); + let mut rows: Vec> = Vec::new(); + rows.push(panel_label( + if d.editing.is_some() { "Editar host" } else { "Nuevo host" }, + theme, + )); + rows.push(panel_label("Nombre", theme)); + rows.push(text_input_view_full( + &d.name, + "ejemplo", + d.focused == Some(HostDraftField::Name), + &tpal, + |ev| Msg::HostDraftCampo(HostDraftField::Name, ev), + )); + rows.push(panel_label("Host", theme)); + rows.push(text_input_view_full( + &d.host, + "1.2.3.4 o ejemplo.com", + d.focused == Some(HostDraftField::Host), + &tpal, + |ev| Msg::HostDraftCampo(HostDraftField::Host, ev), + )); + rows.push(panel_label("Usuario", theme)); + rows.push(text_input_view_full( + &d.user, + "root", + d.focused == Some(HostDraftField::User), + &tpal, + |ev| Msg::HostDraftCampo(HostDraftField::User, ev), + )); + rows.push(panel_label("Puerto", theme)); + rows.push(text_input_view_full( + &d.port, + "22", + d.focused == Some(HostDraftField::Port), + &tpal, + |ev| Msg::HostDraftCampo(HostDraftField::Port, ev), + )); + let auth_label = if d.use_password { + "Contraseña (askpass al conectar)" + } else { + "Clave PEM" + }; + rows.push( + View::new(Style { + size: Size { width: percent(1.0_f32), height: length(26.0_f32) }, + align_items: Some(AlignItems::Center), + padding: Rect { + left: length(4.0_f32), + right: length(8.0_f32), + top: length(0.0_f32), + bottom: length(0.0_f32), + }, + ..Default::default() + }) + .hover_fill(theme.bg_row_hover) + .on_click(Msg::HostDraftToggleAuth) + .text_aligned( + format!("· Auth: {auth_label} (click cambia)"), + 11.0, + theme.fg_text, + Alignment::Start, + ), + ); + // Transporte: el trade-off que el usuario elige por host. Con PTY el + // canal SSH lleva un terminal de verdad y los interactivos del otro lado + // (vim, htop, claude) se pintan acá; sin PTY cada comando es un `exec` + // suelto — más barato, pero mudo para todo lo de pantalla completa. + let pty_label = if d.pty { + "PTY (interactivos andan)" + } else { + "exec por comando (sin terminal)" + }; + rows.push( + View::new(Style { + size: Size { width: percent(1.0_f32), height: length(26.0_f32) }, + align_items: Some(AlignItems::Center), + padding: Rect { + left: length(4.0_f32), + right: length(8.0_f32), + top: length(0.0_f32), + bottom: length(0.0_f32), + }, + ..Default::default() + }) + .hover_fill(theme.bg_row_hover) + .on_click(Msg::HostDraftTogglePty) + .text_aligned( + format!("· Transporte: {pty_label} (click cambia)"), + 11.0, + theme.fg_text, + Alignment::Start, + ), + ); + if !d.use_password { + rows.push(panel_label("Path PEM", theme)); + rows.push(text_input_view_full( + &d.pem_path, + "/home/usuario/.ssh/id_rsa", + d.focused == Some(HostDraftField::Pem), + &tpal, + |ev| Msg::HostDraftCampo(HostDraftField::Pem, ev), + )); + } + let save_label = if d.editing.is_some() { "Guardar (Enter)" } else { "Crear (Enter)" }; + let save = action_button_small(save_label, Msg::HostDraftSave, theme); + let cancel = action_button_small("Cancelar (Esc)", Msg::HostDraftCancel, theme); + let buttons = View::new(Style { + flex_direction: FlexDirection::Row, + size: Size { width: percent(1.0_f32), height: length(36.0_f32) }, + gap: Size { width: length(8.0_f32), height: length(0.0_f32) }, + align_items: Some(AlignItems::Center), + margin: Rect { + left: length(0.0_f32), + right: length(0.0_f32), + top: length(10.0_f32), + bottom: length(0.0_f32), + }, + ..Default::default() + }) + .children(vec![save, cancel]); + rows.push(buttons); + + View::new(Style { + flex_direction: FlexDirection::Column, + size: Size { width: percent(1.0_f32), height: Dimension::auto() }, + padding: Rect { + left: length(12.0_f32), + right: length(12.0_f32), + top: length(12.0_f32), + bottom: length(12.0_f32), + }, + gap: Size { width: length(0.0_f32), height: length(4.0_f32) }, + ..Default::default() + }) + .fill(theme.bg_panel) + .radius(6.0) + .children(rows) +} + +// ─── Layouts modal ────────────────────────────────────────────────── + +/// Diálogo bloqueante de disposiciones estilo tmux. +pub(crate) fn layouts_modal(model: &Model, theme: &Theme) -> View { + use llimphi_widget_modal::{modal_view, ModalButton, ModalPalette, ModalSpec}; + modal_view(ModalSpec { + title: "Disposiciones".to_string(), + body: layouts_modal_body(model, theme), + buttons: vec![ModalButton::cancel("Listo", Msg::CloseLayoutsModal)], + size: (520.0, 520.0), + viewport: model.viewport, + on_dismiss: Msg::Noop, + palette: ModalPalette::from_theme(theme), + body_scroll: None, + }) +} + +fn layouts_modal_body(model: &Model, theme: &Theme) -> View { + use llimphi_ui::llimphi_text::Alignment; + let tpal = TextInputPalette::from_theme(theme); + + let sub = View::new(Style { + size: Size { width: percent(1.0_f32), height: Dimension::auto() }, + ..Default::default() + }) + .text_aligned( + "Una disposición guarda tus sesiones y la geometría de los paneles. Se guardan en ~/.config/shuma/layouts.json.".to_string(), + 11.0, + theme.fg_muted, + Alignment::Start, + ); + + let name_input = text_input_view_full( + &model.layout_name, + "nombre de la disposición", + model.layout_name_focused, + &tpal, + Msg::LayoutNameCampo, + ); + let save_btn = action_button_small("Guardar disposición actual", Msg::SaveLayout, theme); + + let mut rows: Vec> = Vec::new(); + if !model.layouts.is_empty() { + rows.push(panel_label("Guardadas", theme)); + for (i, l) in model.layouts.iter().enumerate() { + rows.push(layout_row(i, &l.name, l.sessions.len(), theme)); + } + } + + View::new(Style { + flex_direction: FlexDirection::Column, + size: Size { width: percent(1.0_f32), height: Dimension::auto() }, + gap: Size { width: length(0.0_f32), height: length(8.0_f32) }, + ..Default::default() + }) + .children({ + let mut all = + vec![sub, panel_label("Guardar la actual", theme), name_input, save_btn]; + all.extend(rows); + all + }) +} + +fn layout_row(idx: usize, name: &str, n_sessions: usize, theme: &Theme) -> View { + use llimphi_ui::llimphi_text::Alignment; + let plural = if n_sessions == 1 { "sesión" } else { "sesiones" }; + let display = View::new(Style { + size: Size { width: Dimension::auto(), height: length(18.0_f32) }, + flex_grow: 1.0, + ..Default::default() + }) + .text_aligned( + format!("{name} · {n_sessions} {plural}"), + 12.0, + theme.fg_text, + Alignment::Start, + ); + let restore_btn = action_button_small("Restaurar", Msg::RestoreLayout(idx), theme); + let rm_btn = action_button_small("🗑", Msg::DeleteLayout(idx), theme); + View::new(Style { + flex_direction: FlexDirection::Row, + size: Size { width: percent(1.0_f32), height: length(30.0_f32) }, + align_items: Some(AlignItems::Center), + gap: Size { width: length(6.0_f32), height: length(0.0_f32) }, + padding: Rect { + left: length(8.0_f32), + right: length(8.0_f32), + top: length(0.0_f32), + bottom: length(0.0_f32), + }, + ..Default::default() + }) + .radius(4.0) + .hover_fill(theme.bg_row_hover) + .children(vec![display, restore_btn, rm_btn]) +} + +// ─── Modal de gestión de perfiles ─────────────────────────────────── + +/// Diálogo de gestión de perfiles: tres pestañas (atajos · apariencia · +/// sesión), cada una lista sus perfiles con activar/duplicar/renombrar/borrar, +/// más un campo de nombre para crear/duplicar/renombrar. +pub(crate) fn perfiles_modal(model: &Model, theme: &Theme) -> View { + use llimphi_widget_modal::{modal_view, ModalButton, ModalPalette, ModalSpec}; + modal_view(ModalSpec { + title: "Perfiles".to_string(), + body: perfiles_modal_body(model, theme), + buttons: vec![ModalButton::cancel("Listo", Msg::ClosePerfilesModal)], + size: (560.0, 600.0), + viewport: model.viewport, + on_dismiss: Msg::Noop, + palette: ModalPalette::from_theme(theme), + body_scroll: None, + }) +} + +/// Botón de pestaña del modal (resaltado si es la activa). +fn prof_tab_btn(label: &str, kind: ProfKind, active: bool, theme: &Theme) -> View { + use llimphi_ui::llimphi_layout::taffy::{AlignItems, JustifyContent}; + use llimphi_ui::llimphi_text::Alignment; + let fill = if active { theme.bg_selected } else { theme.bg_button }; + View::new(Style { + size: Size { width: Dimension::auto(), height: length(28.0_f32) }, + flex_grow: 1.0, + padding: Rect { + left: length(10.0_f32), + right: length(10.0_f32), + top: length(0.0_f32), + bottom: length(0.0_f32), + }, + align_items: Some(AlignItems::Center), + justify_content: Some(JustifyContent::Center), + ..Default::default() + }) + .fill(fill) + .hover_fill(theme.bg_button_hover) + .radius(4.0) + .text_aligned(label.to_string(), 11.5, theme.fg_text, Alignment::Center) + .on_click(Msg::PerfilesTab(kind)) +} + +fn perfiles_modal_body(model: &Model, theme: &Theme) -> View { + use llimphi_ui::llimphi_text::Alignment; + let kind = model.perfiles_tab; + let tpal = TextInputPalette::from_theme(theme); + + // Datos del tipo en pestaña: (nombre, es_activo, es_de_fábrica). + let rows_data: Vec<(String, bool, bool)> = match kind { + ProfKind::Shortcuts => { + let active = model.shortcuts.active().to_string(); + model + .shortcuts + .names() + .into_iter() + .map(|n| { + let b = crate::perfiles::shortcuts::is_builtin(&n); + let a = n == active; + (n, a, b) + }) + .collect() + } + ProfKind::Appearance => { + let active = model.appearance.active().to_string(); + model + .appearance + .names() + .into_iter() + .map(|n| { + let b = crate::perfiles::appearance::is_builtin(&n); + let a = n == active; + (n, a, b) + }) + .collect() + } + ProfKind::Sessions => { + let active = model.session_profiles.active().to_string(); + model + .session_profiles + .names() + .iter() + .map(|n| { + let b = n == crate::perfiles::sessions::DEFAULT_NAME; + let a = *n == active; + (n.clone(), a, b) + }) + .collect() + } + }; + + let descr = match kind { + ProfKind::Shortcuts => "Atajos del workspace (globales). Nuevos/duplicados arrancan como «shuma».", + ProfKind::Appearance => "Coloración por ventana. Nuevos/duplicados arrancan del perfil activo.", + ProfKind::Sessions => "Contextos tipo Firefox: cada uno aísla sus sesiones y workspaces.", + }; + + // Pestañas. + let tabs = View::new(Style { + flex_direction: FlexDirection::Row, + size: Size { width: percent(1.0_f32), height: Dimension::auto() }, + gap: Size { width: length(6.0_f32), height: length(0.0_f32) }, + ..Default::default() + }) + .children(vec![ + prof_tab_btn("Atajos", ProfKind::Shortcuts, kind == ProfKind::Shortcuts, theme), + prof_tab_btn("Apariencia", ProfKind::Appearance, kind == ProfKind::Appearance, theme), + prof_tab_btn("Sesión", ProfKind::Sessions, kind == ProfKind::Sessions, theme), + ]); + + let sub = View::new(Style { + size: Size { width: percent(1.0_f32), height: Dimension::auto() }, + ..Default::default() + }) + .text_aligned(descr.to_string(), 11.0, theme.fg_muted, Alignment::Start); + + // Campo de nombre + crear. + let name_input = text_input_view_full( + &model.prof_name, + "nombre (para crear / duplicar / renombrar)", + model.prof_name_focused, + &tpal, + Msg::ProfNameCampo, + ); + let create_btn = action_button_small("+ Crear con este nombre", Msg::ProfCreate(kind), theme); + + // Lista de perfiles. + let mut rows: Vec> = Vec::new(); + rows.push(panel_label("Perfiles", theme)); + for (name, is_active, is_builtin) in rows_data { + rows.push(prof_row(kind, &name, is_active, is_builtin, theme)); + } + + View::new(Style { + flex_direction: FlexDirection::Column, + size: Size { width: percent(1.0_f32), height: Dimension::auto() }, + gap: Size { width: length(0.0_f32), height: length(8.0_f32) }, + ..Default::default() + }) + .children({ + let mut all = vec![tabs, sub, panel_label("Crear / duplicar / renombrar", theme), name_input, create_btn]; + // Sección de wallpaper, sólo en la pestaña de apariencia. + if kind == ProfKind::Appearance { + all.extend(wallpaper_section(model, theme)); + all.extend(background_section(model, theme)); + } + all.extend(rows); + all + }) +} + +/// La sub-sección de wallpaper del modal (pestaña Apariencia): muestra el +/// wallpaper actual del perfil activo + un campo de path para fijarlo/quitarlo. +fn wallpaper_section(model: &Model, theme: &Theme) -> Vec> { + use llimphi_ui::llimphi_text::Alignment; + let tpal = TextInputPalette::from_theme(theme); + let active = model.appearance.active().to_string(); + let is_system = active == crate::perfiles::appearance::SYSTEM_NAME; + + let header = panel_label(&format!("Wallpaper (perfil «{active}»)"), theme); + + let hint = if is_system { + "«Sistema» sigue al tema global y no lleva wallpaper. Activa/crea otro perfil.".to_string() + } else { + match model.appearance.active_wallpaper() { + Some(p) => format!("Actual: {p}"), + None => "Sin wallpaper. Pega una ruta de imagen (PNG/JPG/WEBP) y «Aplicar».".to_string(), + } + }; + let hint_view = View::new(Style { + size: Size { width: percent(1.0_f32), height: Dimension::auto() }, + ..Default::default() + }) + .text_aligned(hint, 10.5, theme.fg_muted, Alignment::Start); + + let input = text_input_view_full( + &model.wp_path, + "/ruta/al/wallpaper.jpg", + model.wp_path_focused, + &tpal, + Msg::WpPathCampo, + ); + let apply = action_button_small("Aplicar wallpaper", Msg::SetWallpaperActive, theme); + let clear = action_button_small("Quitar wallpaper", Msg::ClearWallpaperActive, theme); + let btns = View::new(Style { + flex_direction: FlexDirection::Row, + size: Size { width: percent(1.0_f32), height: Dimension::auto() }, + gap: Size { width: length(6.0_f32), height: length(0.0_f32) }, + ..Default::default() + }) + .children(vec![apply, clear]); + + vec![header, hint_view, input, btns] +} + +/// La sub-sección de **fondo procedural** del modal (pestaña Apariencia): el +/// fondo animado propio de shuma («párpados»), mucho más lento y tenue que el +/// del compositor. A diferencia del wallpaper, aplica también a «Sistema» (que +/// lo trae por defecto). +fn background_section(model: &Model, theme: &Theme) -> Vec> { + use llimphi_ui::llimphi_text::Alignment; + let active = model.appearance.active().to_string(); + + let header = panel_label(&format!("Fondo animado (perfil «{active}»)"), theme); + + let actual = model + .appearance + .get(&active) + .and_then(|ap| ap.background_pattern()); + let hint = match actual { + Some(p) => format!("Actual: {} — lento y tenue, detrás del texto.", p.label()), + None => "Sin fondo animado. «Párpados» pinta fosfenos suaves detrás del texto.".to_string(), + }; + let hint_view = View::new(Style { + size: Size { width: percent(1.0_f32), height: Dimension::auto() }, + ..Default::default() + }) + .text_aligned(hint, 10.5, theme.fg_muted, Alignment::Start); + + let parpados = action_button_small( + "Párpados", + Msg::SetProceduralBg("parpados".to_string()), + theme, + ); + let ninguno = action_button_small("Sin fondo", Msg::ClearProceduralBg, theme); + let btns = View::new(Style { + flex_direction: FlexDirection::Row, + size: Size { width: percent(1.0_f32), height: Dimension::auto() }, + gap: Size { width: length(6.0_f32), height: length(0.0_f32) }, + ..Default::default() + }) + .children(vec![parpados, ninguno]); + + vec![header, hint_view, btns] +} + +/// Una fila de perfil: nombre (● si activo) + acciones. +fn prof_row(kind: ProfKind, name: &str, is_active: bool, is_builtin: bool, theme: &Theme) -> View { + use llimphi_ui::llimphi_text::Alignment; + let label = if is_active { format!("\u{25CF} {name}") } else { name.to_string() }; + let display = View::new(Style { + size: Size { width: Dimension::auto(), height: length(18.0_f32) }, + flex_grow: 1.0, + ..Default::default() + }) + .text_aligned(label, 12.0, theme.fg_text, Alignment::Start); + + let mut kids: Vec> = vec![display]; + if !is_active { + kids.push(action_button_small("Usar", Msg::ProfUse(kind, name.to_string()), theme)); + } + kids.push(action_button_small("Duplicar", Msg::ProfDuplicate(kind, name.to_string()), theme)); + if !is_builtin { + kids.push(action_button_small("Renombrar", Msg::ProfRename(kind, name.to_string()), theme)); + kids.push(action_button_small("\u{1F5D1}", Msg::ProfDelete(kind, name.to_string()), theme)); + } + + View::new(Style { + flex_direction: FlexDirection::Row, + size: Size { width: percent(1.0_f32), height: length(30.0_f32) }, + align_items: Some(AlignItems::Center), + gap: Size { width: length(6.0_f32), height: length(0.0_f32) }, + padding: Rect { + left: length(8.0_f32), + right: length(8.0_f32), + top: length(0.0_f32), + bottom: length(0.0_f32), + }, + ..Default::default() + }) + .radius(4.0) + .hover_fill(theme.bg_row_hover) + .children(kids) +} diff --git a/02_ruway/shuma/shuma-shell-llimphi/src/view/monitors.rs b/02_ruway/shuma/shuma-shell-llimphi/src/view/monitors.rs new file mode 100644 index 0000000..0b1ecd4 --- /dev/null +++ b/02_ruway/shuma/shuma-shell-llimphi/src/view/monitors.rs @@ -0,0 +1,140 @@ +//! Monitor stack: las stat-cards de CPU/MEM + curvas históricas + extras +//! aportados por los módulos. + +use super::super::*; +use super::widgets::*; +use llimphi_ui::llimphi_layout::taffy::prelude::{length, percent, Dimension, Style}; +use llimphi_ui::llimphi_layout::taffy::{FlexDirection, Rect, Size}; +use llimphi_ui::llimphi_raster::kurbo::{Affine, BezPath, PathEl, Point, Stroke}; +use llimphi_ui::llimphi_raster::peniko::Color; +use llimphi_ui::{PaintRect, View}; +use llimphi_theme::Theme; +use llimphi_widget_stat_card::{stat_card_view, StatCardPalette}; + +pub(crate) fn monitor_stack(model: &Model, theme: &Theme) -> View { + let palette = StatCardPalette::from_theme(theme); + + let (cpu_value, mem_value) = match model.last_snapshot { + Some(s) if s.valid => (s.cpu_percent, s.mem_percent), + _ => (0.0, 0.0), + }; + + let cpu_card = monitor_card( + "CPU", + format!("{cpu_value:>3.0}%"), + match model.last_snapshot { + Some(s) if s.valid => format!( + "{} de {} muestras", + model.sysmon.cpu_history().len(), + HISTORY + ), + _ => rimay_localize::t("shuma-empty-no-data-linux"), + }, + Color::from_rgb8(0x82, 0xCF, 0xF2), + model.sysmon.cpu_history().values(), + &palette, + ); + + let mem_card = monitor_card( + "MEM", + format!("{mem_value:>3.0}%"), + match model.last_snapshot { + Some(s) if s.valid => format!("{} MB de {} MB", s.mem_used_mb, s.mem_total_mb), + _ => rimay_localize::t("shuma-empty-no-data"), + }, + Color::from_rgb8(0xF7, 0xC8, 0x7A), + model.sysmon.mem_history().values(), + &palette, + ); + + let mut children = vec![cpu_card, mem_card]; + + // Stat-cards extra: una por cada `MonitorSpec` aportado por los módulos vivos. + for (slot, contribs) in collect_contributions(model) { + for spec in &contribs.monitors { + let key = monitor_key(&slot, spec); + let history = model.extra_history.get(&key).cloned().unwrap_or_default(); + let display = model + .extra_display + .get(&key) + .cloned() + .unwrap_or_else(|| "-".into()); + let accent = Color::from_rgb8(spec.accent.r, spec.accent.g, spec.accent.b); + children.push(monitor_card( + spec.label.as_str(), + display, + rimay_localize::t_args( + "shuma-stat-samples", + &[ + ("have", history.len().to_string().into()), + ("total", HISTORY.to_string().into()), + ], + ), + accent, + history, + &palette, + )); + } + } + + View::new(Style { + flex_direction: FlexDirection::Column, + size: Size { width: percent(1.0_f32), height: percent(1.0_f32) }, + padding: Rect { + left: length(10.0_f32), + right: length(10.0_f32), + top: length(10.0_f32), + bottom: length(10.0_f32), + }, + gap: Size { width: length(0.0_f32), height: length(10.0_f32) }, + ..Default::default() + }) + .fill(theme.bg_panel_alt) + .children(children) +} + +pub(crate) fn monitor_card( + label: &str, + value: String, + description: String, + accent: Color, + history: Vec, + palette: &StatCardPalette, +) -> View { + let card = stat_card_view::(label, value, description.as_str(), accent, &[], palette); + let curve = curve_view(history, accent); + + View::new(Style { + flex_direction: FlexDirection::Column, + size: Size { width: percent(1.0_f32), height: Dimension::auto() }, + gap: Size { width: length(0.0_f32), height: length(6.0_f32) }, + ..Default::default() + }) + .children(vec![card, curve]) +} + +pub(crate) fn curve_view(history: Vec, accent: Color) -> View { + View::new(Style { + size: Size { width: percent(1.0_f32), height: length(56.0_f32) }, + ..Default::default() + }) + .paint_with(move |scene, _ts, rect: PaintRect| { + if history.len() < 2 { + return; + } + let n = history.len() as f32; + let dx = if n > 1.0 { rect.w / (n - 1.0) } else { rect.w }; + let mut path = BezPath::new(); + for (i, v) in history.iter().enumerate() { + let x = rect.x + dx * i as f32; + let y = rect.y + rect.h - (v.clamp(0.0, 100.0) / 100.0) * rect.h; + let p = Point::new(x as f64, y as f64); + if i == 0 { + path.push(PathEl::MoveTo(p)); + } else { + path.push(PathEl::LineTo(p)); + } + } + scene.stroke(&Stroke::new(1.5), Affine::IDENTITY, accent, None, &path); + }) +} diff --git a/02_ruway/shuma/shuma-shell-llimphi/src/view/session.rs b/02_ruway/shuma/shuma-shell-llimphi/src/view/session.rs new file mode 100644 index 0000000..abf6738 --- /dev/null +++ b/02_ruway/shuma/shuma-shell-llimphi/src/view/session.rs @@ -0,0 +1,1800 @@ +//! Dientes/cuerpo del sidebar de sesiones (izquierdo, sobre el widget unificado +//! `rag-sidebar`), canvas principal y formularios de creación / configuración. + +use std::sync::Arc; + +use super::super::*; +use super::widgets::*; +use llimphi_ui::llimphi_layout::taffy::prelude::{auto, length, percent, Dimension, Style}; +use llimphi_ui::llimphi_layout::taffy::{AlignItems, FlexDirection, JustifyContent, Position, Rect, Size}; +use llimphi_ui::llimphi_raster::peniko::Color; +use llimphi_ui::{DragPhase, View}; +use llimphi_theme::Theme; +use llimphi_widget_panes::{panes_view, PanesPalette}; +use llimphi_widget_dock_rail::{BadgeKind, DockBadge}; +use llimphi_widget_rag_sidebar::RagTooth; +use crate::workspace::FloatPane; +use llimphi_widget_select::{ + select_trigger_view, SelectItem, SelectPalette, +}; + +// ─── Dientes de sesión (rail izquierdo del widget unificado) ──────── + +/// El nombre de una sesión tal como se muestra en el diente (y, para la activa, +/// en el cabezal del panel del widget). +pub(super) fn session_tooth_title(s: &Session) -> String { + if s.pending { + "Sesión nueva".to_string() + } else if s.kind == SessionKind::Draft { + "local · scratch".to_string() + } else { + s.name.clone() + } +} + +/// Los **dientes** del sidebar de sesiones: uno por sesión (draft primero, luego +/// las creadas). El id del diente = su índice; el `Activate` se intercepta a +/// `SelectSession`. El buscador del sidebar filtra las sesiones por nombre. El +/// número de la sesión va como distintivo (badge); la actividad + tipo + aviso +/// de comando largo se pintan dentro del icono. +pub(super) fn session_teeth(model: &Model, _theme: &Theme) -> Vec> { + let filtro = model.sidebar_left.search.trim().to_lowercase(); + model + .sessions + .iter() + .enumerate() + .filter_map(|(i, s)| { + let name = session_tooth_title(s); + if !filtro.is_empty() && !name.to_lowercase().contains(&filtro) { + return None; + } + let activa = i == model.active_session; + let kind = s.kind; + let activity = s.activity(); + // A6 — aviso de comando largo: sólo en sesiones NO activas. + let alerta_larga = !activa && s.long_alerts() > 0; + let icon: Arc View + Send + Sync> = + Arc::new(move |size, color| session_tooth_icon(kind, activity, alerta_larga, size, color)); + let mut t = RagTooth::new(i as u64, name, icon); + if let Some(n) = s.number { + t.badge = Some(DockBadge::Count(n, BadgeKind::Neutral)); + } + Some(t) + }) + .collect() +} + +/// Color del LED de actividad para el aviso visual: quieto (gris), movimiento +/// (verde), claude (naranja claude). Transversal a diente y tab. +pub(super) fn activity_led_color(act: shuma_module_shell::Activity) -> Color { + use shuma_module_shell::Activity; + match act { + Activity::Idle => Color::from_rgb8(0x55, 0x5a, 0x66), + Activity::Busy => Color::from_rgb8(0x4a, 0xde, 0x80), + Activity::Claude => Color::from_rgb8(0xd9, 0x77, 0x57), + } +} + +/// Icono vectorial del diente de una sesión según su tipo. `alert` (A6) pinta +/// una badge ámbar cuando un comando largo terminó en una sesión no-activa. +fn session_tooth_icon( + kind: SessionKind, + activity: shuma_module_shell::Activity, + alert: bool, + size: f32, + color: Color, +) -> View { + View::new(Style { + size: Size { width: length(size), height: length(size) }, + align_items: Some(AlignItems::Center), + justify_content: Some(JustifyContent::Center), + ..Default::default() + }) + .paint_with(move |scene, _ts, rect| { + use llimphi_ui::llimphi_raster::kurbo::{ + Affine, BezPath, Circle, Line, Point, RoundedRect, Stroke, + }; + use llimphi_ui::llimphi_raster::peniko::{Color as PColor, Fill}; + if rect.w <= 0.0 || rect.h <= 0.0 { + return; + } + let cx = (rect.x + rect.w * 0.5) as f64; + let cy = (rect.y + rect.h * 0.5) as f64; + let r = (rect.w.min(rect.h) as f64 * 0.34).max(2.0); + let stroke = Stroke::new((r * 0.22).max(1.2)); + match kind { + SessionKind::Draft | SessionKind::Local => { + let sq = RoundedRect::new(cx - r, cy - r, cx + r, cy + r, 2.5); + scene.fill(Fill::NonZero, Affine::IDENTITY, color, None, &sq); + } + SessionKind::Remote => { + scene.stroke( + &stroke, + Affine::IDENTITY, + color, + None, + &Circle::new((cx, cy), r), + ); + scene.stroke( + &stroke, + Affine::IDENTITY, + color, + None, + &Line::new(Point::new(cx - r, cy), Point::new(cx + r, cy)), + ); + let mut m = BezPath::new(); + m.move_to(Point::new(cx, cy - r)); + m.quad_to(Point::new(cx - r, cy), Point::new(cx, cy + r)); + m.quad_to(Point::new(cx + r, cy), Point::new(cx, cy - r)); + scene.stroke(&stroke, Affine::IDENTITY, color, None, &m); + } + } + // LED de actividad — quieto / movimiento / claude por color. + let led = activity_led_color(activity); + // claude/movimiento llevan halo tenue para que cante; quieto va seco. + if !matches!(activity, shuma_module_shell::Activity::Idle) { + scene.fill( + Fill::NonZero, + Affine::IDENTITY, + led.with_alpha(0.30), + None, + &Circle::new((cx + r * 1.05, cy - r * 1.05), (r * 0.32).max(1.5) * 1.9), + ); + } + scene.fill( + Fill::NonZero, + Affine::IDENTITY, + led, + None, + &Circle::new((cx + r * 1.05, cy - r * 1.05), (r * 0.32).max(1.5)), + ); + // A6 — badge de comando largo: punto ámbar en la esquina opuesta al LED + // (abajo-izquierda), con un halo tenue para que cante un poco más. + if alert { + let ambar = PColor::from_rgb8(0xf7, 0xc8, 0x7a); + let bx = cx - r * 1.05; + let by = cy + r * 1.05; + let rad = (r * 0.36).max(1.8); + scene.fill( + Fill::NonZero, + Affine::IDENTITY, + ambar.with_alpha(0.30), + None, + &Circle::new((bx, by), rad * 1.9), + ); + scene.fill(Fill::NonZero, Affine::IDENTITY, ambar, None, &Circle::new((bx, by), rad)); + } + }) +} + +// ─── Cuerpo de sesión (panel del sidebar izquierdo) ───────────────── + +/// El **cuerpo** del panel de la sesión activa (el cabezal —con el nombre de la +/// sesión— lo pone el widget unificado): toda su configuración, a alto 100%. +pub(super) fn session_body(model: &Model, theme: &Theme) -> View { + use llimphi_ui::llimphi_text::Alignment; + + let Some(session) = model.active() else { + return session_column(Vec::new(), theme); + }; + let idx = model.active_session; + let es_draft = session.kind == SessionKind::Draft; + + if session.pending { + return session_column( + vec![panel_note( + "Configurá los datos en el canvas. Enter confirma, Esc cancela.", + theme, + )], + theme, + ); + } + + let mut children: Vec> = Vec::new(); + children.push(conn_pill(session.conn, theme)); + + if !es_draft { + let label = match session.conn { + ConnState::Connected => "Reconectar", + _ => "Conectar", + }; + children.push(action_button_small(label, Msg::ReconnectSession(idx), theme)); + } + + children.extend(host_select(model, session, theme)); + children.push(container_toggle(session.use_container, theme)); + if session.use_container { + children.extend(container_picker(model, session, theme)); + } + + // Persistencia: el flag guarda el output a disco (cada 5 s + al toggle) + // y lo restaura al reabrir la app. La draft es scratch — no aplica. + if !es_draft { + children.push(toggle_row( + "Persistir sesión (output al reabrir)", + session.persist, + Msg::ToggleSessionPersist(idx), + theme, + )); + } + + // Environment: los grupos de env.json, activables en bloque. + children.extend(env_section(model, theme)); + + if !es_draft { + children.push(panel_label("cwd", theme)); + children.push(panel_note(&session_cwd(session), theme)); + children.push( + View::new(Style { + size: Size { width: percent(1.0_f32), height: length(30.0_f32) }, + margin: Rect { + left: length(0.0_f32), + right: length(0.0_f32), + top: length(10.0_f32), + bottom: length(0.0_f32), + }, + align_items: Some(AlignItems::Center), + justify_content: Some(JustifyContent::Center), + ..Default::default() + }) + .fill(theme.bg_button) + .hover_fill(theme.bg_button_hover) + .radius(5.0) + .text_aligned( + "Cerrar sesión".to_string(), + 12.0, + theme.fg_text, + Alignment::Center, + ) + .on_click(Msg::CloseSession(idx)), + ); + } + + session_column(children, theme) +} + +/// Columna del cuerpo del panel de sesión: padding + gap entre secciones, a +/// alto 100% (el widget lo pone en un contenedor que crece). Reemplaza al viejo +/// `panel_frame` para el sidebar de sesiones. +fn session_column(children: Vec>, theme: &Theme) -> View { + View::new(Style { + flex_direction: FlexDirection::Column, + size: Size { width: percent(1.0_f32), height: percent(1.0_f32) }, + min_size: Size { width: length(0.0_f32), height: length(0.0_f32) }, + padding: Rect { + left: length(12.0_f32), + right: length(12.0_f32), + top: length(10.0_f32), + bottom: length(10.0_f32), + }, + gap: Size { width: length(0.0_f32), height: length(6.0_f32) }, + ..Default::default() + }) + .fill(theme.bg_panel_alt) + .children(children) +} + +fn session_cwd(session: &Session) -> String { + match &session.shell().state { + ModuleState::Shell(sh) => sh.cwd.display().to_string(), + _ => "-".to_string(), + } +} + +/// Píldora de estado de conexión: punto de color + texto. +pub(super) fn conn_pill(conn: ConnState, theme: &Theme) -> View { + use llimphi_ui::llimphi_text::Alignment; + let color = match conn { + ConnState::Connected => { + llimphi_ui::llimphi_raster::peniko::Color::from_rgb8(0x4a, 0xde, 0x80) + } + ConnState::Pending => { + llimphi_ui::llimphi_raster::peniko::Color::from_rgb8(0xf7, 0xc8, 0x7a) + } + ConnState::Disconnected => { + llimphi_ui::llimphi_raster::peniko::Color::from_rgb8(0xe0, 0x6c, 0x6c) + } + }; + let dot = View::new(Style { + size: Size { width: length(12.0_f32), height: length(22.0_f32) }, + align_items: Some(AlignItems::Center), + justify_content: Some(JustifyContent::Center), + ..Default::default() + }) + .paint_with(move |scene, _ts, rect| { + use llimphi_ui::llimphi_raster::kurbo::{Affine, Circle}; + use llimphi_ui::llimphi_raster::peniko::Fill; + let cx = (rect.x + rect.w * 0.5) as f64; + let cy = (rect.y + rect.h * 0.5) as f64; + scene.fill( + Fill::NonZero, + Affine::IDENTITY, + color, + None, + &Circle::new((cx, cy), 4.0), + ); + }); + let txt = View::new(Style { + size: Size { width: percent(1.0_f32), height: Dimension::auto() }, + ..Default::default() + }) + .text_aligned( + conn.label().to_string(), + 11.0, + theme.fg_muted, + Alignment::Start, + ); + View::new(Style { + flex_direction: FlexDirection::Row, + size: Size { width: percent(1.0_f32), height: length(22.0_f32) }, + align_items: Some(AlignItems::Center), + gap: Size { width: length(4.0_f32), height: length(0.0_f32) }, + flex_shrink: 0.0, + ..Default::default() + }) + .children(vec![dot, txt]) +} + +// ─── Selectores inline ───────────────────────────────────────────── + +/// Selector de host remoto: Local + hosts guardados + botón al gestor. +pub(super) fn host_select(model: &Model, session: &Session, theme: &Theme) -> Vec> { + let pal = SelectPalette::from_theme(theme); + let mut out: Vec> = vec![panel_label("Host", theme)]; + let cur_label = match &session.host_label { + None => "Local (esta máquina)".to_string(), + Some(name) => model + .hosts + .iter() + .find(|h| &h.name == name) + .map(|h| h.display()) + .unwrap_or_else(|| name.clone()), + }; + let cur_item = SelectItem::new(cur_label); + out.push(select_trigger_view( + Some(&cur_item), + "Elige el host…", + model.dropdown_open == Some(DropKind::Host), + None, + &pal, + |_, _, _, _| Msg::ToggleDropdown(DropKind::Host), + )); + if model.dropdown_open == Some(DropKind::Host) { + let mut rows: Vec> = + vec![pick_row("Local (esta máquina)".to_string(), Msg::PickHost(None), theme)]; + for (i, h) in model.hosts.iter().enumerate() { + rows.push(pick_row(h.display(), Msg::PickHost(Some(i)), theme)); + } + out.push(inline_list(rows)); + } + out.push(action_button_small("Gestionar hosts…", Msg::OpenHostsWindow, theme)); + out +} + +/// Selector de contenedor: rootfs o podman, inline, + botón al gestor. +pub(super) fn container_picker(model: &Model, session: &Session, theme: &Theme) -> Vec> { + let pal = SelectPalette::from_theme(theme); + let mut out: Vec> = vec![panel_label("Contenedor", theme)]; + let cont_sel = session.container.as_ref().map(|c| { + let short = c.rsplit('/').find(|s| !s.is_empty()).unwrap_or(c.as_str()); + SelectItem::new(short.to_string()) + }); + out.push(select_trigger_view( + cont_sel.as_ref(), + "Elige un contenedor…", + model.dropdown_open == Some(DropKind::Container), + None, + &pal, + |_, _, _, _| Msg::ToggleDropdown(DropKind::Container), + )); + let es_local = session.host_key() == "local"; + if model.dropdown_open == Some(DropKind::Container) { + if !es_local { + if model.remote_containers.is_empty() { + out.push(panel_note( + "Sin contenedores en el host remoto (o no respondió aún).", + theme, + )); + } else { + let mut rows: Vec> = Vec::new(); + for c in &model.remote_containers { + rows.push(pick_row(c.clone(), Msg::PickRemoteContainer(c.clone()), theme)); + } + out.push(inline_list(rows)); + } + } else { + let mut rows: Vec> = Vec::new(); + for distro in &[Distro::Ubuntu, Distro::Debian, Distro::Alpine, Distro::Arch] { + if rootfs_listo(*distro) { + let d = *distro; + rows.push(pick_row( + format!("rootfs · {}", d.label()), + Msg::PickRootfs(d), + theme, + )); + } + } + for (i, c) in model.containers.iter().enumerate() { + rows.push(pick_row(c.clone(), Msg::SubscribeContainer(i), theme)); + } + if rows.is_empty() { + out.push(panel_note( + "Sin contenedores — usa «Gestionar contenedores».", + theme, + )); + } else { + out.push(inline_list(rows)); + } + } + } + out.push(action_button_small( + "Gestionar contenedores…", + Msg::OpenContainersWindow, + theme, + )); + out +} + +/// Checkbox "Aislar en contenedor". +pub(super) fn container_toggle(on: bool, theme: &Theme) -> View { + toggle_row( + "Aislar con rootfs propio (sin instalar nada)", + on, + Msg::ToggleUseContainer, + theme, + ) +} + +/// Fila checkbox genérica del panel: cajita + label, click alterna `msg`. +pub(super) fn toggle_row(label: &str, on: bool, msg: Msg, theme: &Theme) -> View { + use llimphi_ui::llimphi_text::Alignment; + let accent = theme.accent; + let fg = theme.fg_text; + let bg = theme.bg_panel_alt; + let box_view = View::new(Style { + size: Size { width: length(18.0_f32), height: length(18.0_f32) }, + flex_shrink: 0.0, + ..Default::default() + }) + .paint_with(move |scene, _ts, rect| { + use llimphi_ui::llimphi_raster::kurbo::{Affine, BezPath, Point, RoundedRect, Stroke}; + use llimphi_ui::llimphi_raster::peniko::Fill; + let rr = RoundedRect::new( + rect.x as f64 + 1.0, + rect.y as f64 + 1.0, + rect.x as f64 + rect.w as f64 - 1.0, + rect.y as f64 + rect.h as f64 - 1.0, + 3.0, + ); + if on { + scene.fill(Fill::NonZero, Affine::IDENTITY, accent, None, &rr); + let mut p = BezPath::new(); + let cx = (rect.x + rect.w * 0.5) as f64; + let cy = (rect.y + rect.h * 0.5) as f64; + let r = (rect.w.min(rect.h) as f64) * 0.28; + p.move_to(Point::new(cx - r, cy)); + p.line_to(Point::new(cx - r * 0.2, cy + r * 0.7)); + p.line_to(Point::new(cx + r, cy - r * 0.6)); + scene.stroke( + &Stroke::new(2.0), + Affine::IDENTITY, + llimphi_ui::llimphi_raster::peniko::Color::from_rgb8(0xff, 0xff, 0xff), + None, + &p, + ); + } else { + scene.fill(Fill::NonZero, Affine::IDENTITY, bg, None, &rr); + scene.stroke( + &Stroke::new(1.2), + Affine::IDENTITY, + llimphi_ui::llimphi_raster::peniko::Color::from_rgb8(0x55, 0x5a, 0x66), + None, + &rr, + ); + } + }); + let label = View::new(Style { + size: Size { width: Dimension::auto(), height: length(20.0_f32) }, + flex_grow: 1.0, + ..Default::default() + }) + .text_aligned(label.to_string(), 13.0, fg, Alignment::Start); + View::new(Style { + flex_direction: FlexDirection::Row, + size: Size { width: percent(1.0_f32), height: length(26.0_f32) }, + align_items: Some(AlignItems::Center), + gap: Size { width: length(8.0_f32), height: length(0.0_f32) }, + margin: Rect { + left: length(0.0_f32), + right: length(0.0_f32), + top: length(10.0_f32), + bottom: length(0.0_f32), + }, + ..Default::default() + }) + .on_click(msg) + .hover_fill(theme.bg_row_hover) + .children(vec![box_view, label]) +} + +// ─── Environment (grupos activables) ──────────────────────────────── + +/// Cuántas variables se listan por grupo antes de resumir con "+N más". +const ENV_VARS_VISIBLES: usize = 6; + +/// Sección «Environment» del panel: cada grupo de `env.json` con su link +/// on/off (click = activar/desactivar el grupo entero) y sus variables +/// listadas debajo. `:env NAME=valor [@grupo]` agrega desde el teclado. +pub(super) fn env_section(model: &Model, theme: &Theme) -> Vec> { + use llimphi_ui::llimphi_text::Alignment; + let mut out: Vec> = vec![panel_label("Environment", theme)]; + for (i, g) in model.env_groups.iter().enumerate() { + // Fila del grupo: [on|off] nombre · N — click alterna el grupo. + let (pill_fill, pill_fg, pill_txt) = if g.active { + (theme.accent, theme.bg_panel, "on") + } else { + (theme.bg_panel_alt, theme.fg_muted, "off") + }; + let pill = View::new(Style { + size: Size { width: length(30.0_f32), height: length(16.0_f32) }, + flex_shrink: 0.0, + align_items: Some(AlignItems::Center), + justify_content: Some(JustifyContent::Center), + ..Default::default() + }) + .fill(pill_fill) + .radius(8.0) + .text_aligned(pill_txt.to_string(), 10.0, pill_fg, Alignment::Center); + let name_color = if g.active { theme.fg_text } else { theme.fg_muted }; + let nombre = View::new(Style { + size: Size { width: Dimension::auto(), height: length(16.0_f32) }, + flex_grow: 1.0, + ..Default::default() + }) + .text_aligned(g.name.clone(), 12.0, name_color, Alignment::Start); + let count = View::new(Style { + size: Size { width: length(24.0_f32), height: length(16.0_f32) }, + flex_shrink: 0.0, + ..Default::default() + }) + .text_aligned(g.vars.len().to_string(), 10.0, theme.fg_muted, Alignment::End); + out.push( + View::new(Style { + flex_direction: FlexDirection::Row, + size: Size { width: percent(1.0_f32), height: length(24.0_f32) }, + align_items: Some(AlignItems::Center), + gap: Size { width: length(8.0_f32), height: length(0.0_f32) }, + margin: Rect { + left: length(0.0_f32), + right: length(0.0_f32), + top: length(4.0_f32), + bottom: length(0.0_f32), + }, + flex_shrink: 0.0, + ..Default::default() + }) + .hover_fill(theme.bg_row_hover) + .radius(4.0) + .on_click(Msg::ToggleEnvGroup(i)) + .children(vec![pill, nombre, count]), + ); + // Variables del grupo, indentadas y discretas. + for (k, v) in g.vars.iter().take(ENV_VARS_VISIBLES) { + let color = if g.active { theme.fg_muted } else { theme.fg_placeholder }; + out.push( + View::new(Style { + size: Size { width: percent(1.0_f32), height: length(15.0_f32) }, + padding: Rect { + left: length(38.0_f32), + right: length(0.0_f32), + top: length(0.0_f32), + bottom: length(0.0_f32), + }, + flex_shrink: 0.0, + ..Default::default() + }) + .text_aligned(format!("{k}={v}"), 10.0, color, Alignment::Start) + .mono() + .max_lines(1), + ); + } + if g.vars.len() > ENV_VARS_VISIBLES { + out.push(panel_note( + &format!(" +{} más", g.vars.len() - ENV_VARS_VISIBLES), + theme, + )); + } + if g.vars.is_empty() { + out.push( + View::new(Style { + size: Size { width: percent(1.0_f32), height: length(15.0_f32) }, + padding: Rect { + left: length(38.0_f32), + right: length(0.0_f32), + top: length(0.0_f32), + bottom: length(0.0_f32), + }, + flex_shrink: 0.0, + ..Default::default() + }) + .text_aligned("(vacío)".to_string(), 10.0, theme.fg_placeholder, Alignment::Start), + ); + } + } + out.push(panel_note(":env NAME=valor @grupo agrega una variable", theme)); + out +} + +// ─── Canvas principal (workspace tipo zellij) ─────────────────────── + +/// El canvas principal: el **workspace** de la sesión activa — una barra de +/// tabs, el árbol tiling de paneles y la capa de flotantes encima. Si la sesión +/// está en form de creación, ese form ocupa el canvas en su lugar. +pub(super) fn canvas_view(model: &Model, theme: &Theme) -> View { + let body = match model.active() { + None => placeholder(theme, &rimay_localize::t("shuma-empty-no-tabs")), + Some(s) if s.pending => new_session_form(model, s, theme), + Some(s) => workspace_view(model, s, theme), + }; + View::new(Style { + flex_direction: FlexDirection::Column, + flex_grow: 1.0, + size: Size { width: length(0.0_f32), height: percent(1.0_f32) }, + ..Default::default() + }) + .children(vec![body]) +} + +/// Render del workspace de una sesión: barra de tabs + tiling + flotantes. +fn workspace_view(model: &Model, session: &Session, theme: &Theme) -> View { + let idx = model.active_session; + let ws = &session.workspace; + let tab = ws.tab(); + let focused = tab.focused; + let pal = PanesPalette::from_theme(theme); + + // Árbol tiling. La hoja la materializa `pane_body` por id. + let tiled = panes_view( + &tab.layout, + focused, + |id| pane_body(model, idx, id, id == focused, theme), + |path, phase, delta| match phase { + DragPhase::Move => Some(Msg::PaneResize(path, delta)), + DragPhase::End => None, + }, + Msg::PaneFocus, + &pal, + ); + let tiled_box = View::new(Style { + flex_grow: 1.0, + size: full(), + min_size: zero(), + ..Default::default() + }) + .children(vec![tiled]); + + // Capa flotante: cada panel como caja absoluta sobre el tiling. + let mut stack_children = vec![tiled_box]; + if tab.show_floating { + for f in &tab.floating { + stack_children.push(floating_pane(model, idx, f, f.id == focused, theme)); + } + } + let stack = View::new(Style { + position: Position::Relative, + flex_grow: 1.0, + size: full(), + min_size: zero(), + ..Default::default() + }) + .children(stack_children); + + // Con el gestor abierto, el drawer pinta el taskmanager en vez de los panes. + let contenido = if model.taskmanager_open { + taskmanager_view(model, theme) + } else { + stack + }; + + View::new(Style { + flex_direction: FlexDirection::Column, + size: Size { width: percent(1.0_f32), height: percent(1.0_f32) }, + ..Default::default() + }) + .children(vec![tab_bar(model, ws, theme), contenido]) +} + +/// Tamaño 100%/100%. +fn full() -> Size { + Size { width: percent(1.0_f32), height: percent(1.0_f32) } +} +fn zero() -> Size { + Size { width: length(0.0_f32), height: length(0.0_f32) } +} + +/// Materializa el contenido de un panel (tiled o flotante): el shell de ese +/// panel, ruteado a su `Slot::Session(idx, Which::Pane(id))`. El panel con foco, +/// si la app está hospedada en una barra (pata), pinta sólo el cuerpo (su input +/// vive en la barra del host). +fn pane_body(model: &Model, idx: usize, id: u64, focused: bool, theme: &Theme) -> View { + let lift = move |m| Msg::Module(Slot::Session(idx, Which::Pane(id)), ModuleMsg::Shell(m)); + let inst = model.active().and_then(|s| s.workspace.pane(id)); + match inst.map(|i| &i.state) { + Some(ModuleState::Shell(state)) if model.hosted_bar && focused => { + // Input hospedado en la barra del host: el popup baja desde él (al + // tope). El anchor real aún no se hila por shuma_app; default barra-arriba. + shuma_module_shell::body_view::(state, theme, lift, true) + } + Some(ModuleState::Shell(state)) => shuma_module_shell::view::(state, theme, lift), + _ => placeholder(theme, ""), + } +} + +/// Un panel flotante: caja absoluta con cabecera arrastrable + cuerpo del shell. +fn floating_pane( + model: &Model, + idx: usize, + f: &FloatPane, + focused: bool, + theme: &Theme, +) -> View { + use llimphi_ui::llimphi_text::Alignment; + let border = if focused { theme.accent } else { theme.border }; + let id = f.id; + + // Cabecera: drag para mover + botón de cierre. + let title = View::new(Style { + size: Size { width: Dimension::auto(), height: length(18.0_f32) }, + flex_grow: 1.0, + ..Default::default() + }) + .text_aligned("flotante".to_string(), 11.0, theme.fg_muted, Alignment::Start); + let close = View::new(Style { + size: Size { width: length(22.0_f32), height: length(22.0_f32) }, + flex_shrink: 0.0, + align_items: Some(AlignItems::Center), + justify_content: Some(JustifyContent::Center), + ..Default::default() + }) + .hover_fill(theme.bg_row_hover) + .radius(4.0) + .text_aligned("✕".to_string(), 12.0, theme.fg_muted, Alignment::Center) + .on_click(Msg::PaneClose); + let header = View::new(Style { + flex_direction: FlexDirection::Row, + size: Size { width: percent(1.0_f32), height: length(24.0_f32) }, + align_items: Some(AlignItems::Center), + padding: Rect { + left: length(8.0_f32), + right: length(4.0_f32), + top: length(0.0_f32), + bottom: length(0.0_f32), + }, + flex_shrink: 0.0, + ..Default::default() + }) + .fill(theme.bg_panel_alt) + .draggable(move |_phase, dx, dy| Some(Msg::FloatMove(id, dx, dy))) + .children(vec![title, close]); + + let body = View::new(Style { + flex_grow: 1.0, + flex_direction: FlexDirection::Column, + size: full(), + min_size: zero(), + ..Default::default() + }) + .fill(theme.bg_app) + .children(vec![pane_body(model, idx, id, focused, theme)]); + + // Marco con el truco del padding (no hay stroke): caja exterior rellena con + // el color de borde + padding 2px; el interior tapa el centro. + let inner = View::new(Style { + flex_direction: FlexDirection::Column, + size: full(), + min_size: zero(), + padding: Rect { + left: length(2.0_f32), + right: length(2.0_f32), + top: length(2.0_f32), + bottom: length(2.0_f32), + }, + ..Default::default() + }) + .fill(border) + .children(vec![View::new(Style { + flex_direction: FlexDirection::Column, + size: full(), + min_size: zero(), + ..Default::default() + }) + .fill(theme.bg_app) + .children(vec![header, body])]); + + View::new(Style { + position: Position::Absolute, + inset: Rect { + left: length(f.x), + top: length(f.y), + right: auto(), + bottom: auto(), + }, + size: Size { width: length(f.w), height: length(f.h) }, + flex_direction: FlexDirection::Column, + ..Default::default() + }) + .on_click(Msg::PaneFocus(id)) + .children(vec![inner]) +} + +// ─── Pestañas vivas ───────────────────────────────────────────────── +// +// Una pestaña de terminal tiene que contestar tres preguntas de un vistazo, +// sin que uno la abra: **qué es** (título de contexto), **si te llama** +// (parpadeo del aviso) y **cuánto se mueve** (el hilo de cava abajo). +// +// - El título sale de `State::titulo_contexto`: título OSC del programa → +// programa corriendo → cwd. Es lo mismo que muestra cualquier terminal. +// - El aviso lo levanta `WsTab::refrescar_aviso` por flanco y sobrevive hasta +// que visitás la pestaña. +// - El hilo son los ~2,4 s de caudal de `shuma_module_shell::pulso`. + +/// Ancho mínimo de una pestaña (px): con la barra llena, todas se achican hasta +/// acá y recién entonces el título se corta con puntos suspensivos. +const TAB_MIN: f32 = 96.0; +/// Ancho máximo de una pestaña (px). Generoso —un título de contexto entero +/// entra— pero no es el ancho de la barra: una pestaña sola sigue siendo una +/// pestaña, no un encabezado. +const TAB_MAX: f32 = 340.0; +/// Ancho reservado para la ✕ de cerrar (px). +const CERRAR_W: f32 = 18.0; +/// Alto del hilo de cava bajo cada pestaña (px). +const HILO_H: f32 = 4.0; +/// Alto de la fila de título de la pestaña (px). +const TITULO_H: f32 = 22.0; +/// Bins por segundo del muestreo del pulso — la fase parpadea a 1 Hz. +const FASE_HZ: u64 = 10; + +/// Color de un aviso pendiente. Sigue la convención del repo: verde ok, rojo +/// error, ámbar «te esperan», acento para la campana (que es el programa +/// llamándote por su cuenta, no un resultado). +fn aviso_color(a: crate::workspace::TabAviso, theme: &Theme) -> Color { + use crate::workspace::TabAviso as A; + match a { + A::Campana => theme.accent, + A::Espera => Color::from_rgb8(0xE0, 0xB2, 0x4A), + A::Ok => Color::from_rgb8(0x4a, 0xde, 0x80), + A::Error => Color::from_rgb8(0xE0, 0x5A, 0x5A), + } +} + +/// Mezcla lineal de dos colores (`t=0` → `a`, `t=1` → `b`). +fn mezclar(a: Color, b: Color, t: f32) -> Color { + let t = t.clamp(0.0, 1.0); + let mut out = a; + for i in 0..4 { + out.components[i] = a.components[i] + (b.components[i] - a.components[i]) * t; + } + out +} + +/// Onda de parpadeo 0..1 a 1 Hz, suave (sin el corte duro de un cuadrado — un +/// parpadeo binario en el borde del campo visual es una molestia, uno que +/// respira se nota igual y no irrita). +fn respiracion(fase: u64) -> f32 { + let t = (fase % FASE_HZ) as f32 / FASE_HZ as f32; + 0.5 - 0.5 * (t * std::f32::consts::TAU).cos() +} + +/// `true` si alguna pestaña tiene algo que animar: un aviso pendiente o caudal +/// en la ventana del cava. Con todo quieto la barra se pinta idéntica frame a +/// frame y el reloj de fase se congela — ver [[llimphi-layer-repintado-perpetuo]] +/// y la campaña de CPU: animar en reposo es el gasto que más caro sale. +pub(crate) fn tabs_animadas(model: &Model) -> bool { + model.sessions.iter().any(|s| { + s.workspace + .tabs + .iter() + .any(|t| t.aviso().is_some() || t.barras().iter().any(|v| *v > 0.0)) + }) +} + +/// El **hilo de cava** bajo una pestaña: el caudal de salida de sus paneles en +/// los últimos ~2,4 s, de izquierda (viejo) a derecha (ahora). En reposo queda +/// una línea plana tenue — la pestaña sigue existiendo, sólo que callada. +fn hilo_cava(barras: [f32; shuma_module_shell::pulso::MUESTRAS], color: Color) -> View { + // Ancho al 100% del chip, no fijo: la pestaña negocia su ancho con las + // vecinas y el hilo tiene que acompañarla, no quedarse con el ancho que la + // pestaña *pidió* antes de encogerse. + View::new(Style { + size: Size { width: percent(1.0_f32), height: length(HILO_H) }, + flex_shrink: 0.0, + ..Default::default() + }) + .paint_with(move |scene, _ts, rect| { + use llimphi_ui::llimphi_raster::kurbo::{Affine, Rect as KRect}; + use llimphi_ui::llimphi_raster::peniko::Fill; + if rect.w <= 0.0 || rect.h <= 0.0 { + return; + } + let n = barras.len() as f32; + let paso = rect.w / n; + // Línea de base: el hilo nunca desaparece del todo, así se lee como + // «esto está en silencio» y no como «esto no existe». + let base = KRect::new( + rect.x as f64, + (rect.y + rect.h - 1.0) as f64, + (rect.x + rect.w) as f64, + (rect.y + rect.h) as f64, + ); + scene.fill(Fill::NonZero, Affine::IDENTITY, color.with_alpha(0.18), None, &base); + for (i, v) in barras.iter().enumerate() { + if *v <= 0.0 { + continue; + } + let alto = (v * rect.h).max(1.0); + let x0 = rect.x + i as f32 * paso; + // 0,6 px de aire entre bins: se lee como cava, no como una mancha. + let x1 = (x0 + paso - 0.6).max(x0 + 0.4); + let barra = KRect::new( + x0 as f64, + (rect.y + rect.h - alto) as f64, + x1 as f64, + (rect.y + rect.h) as f64, + ); + // Las ráfagas fuertes pintan más sólido — la intensidad se lee por + // altura Y por presencia, como en el cava de pata. + let a = 0.35 + 0.65 * v; + scene.fill(Fill::NonZero, Affine::IDENTITY, color.with_alpha(a), None, &barra); + } + }) +} + +/// Un chip de pestaña: LED + título arriba, hilo de cava abajo. +fn tab_chip( + model: &Model, + t: &crate::workspace::WsTab, + i: usize, + activa: bool, + n_tabs: usize, + theme: &Theme, +) -> View { + use llimphi_ui::llimphi_text::Alignment; + + let titulo = t.titulo(i); + // Ancho del chip: lo que mida el título (medido con la fuente real) más el + // LED y el padding, acotado para que 8 pestañas sigan entrando. + // Ancho FLEXIBLE. El chip pide lo que mide su título (con la fuente real) y + // cede si no hay lugar: `flex_basis` es el deseo, `flex_shrink` la + // negociación, `min_size` el piso. Con una sola pestaña se estira hasta + // `TAB_MAX` y el título se lee entero; con ocho se achican todas juntas + // hasta `TAB_MIN` y recién ahí aparecen los puntos suspensivos. + // + // El tope NO es el ancho de la barra a propósito: una pestaña sola no debe + // ocupar todo — sigue siendo una pestaña, no un encabezado. + let hueco_cerrar = if n_tabs > 1 { CERRAR_W } else { 0.0 }; + let deseado = (llimphi_ui::llimphi_text::text_width(&titulo, 12.0) + 40.0 + hueco_cerrar) + .clamp(TAB_MIN, TAB_MAX); + + // Atenuación por quietud: una pestaña dormida hace minutos se apaga (pero + // no se borra). La activa nunca se apaga — la estás mirando. + let aten = if activa { 1.0 } else { t.atenuacion() }; + let latido = t.aviso().map(|_| respiracion(model.pulso_fase)).unwrap_or(0.0); + + // Color rector del chip: el del aviso si hay uno pendiente, si no el de + // actividad (quieto / movimiento / claude). + let base_color = activity_led_color(t.activity()); + let color = match t.aviso() { + Some(a) => mezclar(base_color, aviso_color(a, theme), 0.35 + 0.65 * latido), + None => base_color, + }; + + let fill_base = if activa { theme.bg_selected } else { theme.bg_panel_alt }; + // Tinte del chip cuando algo lo llama: sutil, sólo para que se enganche con + // el rabillo del ojo. El grueso del aviso lo lleva el LED. + let fill = match t.aviso() { + Some(a) => mezclar(fill_base, aviso_color(a, theme), 0.10 + 0.14 * latido), + None => fill_base, + }; + // El título va SIEMPRE a color pleno. La atenuación por quietud es para las + // señales de vida —el LED y el hilo de cava—, no para el rótulo: lerpear el + // texto hacia el fondo dejaba las pestañas quietas literalmente ilegibles a + // los tres minutos («no veo títulos en los tabs»). Que una pestaña esté + // dormida no la vuelve menos identificable; justamente cuando volvés después + // de un rato es cuando más necesitás leer cuál es cuál. + let fg = if activa { theme.fg_text } else { theme.fg_muted }; + + // LED de actividad/aviso. En pestañas inactivas con claude o con un comando + // corriendo es la única forma de enterarte. + let led = View::new(Style { + size: Size { width: length(7.0_f32), height: length(7.0_f32) }, + flex_shrink: 0.0, + ..Default::default() + }) + .fill(color.with_alpha(color.components[3] * aten)) + .radius(4.0); + + // Renombrando ESTA pestaña (menú contextual → «Renombrar…»): en vez del + // rótulo va un campo editable, en el propio chip. Enter confirma, Esc + // cancela y vacío la devuelve al título automático (ver `Msg::TabRename*`). + let renombrando = model + .tab_rename + .as_ref() + .filter(|(idx, _)| *idx == i) + .map(|(_, campo)| campo); + let nombre = match renombrando { + Some(campo) => View::new(Style { + size: Size { width: Dimension::auto(), height: length(TITULO_H) }, + flex_grow: 1.0, + min_size: Size { width: length(0.0_f32), height: auto() }, + align_items: Some(AlignItems::Center), + ..Default::default() + }) + .children(vec![llimphi_widget_text_input::text_input_view( + campo, + "nombre de la pestaña", + true, + &llimphi_widget_text_input::TextInputPalette::from_theme(theme), + Msg::Noop, + )]), + None => View::new(Style { + size: Size { width: Dimension::auto(), height: length(TITULO_H) }, + flex_grow: 1.0, + min_size: Size { width: length(0.0_f32), height: auto() }, + align_items: Some(AlignItems::Center), + ..Default::default() + }) + .text_aligned(titulo, 12.0, fg, Alignment::Start) + .ellipsis(1), + }; + + // La ✕ de cerrar: presente pero DISCRETA (medio tono), y con su propio + // realce al pasarle por encima — que es lo que evita el click de más. Se + // pensó en mostrarla sólo con hover, pero `View` no expone color de texto + // por hover, y falsearlo con dos nodos superpuestos habría sido peor que el + // problema. El click-medio y el menú contextual siguen andando, para quien + // ya los tiene en el dedo. + // + // No se pinta en la ÚNICA pestaña que queda: cerrarla es no-op + // (`Workspace::close_tab` no cierra la última) y un botón que no hace nada + // es peor que no tenerlo. + let mut hijos = vec![led, nombre]; + if n_tabs > 1 { + hijos.push( + View::new(Style { + size: Size { width: length(CERRAR_W), height: length(16.0_f32) }, + flex_shrink: 0.0, + align_items: Some(AlignItems::Center), + justify_content: Some(JustifyContent::Center), + ..Default::default() + }) + .radius(4.0) + // Transparente en reposo, visible al pasar por encima del chip: el + // hover del padre la revela sin que el layout se mueva (el hueco ya + // está reservado, así que el título no salta al aparecer la ✕). + .text_aligned( + "✕".to_string(), + 11.0, + theme.fg_muted.with_alpha(if activa { 0.75 } else { 0.40 }), + Alignment::Center, + ) + .hover_fill(theme.bg_row_hover) + .tooltip("Cerrar la pestaña") + .cursor(llimphi_ui::Cursor::Pointer) + .on_click(Msg::TabClose(i)), + ); + } + + let fila = View::new(Style { + flex_direction: FlexDirection::Row, + size: Size { width: percent(1.0_f32), height: length(TITULO_H) }, + align_items: Some(AlignItems::Center), + gap: Size { width: length(6.0_f32), height: length(0.0_f32) }, + padding: Rect { + left: length(10.0_f32), + right: length(if n_tabs > 1 { 4.0 } else { 10.0 }), + top: length(0.0_f32), + bottom: length(0.0_f32), + }, + ..Default::default() + }) + .children(hijos); + + // Chip: click activa, click-medio cierra (sin la ✕ riesgosa), click derecho + // abre el menú contextual de la tab. + View::new(Style { + flex_direction: FlexDirection::Column, + size: Size { width: Dimension::auto(), height: length(TITULO_H + HILO_H) }, + flex_basis: length(deseado), + flex_grow: 0.0, + flex_shrink: 1.0, + min_size: Size { width: length(TAB_MIN.min(deseado)), height: auto() }, + max_size: Size { width: length(deseado), height: auto() }, + ..Default::default() + }) + .fill(fill) + .hover_fill(if activa { fill } else { theme.bg_row_hover }) + .radius(4.0) + .on_click(Msg::TabSwitch(i)) + .on_middle_click(Msg::TabClose(i)) + // Coordenadas de PANTALLA (no locales al chip): el menú contextual se ancla + // al puntero y su overlay se monta sobre la surface entera, así que ambos + // tienen que hablar el mismo sistema. Con `on_right_click_at` el anchor + // llegaba relativo al chip (x≈20, y≈10) y el menú se pintaba en un rincón. + .on_right_click_screen(move |x, y, _w, _h| Some(Msg::TabCtxOpen(i, x, y))) + .children(vec![ + fila, + hilo_cava(t.barras(), color.with_alpha(color.components[3] * aten)), + ]) +} + +/// Barra de tabs del workspace: un chip por tab + `+` + controles de tiling. +fn tab_bar(model: &Model, ws: &crate::workspace::Workspace, theme: &Theme) -> View { + let mut row: Vec> = Vec::new(); + for (i, t) in ws.tabs.iter().enumerate() { + row.push(tab_chip(model, t, i, i == ws.active_tab, ws.tabs.len(), theme)); + } + + // `+` tab nueva. + row.push(bar_button("+", Msg::TabNew, theme)); + // Gestor de sesiones (taskmanager): un tab-botón chico que togglea su + // propio canvas en el drawer (lista el fondo del daemon: restaurar/matar). + row.push(bar_button("▤", Msg::TaskManagerToggle, theme)); + + // Spacer. + row.push( + View::new(Style { + flex_grow: 1.0, + size: Size { width: length(0.0_f32), height: length(1.0_f32) }, + ..Default::default() + }), + ); + + // Controles de tiling/flotantes (co-locados con las tabs). Glyphs en + // box-drawing / geometric shapes (presentes en DejaVu, sin tofu): + // `│` parte lado a lado, `─` parte apilado, `✕` cierra el panel, + // `▣` agrega flotante, `□` togglea la capa flotante. + row.push(bar_button("│", Msg::PaneSplit(llimphi_widget_panes::Axis::Horizontal), theme)); + row.push(bar_button("─", Msg::PaneSplit(llimphi_widget_panes::Axis::Vertical), theme)); + row.push(bar_button("✕", Msg::PaneClose, theme)); + row.push(bar_button("▣", Msg::FloatNew, theme)); + row.push(bar_button("□", Msg::FloatToggle, theme)); + + View::new(Style { + flex_direction: FlexDirection::Row, + size: Size { width: percent(1.0_f32), height: length(32.0_f32) }, + align_items: Some(AlignItems::Center), + gap: Size { width: length(4.0_f32), height: length(0.0_f32) }, + padding: Rect { + left: length(6.0_f32), + right: length(6.0_f32), + top: length(3.0_f32), + bottom: length(3.0_f32), + }, + flex_shrink: 0.0, + ..Default::default() + }) + .fill(theme.bg_panel) + .children(row) +} + +/// Botón compacto de la barra de tabs. +fn bar_button(label: &str, msg: Msg, theme: &Theme) -> View { + use llimphi_ui::llimphi_text::Alignment; + View::new(Style { + size: Size { width: length(26.0_f32), height: length(22.0_f32) }, + flex_shrink: 0.0, + align_items: Some(AlignItems::Center), + justify_content: Some(JustifyContent::Center), + ..Default::default() + }) + .fill(theme.bg_panel_alt) + .hover_fill(theme.bg_row_hover) + .radius(4.0) + .text_aligned(label.to_string(), 13.0, theme.fg_muted, Alignment::Center) + .on_click(msg) +} + +// ─── Gestor de sesiones (taskmanager) ──────────────────────────────── +// +// Lo que el gestor tiene que contestar, en orden: +// +// 1. ¿QUÉ es cada sesión? — no "claude", sino *cuál* claude: el título que el +// programa puso (OSC 0/2) y una **miniatura** de su pantalla. Un rótulo con +// el nombre del binario no distingue seis sesiones del mismo binario. +// 2. ¿CUÁLES cerré y cuáles no? — las filas van **agrupadas por estado** +// (`EstadoTab`): abiertas en una pestaña de esta ventana, abiertas en otro +// cliente, al fondo (las que cerraste y siguen corriendo) y terminadas. +// 3. ¿Qué hago con ella? — acciones por grupo: a la que ya está abierta se +// *salta*, a la del fondo se la *restaura*, a la terminada se la *quita*. + +/// Ancho de la miniatura de pantalla, en px. +const TM_MINI_W: f32 = 232.0; +/// Alto de línea de la miniatura. +const TM_MINI_LH: f32 = 11.0; +/// Cuerpo de la fuente de la miniatura (mono). +const TM_MINI_PX: f32 = 8.5; +/// Alto de una tarjeta de sesión. +const TM_CARD_H: f32 = 118.0; +/// Alto del encabezado de un grupo. +const TM_GRUPO_H: f32 = 30.0; +/// Aire entre tarjetas. +const TM_GAP: f32 = 8.0; + +/// El color con que se lee cada estado de un vistazo. +fn tm_color(estado: crate::types::EstadoTab, theme: &Theme) -> Color { + use crate::types::EstadoTab as E; + match estado { + E::EnPestana => theme.accent, + E::EnOtroCliente => Color::from_rgba8(140, 190, 255, 255), + E::AlFondo => Color::from_rgba8(230, 180, 90, 255), + E::Terminada => theme.fg_muted, + } +} + +/// El glifo del estado. `●` lleno = viva y a la vista; `◐` = viva en otro lado; +/// `○` = viva pero sin nadie mirándola; `✕` = terminada. +fn tm_glifo(estado: crate::types::EstadoTab) -> &'static str { + use crate::types::EstadoTab as E; + match estado { + E::EnPestana => "●", + E::EnOtroCliente => "◐", + E::AlFondo => "○", + E::Terminada => "✕", + } +} + +/// El gestor de sesiones: lista TODAS las sesiones del daemon (el fondo +/// incluido) agrupadas por estado, con miniatura, título real, antigüedad y +/// acciones. Reemplaza los panes de la tab activa cuando +/// `model.taskmanager_open`. La data sale de `model.task_rows` (la refresca un +/// worker; la vista no hace IO). +fn taskmanager_view(model: &Model, theme: &Theme) -> View { + use crate::types::EstadoTab as E; + use llimphi_ui::llimphi_text::Alignment; + use llimphi_widget_scroll::{scroll_y, ScrollPalette}; + + let now_ms = std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .map(|d| d.as_millis() as u64) + .unwrap_or(0); + + let vivas = model.task_rows.iter().filter(|r| r.alive).count(); + let rotulo = if model.task_cargando { + "Gestor de sesiones — leyendo el daemon…".to_string() + } else { + format!( + "Gestor de sesiones — {} viva{} de {} en el daemon", + vivas, + if vivas == 1 { "" } else { "s" }, + model.task_rows.len() + ) + }; + + let header = View::new(Style { + flex_direction: FlexDirection::Row, + size: Size { width: percent(1.0_f32), height: length(34.0_f32) }, + align_items: Some(AlignItems::Center), + gap: Size { width: length(8.0_f32), height: length(0.0_f32) }, + padding: Rect { + left: length(10.0_f32), + right: length(10.0_f32), + top: length(4.0_f32), + bottom: length(4.0_f32), + }, + flex_shrink: 0.0, + ..Default::default() + }) + .fill(theme.bg_panel) + .children(vec![ + View::new(Style { + flex_grow: 1.0, + min_size: Size { width: length(0.0_f32), height: auto() }, + ..Default::default() + }) + .text_aligned(rotulo, 14.0, theme.fg_text, Alignment::Start) + .ellipsis(1), + tm_action("↻ refrescar", Msg::TaskManagerRefresh, 96.0, theme), + tm_action("cerrar", Msg::TaskManagerToggle, 64.0, theme), + ]); + + // Contenido: un bloque por grupo no vacío, en el orden del enum. + let mut filas: Vec> = Vec::new(); + let mut alto = 0.0_f32; + if model.task_rows.is_empty() { + filas.push( + View::new(Style { + padding: Rect { + left: length(12.0_f32), + right: length(12.0_f32), + top: length(12.0_f32), + bottom: length(12.0_f32), + }, + ..Default::default() + }) + .text_aligned( + if model.task_cargando { + "(consultando al daemon…)".to_string() + } else { + "(no hay sesiones en el daemon)".to_string() + }, + 13.0, + theme.fg_muted, + Alignment::Start, + ), + ); + alto += 40.0; + } + for estado in [E::EnPestana, E::EnOtroCliente, E::AlFondo, E::Terminada] { + let del_grupo: Vec<&crate::types::TaskRow> = + model.task_rows.iter().filter(|r| r.estado() == estado).collect(); + if del_grupo.is_empty() { + continue; + } + filas.push(tm_grupo_header(estado, del_grupo.len(), theme)); + alto += TM_GRUPO_H + TM_GAP; + for r in del_grupo { + filas.push(task_row_view(r, estado, now_ms, theme)); + alto += TM_CARD_H + TM_GAP; + } + } + + let lista = View::new(Style { + flex_direction: FlexDirection::Column, + size: Size { width: percent(1.0_f32), height: auto() }, + gap: Size { width: length(0.0_f32), height: length(TM_GAP) }, + padding: Rect { + left: length(8.0_f32), + right: length(8.0_f32), + top: length(8.0_f32), + bottom: length(8.0_f32), + }, + ..Default::default() + }) + .children(filas); + + // El viewport es lo que queda del drawer bajo la barra de tabs y el + // encabezado; con miniaturas entran tres o cuatro tarjetas, así que la + // lista SIEMPRE puede desbordar. + let viewport = (model.viewport.1 - 34.0 - 32.0).max(120.0); + let paleta = ScrollPalette { + track: theme.bg_panel, + thumb: theme.fg_muted, + thumb_hover: theme.fg_text, + ..ScrollPalette::default() + }; + + View::new(Style { + flex_direction: FlexDirection::Column, + size: full(), + min_size: zero(), + flex_grow: 1.0, + ..Default::default() + }) + .fill(theme.bg_app) + .children(vec![ + header, + View::new(Style { + size: full(), + min_size: zero(), + flex_grow: 1.0, + ..Default::default() + }) + .children(vec![scroll_y( + model.task_scroll, + alto + 16.0, + viewport, + lista, + Msg::TaskScrollBy, + &paleta, + )]), + ]) +} + +/// Encabezado de un grupo: el estado, cuántas hay y qué significa. +fn tm_grupo_header(estado: crate::types::EstadoTab, n: usize, theme: &Theme) -> View { + use crate::types::EstadoTab as E; + use llimphi_ui::llimphi_text::Alignment; + let col = tm_color(estado, theme); + let ayuda = match estado { + E::EnPestana => "las estás viendo — un clic salta a su pestaña", + E::EnOtroCliente => "otra ventana o un attach desde una terminal las tiene", + E::AlFondo => "nadie las mira; restaurarlas las trae a una pestaña nueva", + E::Terminada => "el proceso salió; quitarlas las borra del registro", + }; + View::new(Style { + flex_direction: FlexDirection::Row, + size: Size { width: percent(1.0_f32), height: length(TM_GRUPO_H) }, + align_items: Some(AlignItems::Center), + gap: Size { width: length(8.0_f32), height: length(0.0_f32) }, + padding: Rect { + left: length(4.0_f32), + right: length(4.0_f32), + top: length(2.0_f32), + bottom: length(2.0_f32), + }, + flex_shrink: 0.0, + ..Default::default() + }) + .children(vec![ + View::new(Style { + size: Size { width: length(10.0_f32), height: length(10.0_f32) }, + flex_shrink: 0.0, + ..Default::default() + }) + .fill(col) + .radius(5.0), + View::new(Style { flex_shrink: 0.0, ..Default::default() }).text_aligned( + format!("{} ({n})", estado.titulo_grupo()), + 12.5, + col, + Alignment::Start, + ), + View::new(Style { + flex_grow: 1.0, + min_size: Size { width: length(0.0_f32), height: auto() }, + ..Default::default() + }) + .text_aligned(ayuda.to_string(), 11.0, theme.fg_muted, Alignment::Start) + .ellipsis(1), + ]) +} + +/// La **miniatura**: las últimas líneas de la pantalla de la sesión, en mono, +/// sobre el fondo de la app. No es una captura de píxeles (una sesión del fondo +/// no tiene ventana que capturar): es su pantalla vt100 re-renderizada por el +/// daemon a texto. Alcanza para reconocer de un vistazo qué había ahí. +fn tm_miniatura(r: &crate::types::TaskRow, viva: bool, theme: &Theme) -> View { + use llimphi_ui::llimphi_text::Alignment; + + let cuerpo: Vec> = if r.preview.is_empty() { + vec![View::new(Style { + size: Size { width: percent(1.0_f32), height: length(TM_MINI_LH) }, + ..Default::default() + }) + .text_aligned( + "(sin salida todavía)".to_string(), + TM_MINI_PX + 1.0, + theme.fg_placeholder, + Alignment::Start, + )] + } else { + r.preview + .iter() + .map(|l| { + View::new(Style { + size: Size { width: percent(1.0_f32), height: length(TM_MINI_LH) }, + flex_shrink: 0.0, + ..Default::default() + }) + .text_aligned( + l.clone(), + TM_MINI_PX, + // La sesión muerta se lee como fantasma: mismo contenido, + // menos presencia. + if viva { theme.fg_text.with_alpha(0.82) } else { theme.fg_muted.with_alpha(0.6) }, + Alignment::Start, + ) + .mono() + .no_wrap() + }) + .collect() + }; + + View::new(Style { + flex_direction: FlexDirection::Column, + // `justify_content: End` ancla el texto ABAJO: lo último que escribió la + // sesión queda pegado al borde inferior, como en un terminal real. + justify_content: Some(JustifyContent::End), + size: Size { width: length(TM_MINI_W), height: percent(1.0_f32) }, + flex_shrink: 0.0, + padding: Rect { + left: length(6.0_f32), + right: length(6.0_f32), + top: length(5.0_f32), + bottom: length(5.0_f32), + }, + ..Default::default() + }) + .fill(theme.bg_app) + .radius(4.0) + .clip(true) + .children(cuerpo) +} + +/// Una tarjeta del gestor: miniatura + título/estado/comando + acciones. +fn task_row_view( + r: &crate::types::TaskRow, + estado: crate::types::EstadoTab, + now_ms: u64, + theme: &Theme, +) -> View { + use crate::types::EstadoTab as E; + use llimphi_ui::llimphi_text::Alignment; + + let col = tm_color(estado, theme); + let inicio = chrono::DateTime::from_timestamp_millis(r.created_ms as i64) + .map(|dt| dt.with_timezone(&chrono::Local).format("%d/%m %H:%M").to_string()) + .unwrap_or_else(|| "?".to_string()); + let edad = humaniza_edad(now_ms.saturating_sub(r.created_ms)); + let id_corto = r.id.get(r.id.len().saturating_sub(6)..).unwrap_or(&r.id).to_string(); + + // Línea de estado: dónde está y desde cuándo. Es la respuesta a "¿esta la + // cerré?" — por eso va en el color del grupo y con el nombre de la pestaña. + let donde = match (estado, r.en_tab.as_ref()) { + (E::EnPestana, Some((_, ti, nombre))) => { + format!("abierta · pestaña {} de «{}»", ti + 1, nombre) + } + (E::EnPestana, None) => "abierta en una pestaña".to_string(), + (E::EnOtroCliente, _) => format!("abierta en otro cliente · {} adjunta(s)", r.attached), + (E::AlFondo, _) => "cerraste la pestaña · sigue corriendo".to_string(), + (E::Terminada, _) => match r.exit_code { + Some(0) => "terminó bien (exit 0)".to_string(), + Some(c) => format!("terminó con error (exit {c})"), + None => "terminada".to_string(), + }, + }; + + let centro = View::new(Style { + flex_direction: FlexDirection::Column, + flex_grow: 1.0, + // El piso a 0 es lo que deja al título ELIDIR en vez de envolverse a dos + // líneas y quedar recortado por el alto de la tarjeta. Sin esto, el + // rótulo se partía y sólo se leía el arranque. + min_size: Size { width: length(0.0_f32), height: auto() }, + gap: Size { width: length(0.0_f32), height: length(3.0_f32) }, + ..Default::default() + }) + .children(vec![ + // El título va COMPLETO (una línea, con `…` sólo si no entra en el ancho + // real). No se recorta a mano: recortar a N caracteres es lo que hacía + // que todas las sesiones se llamaran igual. + View::new(Style { + size: Size { width: percent(1.0_f32), height: length(19.0_f32) }, + flex_shrink: 0.0, + ..Default::default() + }) + .text_aligned(r.titulo(), 14.0, theme.fg_text, Alignment::Start) + .ellipsis(1), + View::new(Style { + flex_direction: FlexDirection::Row, + size: Size { width: percent(1.0_f32), height: length(16.0_f32) }, + align_items: Some(AlignItems::Center), + gap: Size { width: length(6.0_f32), height: length(0.0_f32) }, + flex_shrink: 0.0, + ..Default::default() + }) + .children(vec![ + View::new(Style { flex_shrink: 0.0, ..Default::default() }).text_aligned( + tm_glifo(estado).to_string(), + 11.0, + col, + Alignment::Start, + ), + View::new(Style { + flex_grow: 1.0, + min_size: Size { width: length(0.0_f32), height: auto() }, + ..Default::default() + }) + .text_aligned(donde, 11.5, col, Alignment::Start) + .ellipsis(1), + ]), + View::new(Style { + size: Size { width: percent(1.0_f32), height: length(15.0_f32) }, + flex_shrink: 0.0, + ..Default::default() + }) + .text_aligned(r.cmd.clone(), 11.0, theme.fg_muted, Alignment::Start) + .mono() + .ellipsis(1), + View::new(Style { + size: Size { width: percent(1.0_f32), height: length(15.0_f32) }, + flex_shrink: 0.0, + ..Default::default() + }) + .text_aligned( + format!("{} · inició {inicio} (hace {edad}) · …{id_corto}", r.cwd), + 10.5, + theme.fg_muted.with_alpha(0.85), + Alignment::Start, + ) + .ellipsis(1), + ]); + + // Acciones por estado: a la que ya tenés abierta se salta (restaurarla + // abriría un segundo frontend de la misma sesión), a la del fondo se la trae, + // a la muerta sólo se la saca del registro. + let mut botones = Vec::new(); + match estado { + E::EnPestana => botones.push(tm_action("Ir a la pestaña", Msg::TaskGoTo(r.id.clone()), 118.0, theme)), + E::EnOtroCliente | E::AlFondo => { + botones.push(tm_action("Restaurar acá", Msg::TaskRestore(r.id.clone()), 118.0, theme)) + } + E::Terminada => {} + } + botones.push(tm_action( + if r.alive { "Matar" } else { "Quitar" }, + Msg::TaskKill(r.id.clone()), + 118.0, + theme, + )); + + let acciones = View::new(Style { + flex_direction: FlexDirection::Column, + justify_content: Some(JustifyContent::Center), + gap: Size { width: length(0.0_f32), height: length(6.0_f32) }, + flex_shrink: 0.0, + ..Default::default() + }) + .children(botones); + + View::new(Style { + flex_direction: FlexDirection::Row, + size: Size { width: percent(1.0_f32), height: length(TM_CARD_H) }, + align_items: Some(AlignItems::Stretch), + gap: Size { width: length(12.0_f32), height: length(0.0_f32) }, + padding: Rect { + left: length(10.0_f32), + right: length(10.0_f32), + top: length(9.0_f32), + bottom: length(9.0_f32), + }, + flex_shrink: 0.0, + ..Default::default() + }) + .fill(theme.bg_panel) + .radius(6.0) + .children(vec![tm_miniatura(r, r.alive, theme), centro, acciones]) +} + +/// Una tarjeta del gestor, expuesta para medirla en tests de layout +/// (`tests/taskmanager_layout.rs`): es la pieza donde el título se recortaba. +pub fn tarjeta_de_sesion_para_test( + r: &crate::types::TaskRow, + estado: crate::types::EstadoTab, + now_ms: u64, + theme: &Theme, +) -> View { + task_row_view(r, estado, now_ms, theme) +} + +/// Botón de acción del gestor (ancho fijo `w`). +fn tm_action(label: &str, msg: Msg, w: f32, theme: &Theme) -> View { + use llimphi_ui::llimphi_text::Alignment; + View::new(Style { + size: Size { width: length(w), height: length(24.0_f32) }, + flex_shrink: 0.0, + align_items: Some(AlignItems::Center), + justify_content: Some(JustifyContent::Center), + ..Default::default() + }) + .fill(theme.bg_panel_alt) + .hover_fill(theme.bg_row_hover) + .radius(4.0) + .text_aligned(label.to_string(), 12.0, theme.fg_text, Alignment::Center) + .cursor(llimphi_ui::Cursor::Pointer) + .on_click(msg) +} + +/// "hace 2h 15m" a partir de una duración en ms. +fn humaniza_edad(ms: u64) -> String { + let s = ms / 1000; + if s < 60 { + format!("{s}s") + } else if s < 3600 { + format!("{}m", s / 60) + } else if s < 86_400 { + format!("{}h {}m", s / 3600, (s % 3600) / 60) + } else { + format!("{}d {}h", s / 86_400, (s % 86_400) / 3600) + } +} + +// ─── Form de nueva sesión ─────────────────────────────────────────── + +/// Form grande de creación de sesión, ocupa el canvas mientras `session.pending`. +fn new_session_form(model: &Model, session: &Session, theme: &Theme) -> View { + use llimphi_ui::llimphi_text::Alignment; + + let titulo = View::new(Style { + size: Size { width: percent(1.0_f32), height: length(34.0_f32) }, + ..Default::default() + }) + .text_aligned( + format!("Nueva sesión · {}", session.name), + 18.0, + theme.fg_text, + Alignment::Start, + ); + let sub = View::new(Style { + size: Size { width: percent(1.0_f32), height: length(20.0_f32) }, + ..Default::default() + }) + .text_aligned( + "Elige dónde corre el shell. Enter confirma · Esc cancela.".to_string(), + 12.0, + theme.fg_muted, + Alignment::Start, + ); + + let mut children: Vec> = vec![titulo, sub]; + children.extend(host_select(model, session, theme)); + children.push(container_toggle(session.use_container, theme)); + if session.use_container { + children.extend(container_picker(model, session, theme)); + } + + let cancelar = View::new(Style { + size: Size { width: length(120.0_f32), height: length(34.0_f32) }, + align_items: Some(AlignItems::Center), + justify_content: Some(JustifyContent::Center), + ..Default::default() + }) + .fill(theme.bg_button) + .hover_fill(theme.bg_button_hover) + .radius(5.0) + .text_aligned("Cancelar".to_string(), 12.0, theme.fg_text, Alignment::Center) + .on_click(Msg::CancelNewSession); + let crear = View::new(Style { + size: Size { width: length(120.0_f32), height: length(34.0_f32) }, + align_items: Some(AlignItems::Center), + justify_content: Some(JustifyContent::Center), + ..Default::default() + }) + .fill(theme.accent) + .hover_fill(theme.accent) + .radius(5.0) + .text_aligned("Crear".to_string(), 12.0, theme.bg_app, Alignment::Center) + .on_click(Msg::ConfirmNewSession); + let botones = View::new(Style { + flex_direction: FlexDirection::Row, + size: Size { width: percent(1.0_f32), height: length(40.0_f32) }, + gap: Size { width: length(10.0_f32), height: length(0.0_f32) }, + align_items: Some(AlignItems::Center), + margin: Rect { + left: length(0.0_f32), + right: length(0.0_f32), + top: length(16.0_f32), + bottom: length(0.0_f32), + }, + ..Default::default() + }) + .children(vec![cancelar, crear]); + children.push(botones); + + let form = View::new(Style { + flex_direction: FlexDirection::Column, + size: Size { width: Dimension::auto(), height: Dimension::auto() }, + padding: Rect { + left: length(24.0_f32), + right: length(24.0_f32), + top: length(24.0_f32), + bottom: length(24.0_f32), + }, + gap: Size { width: length(0.0_f32), height: length(6.0_f32) }, + ..Default::default() + }) + .fill(theme.bg_panel) + .radius(8.0) + .children(children); + + View::new(Style { + flex_direction: FlexDirection::Column, + size: Size { width: percent(1.0_f32), height: percent(1.0_f32) }, + align_items: Some(AlignItems::Center), + justify_content: Some(JustifyContent::Center), + padding: Rect { + left: length(24.0_f32), + right: length(24.0_f32), + top: length(24.0_f32), + bottom: length(24.0_f32), + }, + ..Default::default() + }) + .fill(theme.bg_app) + .children(vec![View::new(Style { + size: Size { width: length(520.0_f32), height: Dimension::auto() }, + ..Default::default() + }) + .children(vec![form])]) +} diff --git a/02_ruway/shuma/shuma-shell-llimphi/src/view/tools.rs b/02_ruway/shuma/shuma-shell-llimphi/src/view/tools.rs new file mode 100644 index 0000000..8e503de --- /dev/null +++ b/02_ruway/shuma/shuma-shell-llimphi/src/view/tools.rs @@ -0,0 +1,644 @@ +//! Dientes/cuerpo del sidebar de herramientas (derecho, sobre el widget +//! unificado `rag-sidebar`): historial, monitor, explorador, matilda, agente e +//! iconos vectoriales. + +use std::sync::Arc; + +use super::super::*; +use super::monitors::monitor_stack; +use super::widgets::*; +use llimphi_ui::llimphi_layout::taffy::prelude::{length, percent, Dimension, Style}; +use llimphi_ui::llimphi_layout::taffy::{AlignItems, FlexDirection, JustifyContent, Rect, Size}; +use llimphi_ui::llimphi_raster::peniko::Color; +use llimphi_ui::View; +use llimphi_theme::Theme; +use llimphi_widget_empty::{empty_view, EmptyPalette}; +use llimphi_widget_skeleton::{skeleton_view, SkeletonPalette}; +use llimphi_widget_rag_sidebar::RagTooth; +use llimphi_icons::Icon; + +// ─── Dientes de herramienta (rail derecho del widget unificado) ───── + +/// Los **dientes** del sidebar de herramientas: uno por [`Tool`] (id = índice en +/// `Tool::ALL`). El dock es open/collapse nativo — el `Activate` se intercepta a +/// `SelectTool`, que ya alterna abrir/colapsar el diente. +pub(super) fn tool_teeth(_model: &Model, _theme: &Theme) -> Vec> { + Tool::ALL + .iter() + .enumerate() + .map(|(i, &t)| { + let icon: Arc View + Send + Sync> = + Arc::new(move |size, color| tool_icon(t, size, color)); + RagTooth::new(i as u64, t.label().to_string(), icon) + }) + .collect() +} + +/// Icono vectorial de una herramienta del rail derecho (`paint_with` + kurbo). +pub(super) fn tool_icon(tool: Tool, size: f32, color: Color) -> View { + View::new(Style { + size: Size { width: length(size), height: length(size) }, + align_items: Some(AlignItems::Center), + justify_content: Some(JustifyContent::Center), + ..Default::default() + }) + .paint_with(move |scene, _ts, rect| { + use llimphi_ui::llimphi_raster::kurbo::{ + Affine, BezPath, Circle, Point, RoundedRect, Stroke, + }; + use llimphi_ui::llimphi_raster::peniko::Fill; + if rect.w <= 0.0 || rect.h <= 0.0 { + return; + } + let cx = (rect.x + rect.w * 0.5) as f64; + let cy = (rect.y + rect.h * 0.5) as f64; + let r = (rect.w.min(rect.h) as f64 * 0.34).max(2.0); + let stroke = Stroke::new((r * 0.22).max(1.2)); + match tool { + // Historial: reloj. + Tool::History => { + scene.stroke( + &stroke, + Affine::IDENTITY, + color, + None, + &Circle::new((cx, cy), r), + ); + let mut h = BezPath::new(); + h.move_to(Point::new(cx, cy)); + h.line_to(Point::new(cx, cy - r * 0.55)); + h.move_to(Point::new(cx, cy)); + h.line_to(Point::new(cx + r * 0.45, cy)); + scene.stroke(&stroke, Affine::IDENTITY, color, None, &h); + } + // Monitor: tres barras verticales. + Tool::Monitor => { + let heights = [0.55_f64, 0.95, 0.7]; + let bw = r * 0.45; + let gap = r * 0.32; + let total = 3.0 * bw + 2.0 * gap; + let x0 = cx - total / 2.0; + for (i, h) in heights.iter().enumerate() { + let x = x0 + i as f64 * (bw + gap); + let top = (cy + r) - 2.0 * r * h; + scene.fill( + Fill::NonZero, + Affine::IDENTITY, + color, + None, + &RoundedRect::new(x, top, x + bw, cy + r, 1.0), + ); + } + } + // Explorer: carpeta. + Tool::Explorer => { + let body = RoundedRect::new(cx - r, cy - r * 0.5, cx + r, cy + r * 0.75, 2.0); + scene.stroke(&stroke, Affine::IDENTITY, color, None, &body); + let mut tab = BezPath::new(); + tab.move_to(Point::new(cx - r, cy - r * 0.5)); + tab.line_to(Point::new(cx - r * 0.4, cy - r * 0.5)); + tab.line_to(Point::new(cx - r * 0.2, cy - r * 0.85)); + tab.line_to(Point::new(cx - r, cy - r * 0.85)); + tab.close_path(); + scene.fill(Fill::NonZero, Affine::IDENTITY, color, None, &tab); + } + // Matilda: tres racks apilados. + Tool::Matilda => { + for i in 0..3 { + let y = cy - r + i as f64 * (r * 0.78); + scene.stroke( + &stroke, + Affine::IDENTITY, + color, + None, + &RoundedRect::new(cx - r, y, cx + r, y + r * 0.5, 1.5), + ); + } + } + // Agente: globo de diálogo con colita. + Tool::Agente => { + let body = RoundedRect::new(cx - r, cy - r * 0.85, cx + r, cy + r * 0.35, r * 0.4); + scene.stroke(&stroke, Affine::IDENTITY, color, None, &body); + let mut tail = BezPath::new(); + tail.move_to(Point::new(cx - r * 0.35, cy + r * 0.35)); + tail.line_to(Point::new(cx - r * 0.55, cy + r * 0.85)); + tail.line_to(Point::new(cx - r * 0.02, cy + r * 0.35)); + tail.close_path(); + scene.fill(Fill::NonZero, Affine::IDENTITY, color, None, &tail); + } + } + }) +} + +// ─── Cuerpo de herramienta ────────────────────────────────────────── + +/// El **cuerpo** del panel de la herramienta activa (el cabezal —con la etiqueta +/// de la herramienta— lo pone el widget unificado), a alto 100%. El buscador del +/// sidebar filtra las herramientas que son lista (History/Explorer); las demás +/// (Monitor/Matilda/Agente) lo ignoran. +pub(super) fn tool_body(model: &Model, tool: Tool, theme: &Theme) -> View { + let search = model.sidebar_right.search.trim(); + match tool { + Tool::History => history_column(model, theme, search), + Tool::Monitor => monitor_stack(model, theme), + Tool::Explorer => explorer_panel(model, theme, search), + Tool::Matilda => matilda_panel(model, theme), + Tool::Agente => agente_panel(model, theme), + } +} + +/// El panel de chat multi-agente: delega al `view` del módulo, lifteando sus +/// mensajes a `Msg::Agente`. +pub(super) fn agente_panel(model: &Model, theme: &Theme) -> View { + shuma_module_agente::view(&model.agente, theme, Msg::Agente) +} + +// ─── Historial ────────────────────────────────────────────────────── + +/// La columna de historial del rail derecho. `search` (buscador del sidebar) +/// filtra los comandos por substring (case-insensitive; vacío = todo pasa). +pub(super) fn history_column(model: &Model, theme: &Theme, search: &str) -> View { + let filtro = search.to_lowercase(); + let mut comandos: Vec = Vec::new(); + if let Some(s) = model.active() { + if let ModuleState::Shell(sh) = &s.shell().state { + comandos = sh + .output + .iter() + .filter(|l| l.kind == shuma_module_shell::OutputKind::Prompt) + .map(|l| l.text.trim_start_matches("$ ").to_string()) + .filter(|c| filtro.is_empty() || c.to_lowercase().contains(&filtro)) + .collect(); + } + } + comandos.reverse(); + let mut grupos: Vec<(String, usize)> = Vec::new(); + for c in comandos { + if let Some(g) = grupos.iter_mut().find(|(t, _)| *t == c) { + g.1 += 1; + } else { + grupos.push((c, 1)); + } + } + grupos.truncate(60); + + // El cabezal ("Historial") lo pone el widget unificado; aquí va sólo el + // contenido (para no duplicar el título). + let mut children: Vec> = Vec::new(); + if grupos.is_empty() { + // Empty-state con icono en vez de una línea tenue: el panel de historial + // vacío comunica de qué se trata y se siente intencional. + let (titulo, sub): (&str, &str) = if filtro.is_empty() { + ("Sin comandos aún", "Lo que ejecutes aparece aquí.") + } else { + ("Sin coincidencias", "Ningún comando contiene el filtro.") + }; + children.push( + View::new(Style { + size: Size { width: percent(1.0_f32), height: percent(1.0_f32) }, + flex_grow: 1.0, + ..Default::default() + }) + .children(vec![empty_view::( + Icon::Code, + titulo, + Some(sub), + &EmptyPalette::from_theme(theme), + )]), + ); + } else { + for (cmd, count) in grupos { + children.push(history_row(&cmd, count, theme)); + } + } + + View::new(Style { + flex_direction: FlexDirection::Column, + size: Size { width: percent(1.0_f32), height: percent(1.0_f32) }, + min_size: Size { width: length(0.0_f32), height: length(0.0_f32) }, + ..Default::default() + }) + .fill(theme.bg_panel_alt) + .children(children) +} + +/// Una fila del historial: `[ comando… ×N ▶ ]`. +pub(super) fn history_row(cmd: &str, count: usize, theme: &Theme) -> View { + use llimphi_ui::llimphi_text::Alignment; + + let mut cuerpo_hijos: Vec> = vec![View::new(Style { + size: Size { width: length(0.0_f32), height: percent(1.0_f32) }, + flex_grow: 1.0, + align_items: Some(AlignItems::Center), + ..Default::default() + }) + .text_aligned(cmd.to_string(), 12.0, theme.fg_text, Alignment::Start)]; + if count > 1 { + cuerpo_hijos.push( + View::new(Style { + size: Size { width: Dimension::auto(), height: percent(1.0_f32) }, + align_items: Some(AlignItems::Center), + flex_shrink: 0.0, + margin: Rect { + left: length(6.0_f32), + right: length(0.0_f32), + top: length(0.0_f32), + bottom: length(0.0_f32), + }, + ..Default::default() + }) + .text_aligned(format!("×{count}"), 10.0, theme.fg_muted, Alignment::End), + ); + } + let cuerpo = View::new(Style { + flex_direction: FlexDirection::Row, + size: Size { width: length(0.0_f32), height: percent(1.0_f32) }, + flex_grow: 1.0, + align_items: Some(AlignItems::Center), + ..Default::default() + }) + .children(cuerpo_hijos) + .on_click(Msg::RunFromHistory(cmd.to_string())); + + let run = View::new(Style { + size: Size { width: length(22.0_f32), height: percent(1.0_f32) }, + align_items: Some(AlignItems::Center), + justify_content: Some(JustifyContent::Center), + flex_shrink: 0.0, + ..Default::default() + }) + .hover_fill(theme.bg_button_hover) + .text_aligned("▶".to_string(), 10.0, theme.accent, Alignment::Center) + .on_click(Msg::RunFromHistoryNow(cmd.to_string())); + + View::new(Style { + flex_direction: FlexDirection::Row, + size: Size { width: percent(1.0_f32), height: length(24.0_f32) }, + padding: Rect { + left: length(12.0_f32), + right: length(4.0_f32), + top: length(0.0_f32), + bottom: length(0.0_f32), + }, + align_items: Some(AlignItems::Center), + flex_shrink: 0.0, + ..Default::default() + }) + .hover_fill(theme.bg_row_hover) + .children(vec![cuerpo, run]) +} + +// ─── Explorer ─────────────────────────────────────────────────────── + +/// Panel Explorer: lista los archivos del cwd de la sesión. Para sesiones +/// remotas (Remote / RemoteContainer) el listado viene del cache +/// `model.explorer` (lo trae `reconcile_explorer` off-thread por SSH); +/// para locales lee el filesystem directamente con `read_dir`. +pub(super) fn explorer_panel(model: &Model, theme: &Theme, search: &str) -> View { + // Una búsqueda de archivos activa (`:buscar-archivos`) de la sesión actual + // reemplaza el listado del cwd por sus resultados rankeados. + if let Some(fs) = &model.file_search { + if fs.session == model.active_session { + return explorer_search_panel(fs, theme); + } + } + let filtro = search.to_lowercase(); + let remoto = model.active().and_then(|s| match &s.shell().state { + ModuleState::Shell(sh) + if matches!(sh.source, Source::Remote { .. } | Source::RemoteContainer { .. }) => + { + Some(sh.cwd.display().to_string()) + } + _ => None, + }); + match remoto { + Some(cwd) => explorer_panel_remote(model, &cwd, theme, &filtro), + None => explorer_panel_local(model, theme, &filtro), + } +} + +/// Explorer de una sesión local: `read_dir` directo del cwd. `filtro` (buscador +/// del sidebar, ya en minúsculas) descarta las entradas que no lo contienen. +fn explorer_panel_local(model: &Model, theme: &Theme, filtro: &str) -> View { + let cwd = model + .active() + .and_then(|s| match &s.shell().state { + ModuleState::Shell(sh) => Some(sh.cwd.display().to_string()), + _ => None, + }) + .unwrap_or_else(|| ".".to_string()); + + let mut filas: Vec> = vec![explorer_note(&format!("· {cwd}"), theme)]; + match std::fs::read_dir(&cwd) { + Ok(rd) => { + let mut entradas: Vec<(bool, String)> = rd + .flatten() + .map(|e| { + let dir = e.file_type().map(|t| t.is_dir()).unwrap_or(false); + (dir, e.file_name().to_string_lossy().to_string()) + }) + .filter(|(_, name)| filtro.is_empty() || name.to_lowercase().contains(filtro)) + .collect(); + entradas.sort_by(|a, b| b.0.cmp(&a.0).then(a.1.cmp(&b.1))); + entradas.truncate(200); + for (dir, name) in entradas { + filas.push(explorer_row(dir, &name, true, theme)); + } + } + Err(_) => filas.push(explorer_note("(cwd inaccesible)", theme)), + } + explorer_column(filas) +} + +/// Explorer de una sesión remota: renderiza el cache `model.explorer`. `filtro` +/// (buscador del sidebar) descarta las entradas que no lo contienen. +fn explorer_panel_remote(model: &Model, cwd: &str, theme: &Theme, filtro: &str) -> View { + let mut filas: Vec> = vec![explorer_remote_header(cwd, theme)]; + // Sin conexión no listamos nada (reconcile_explorer tampoco spawnea). + let conectada = model.active().map(|s| s.conn == ConnState::Connected).unwrap_or(false); + if !conectada { + filas.push(explorer_note("(sesión no conectada)", theme)); + return explorer_column(filas); + } + // El cache vale sólo si su clave coincide con la sesión + cwd de ahora. + let vigente = model + .explorer + .key + .as_ref() + .is_some_and(|(s, p)| *s == model.active_session && p == cwd); + if vigente { + match &model.explorer.state { + ExplorerState::Loaded(entries) if entries.is_empty() => { + filas.push(explorer_note("(vacío)", theme)); + } + ExplorerState::Loaded(entries) => { + for e in entries { + if !filtro.is_empty() && !e.name.to_lowercase().contains(filtro) { + continue; + } + filas.push(explorer_row(e.is_dir, &e.name, false, theme)); + } + } + ExplorerState::Error(err) => filas.push(explorer_note(&format!("✘ {err}"), theme)), + _ => filas.push(explorer_skeleton(theme)), + } + } else { + filas.push(explorer_skeleton(theme)); + } + explorer_column(filas) +} + +/// Placeholders con shimmer mientras el Explorer remoto trae el listado por +/// SSH: el usuario ve la forma de la lista que viene, no un hueco con texto. +fn explorer_skeleton(theme: &Theme) -> View { + let pal = SkeletonPalette::from_theme(theme); + let rows: Vec> = (0..8) + .map(|_| { + View::new(Style { + size: Size { width: percent(1.0_f32), height: length(24.0_f32) }, + padding: Rect { + left: length(12.0_f32), + right: length(12.0_f32), + top: length(6.0_f32), + bottom: length(6.0_f32), + }, + flex_shrink: 0.0, + ..Default::default() + }) + .radius(4.0) + .clip(true) + .children(vec![skeleton_view(&pal)]) + }) + .collect(); + View::new(Style { + flex_direction: FlexDirection::Column, + size: Size { width: percent(1.0_f32), height: Dimension::auto() }, + ..Default::default() + }) + .children(rows) +} + +/// Header del Explorer remoto: título + botón ↻ para re-listar. +fn explorer_remote_header(cwd: &str, theme: &Theme) -> View { + use llimphi_ui::llimphi_text::Alignment; + let titulo = View::new(Style { + size: Size { width: length(0.0_f32), height: percent(1.0_f32) }, + flex_grow: 1.0, + align_items: Some(AlignItems::Center), + padding: Rect { + left: length(12.0_f32), + right: length(4.0_f32), + top: length(0.0_f32), + bottom: length(0.0_f32), + }, + ..Default::default() + }) + .text_aligned(format!("Explorer · {cwd}"), 11.0, theme.fg_muted, Alignment::Start); + let refrescar = View::new(Style { + size: Size { width: length(24.0_f32), height: percent(1.0_f32) }, + align_items: Some(AlignItems::Center), + justify_content: Some(JustifyContent::Center), + flex_shrink: 0.0, + ..Default::default() + }) + .hover_fill(theme.bg_button_hover) + .text_aligned("↻".to_string(), 12.0, theme.accent, Alignment::Center) + .on_click(Msg::RefreshExplorer); + View::new(Style { + flex_direction: FlexDirection::Row, + size: Size { width: percent(1.0_f32), height: length(28.0_f32) }, + align_items: Some(AlignItems::Center), + flex_shrink: 0.0, + ..Default::default() + }) + .fill(theme.bg_panel) + .children(vec![titulo, refrescar]) +} + +/// Una fila del Explorer: click en un dir → `cd`. En un archivo, si +/// `open_files` (sesión local), lo abre con el visor por contenido +/// (`Msg::OpenFile`); si no (remoto, sin acceso local al archivo), inserta su +/// nombre en el input. Compartida por el panel local y el remoto. +fn explorer_row(dir: bool, name: &str, open_files: bool, theme: &Theme) -> View { + use llimphi_ui::llimphi_text::Alignment; + let etiqueta = if dir { format!("{name}/") } else { name.to_string() }; + let msg = if dir { + Msg::RunFromHistory(format!("cd {name}")) + } else if open_files { + Msg::OpenFile(name.to_string()) + } else { + Msg::RunFromHistory(name.to_string()) + }; + View::new(Style { + size: Size { width: percent(1.0_f32), height: length(24.0_f32) }, + padding: Rect { + left: length(12.0_f32), + right: length(8.0_f32), + top: length(0.0_f32), + bottom: length(0.0_f32), + }, + align_items: Some(AlignItems::Center), + ..Default::default() + }) + .hover_fill(theme.bg_row_hover) + .on_click(msg) + .text_aligned( + etiqueta, + 12.0, + if dir { theme.accent } else { theme.fg_text }, + Alignment::Start, + ) +} + +/// Panel del Explorer en modo **búsqueda de archivos** (`:buscar-archivos`): +/// encabezado con la consulta + ✕ para volver al cwd, y las rutas rankeadas +/// como filas clickeables (insertan la ruta en el input). +fn explorer_search_panel(fs: &crate::types::FileSearch, theme: &Theme) -> View { + let mut filas: Vec> = vec![explorer_search_header(&fs.query, theme)]; + if fs.hits.is_empty() { + filas.push(explorer_note("(sin coincidencias por significado)", theme)); + } else { + for (path, score) in &fs.hits { + filas.push(explorer_search_row(path, *score, theme)); + } + } + explorer_column(filas) +} + +/// Encabezado del modo búsqueda: «🔎 query» + botón ✕ que limpia la búsqueda. +fn explorer_search_header(query: &str, theme: &Theme) -> View { + use llimphi_ui::llimphi_text::Alignment; + let titulo = View::new(Style { + size: Size { width: length(0.0_f32), height: percent(1.0_f32) }, + flex_grow: 1.0, + align_items: Some(AlignItems::Center), + padding: Rect { + left: length(12.0_f32), + right: length(4.0_f32), + top: length(0.0_f32), + bottom: length(0.0_f32), + }, + ..Default::default() + }) + .text_aligned(format!("🔎 {query}"), 11.0, theme.fg_muted, Alignment::Start); + let limpiar = View::new(Style { + size: Size { width: length(24.0_f32), height: percent(1.0_f32) }, + align_items: Some(AlignItems::Center), + justify_content: Some(JustifyContent::Center), + flex_shrink: 0.0, + ..Default::default() + }) + .hover_fill(theme.bg_button_hover) + .text_aligned("✕".to_string(), 12.0, theme.accent, Alignment::Center) + .on_click(Msg::ClearFileSearch); + View::new(Style { + flex_direction: FlexDirection::Row, + size: Size { width: percent(1.0_f32), height: length(28.0_f32) }, + align_items: Some(AlignItems::Center), + flex_shrink: 0.0, + ..Default::default() + }) + .fill(theme.bg_panel) + .children(vec![titulo, limpiar]) +} + +/// Una fila de resultado de búsqueda: `NN% ruta`, click inserta la ruta en el +/// input (como una fila de archivo del Explorer). +fn explorer_search_row(path: &str, score: f32, theme: &Theme) -> View { + use llimphi_ui::llimphi_text::Alignment; + let pct = View::new(Style { + size: Size { width: length(34.0_f32), height: percent(1.0_f32) }, + flex_shrink: 0.0, + align_items: Some(AlignItems::Center), + justify_content: Some(JustifyContent::Center), + ..Default::default() + }) + .text_aligned(format!("{}%", (score * 100.0).round() as i32), 10.0, theme.fg_muted, Alignment::Center); + let nombre = View::new(Style { + size: Size { width: length(0.0_f32), height: percent(1.0_f32) }, + flex_grow: 1.0, + align_items: Some(AlignItems::Center), + ..Default::default() + }) + .text_aligned(path.to_string(), 12.0, theme.fg_text, Alignment::Start); + View::new(Style { + flex_direction: FlexDirection::Row, + size: Size { width: percent(1.0_f32), height: length(24.0_f32) }, + padding: Rect { + left: length(8.0_f32), + right: length(8.0_f32), + top: length(0.0_f32), + bottom: length(0.0_f32), + }, + align_items: Some(AlignItems::Center), + ..Default::default() + }) + .hover_fill(theme.bg_row_hover) + .on_click(Msg::OpenFile(path.to_string())) + .children(vec![pct, nombre]) +} + +/// Una línea de nota tenue del Explorer (vacío / cargando / error). +fn explorer_note(text: &str, theme: &Theme) -> View { + use llimphi_ui::llimphi_text::Alignment; + View::new(Style { + size: Size { width: percent(1.0_f32), height: length(24.0_f32) }, + padding: Rect { + left: length(12.0_f32), + right: length(8.0_f32), + top: length(0.0_f32), + bottom: length(0.0_f32), + }, + align_items: Some(AlignItems::Center), + ..Default::default() + }) + .text_aligned(text.to_string(), 11.0, theme.fg_muted, Alignment::Start) +} + +/// El contenedor en columna del panel Explorer. +fn explorer_column(filas: Vec>) -> View { + View::new(Style { + flex_direction: FlexDirection::Column, + size: Size { width: percent(1.0_f32), height: percent(1.0_f32) }, + ..Default::default() + }) + .children(filas) +} + +// ─── Matilda ──────────────────────────────────────────────────────── + +/// Panel Matilda: hosts + vhosts del inventario de la sesión activa. +pub(super) fn matilda_panel(model: &Model, theme: &Theme) -> View { + let Some(session) = model.active() else { + return explorer_note("(sin inventario)", theme); + }; + let st = match &session.matilda.state { + ModuleState::Matilda(st) => st.as_ref(), + _ => return explorer_note("(sin inventario)", theme), + }; + let slot = Slot::Session(model.active_session, Which::Matilda); + + let acciones = shuma_module_matilda::contributions(st) + .shortcuts + .into_iter() + .map(|spec| { + action_button( + &spec.label, + Msg::ShortcutClicked(slot.clone(), spec.action), + theme, + ) + }) + .collect::>(); + let barra = chip_row(acciones); + + let hosts_v = hosts_view(&st.desired, theme); + let vhosts_v = vhosts_view(&st.desired, theme); + + View::new(Style { + flex_direction: FlexDirection::Column, + size: Size { width: percent(1.0_f32), height: percent(1.0_f32) }, + gap: Size { width: length(0.0_f32), height: length(8.0_f32) }, + ..Default::default() + }) + .children(vec![barra, hosts_v, vhosts_v]) +} diff --git a/02_ruway/shuma/shuma-shell-llimphi/src/view/widgets.rs b/02_ruway/shuma/shuma-shell-llimphi/src/view/widgets.rs new file mode 100644 index 0000000..d9fa689 --- /dev/null +++ b/02_ruway/shuma/shuma-shell-llimphi/src/view/widgets.rs @@ -0,0 +1,315 @@ +//! Primitivos de UI compartidos por todos los sub-módulos de view. +//! +//! Aquí viven los constructores de View que no pertenecen a un dominio +//! concreto (sesión, herramienta, modal…): etiquetas, botones, filas +//! genéricas, inventario. El marco/cabezal de panel lo pone ahora el widget +//! unificado `rag-sidebar` (se borró el chrome paralelo `panel_frame` / +//! `panel_title` / `tool_header`). + +use super::super::*; +use llimphi_ui::llimphi_layout::taffy::prelude::{length, percent, Dimension, Style}; +use llimphi_ui::llimphi_layout::taffy::{FlexDirection, Rect, Size}; +use llimphi_ui::View; +use llimphi_theme::Theme; + +// ─── Etiquetas de sección ────────────────────────────────────────── + +/// Etiqueta de sección (tenue, chica). +pub(super) fn panel_label(t: &str, theme: &Theme) -> View { + use llimphi_ui::llimphi_text::Alignment; + View::new(Style { + size: Size { width: percent(1.0_f32), height: Dimension::auto() }, + margin: Rect { + left: length(0.0_f32), + right: length(0.0_f32), + top: length(8.0_f32), + bottom: length(2.0_f32), + }, + flex_shrink: 0.0, + ..Default::default() + }) + .text_aligned(t.to_string(), 10.0, theme.fg_muted, Alignment::Start) +} + +/// Nota/párrafo tenue dentro de un panel. +pub(super) fn panel_note(t: &str, theme: &Theme) -> View { + use llimphi_ui::llimphi_layout::taffy::AlignItems; + use llimphi_ui::llimphi_text::Alignment; + View::new(Style { + size: Size { width: percent(1.0_f32), height: Dimension::auto() }, + align_items: Some(AlignItems::Center), + flex_shrink: 0.0, + ..Default::default() + }) + .text_aligned(t.to_string(), 11.0, theme.fg_muted, Alignment::Start) +} + +// ─── Botones ──────────────────────────────────────────────────────── + +/// Un botón de acción (para el panel de matilda / shortcuts). +pub(super) fn action_button(label: &str, msg: Msg, theme: &Theme) -> View { + use llimphi_ui::llimphi_layout::taffy::{AlignItems, JustifyContent}; + use llimphi_ui::llimphi_text::Alignment; + View::new(Style { + size: Size { width: Dimension::auto(), height: length(26.0_f32) }, + padding: Rect { + left: length(10.0_f32), + right: length(10.0_f32), + top: length(0.0_f32), + bottom: length(0.0_f32), + }, + margin: Rect { + left: length(0.0_f32), + right: length(6.0_f32), + top: length(0.0_f32), + bottom: length(6.0_f32), + }, + align_items: Some(AlignItems::Center), + justify_content: Some(JustifyContent::Center), + flex_shrink: 0.0, + ..Default::default() + }) + .fill(theme.bg_button) + .hover_fill(theme.bg_button_hover) + .radius(5.0) + .text_aligned(label.to_string(), 11.5, theme.fg_text, Alignment::Center) + .on_click(msg) +} + +/// Botón de acción compacto (sin margen grande). +pub(super) fn action_button_small(label: &str, msg: Msg, theme: &Theme) -> View { + use llimphi_ui::llimphi_layout::taffy::{AlignItems, JustifyContent}; + use llimphi_ui::llimphi_text::Alignment; + View::new(Style { + size: Size { width: Dimension::auto(), height: length(28.0_f32) }, + flex_shrink: 0.0, + padding: Rect { + left: length(10.0_f32), + right: length(10.0_f32), + top: length(0.0_f32), + bottom: length(0.0_f32), + }, + align_items: Some(AlignItems::Center), + justify_content: Some(JustifyContent::Center), + ..Default::default() + }) + .fill(theme.bg_button) + .hover_fill(theme.bg_button_hover) + .radius(4.0) + .text_aligned(label.to_string(), 11.0, theme.fg_text, Alignment::Center) + .on_click(msg) +} + +// ─── Fila de chips y listas inline ───────────────────────────────── + +/// Fila de chips, con wrap si no caben en el ancho del panel. +pub(super) fn chip_row(chips: Vec>) -> View { + use llimphi_ui::llimphi_layout::taffy::FlexWrap; + View::new(Style { + flex_direction: FlexDirection::Row, + flex_wrap: FlexWrap::Wrap, + size: Size { width: percent(1.0_f32), height: Dimension::auto() }, + ..Default::default() + }) + .children(chips) +} + +/// Fila clickeable de un select expandido inline (form de sesión nueva). +pub(super) fn pick_row(label: String, msg: Msg, theme: &Theme) -> View { + use llimphi_ui::llimphi_layout::taffy::AlignItems; + use llimphi_ui::llimphi_text::Alignment; + View::new(Style { + size: Size { width: percent(1.0_f32), height: length(26.0_f32) }, + padding: Rect { + left: length(10.0_f32), + right: length(10.0_f32), + top: length(0.0_f32), + bottom: length(0.0_f32), + }, + align_items: Some(AlignItems::Center), + ..Default::default() + }) + .fill(theme.bg_panel_alt) + .hover_fill(theme.bg_row_hover) + .radius(3.0) + .text_aligned(label, 11.0, theme.fg_text, Alignment::Start) + .on_click(msg) +} + +/// Columna de `pick_row`s — el cuerpo expandido de un select inline. +pub(super) fn inline_list(rows: Vec>) -> View { + View::new(Style { + flex_direction: FlexDirection::Column, + size: Size { width: percent(1.0_f32), height: Dimension::auto() }, + gap: Size { width: length(0.0_f32), height: length(3.0_f32) }, + ..Default::default() + }) + .children(rows) +} + +/// Placeholder de área vacía o incompatible. +pub(crate) fn placeholder(theme: &Theme, text: &str) -> View { + use llimphi_ui::llimphi_text::Alignment; + View::new(Style { + size: Size { width: percent(1.0_f32), height: percent(1.0_f32) }, + padding: Rect { + left: length(24.0_f32), + right: length(24.0_f32), + top: length(20.0_f32), + bottom: length(20.0_f32), + }, + ..Default::default() + }) + .fill(theme.bg_app) + .text_aligned(text.to_string(), 13.0, theme.fg_muted, Alignment::Start) +} + +// ─── Inventario (Matilda) ──────────────────────────────────────────── + +/// Lista de hosts del inventario de la sesión: nombre · dirección · tags. +pub(super) fn hosts_view(inv: &matilda_core::Inventory, theme: &Theme) -> View { + use llimphi_ui::llimphi_layout::taffy::AlignItems; + use llimphi_ui::llimphi_text::Alignment; + let mut filas: Vec> = inv + .hosts() + .map(|h| { + let tags = if h.tags.is_empty() { + String::new() + } else { + format!(" [{}]", h.tags.join(", ")) + }; + inventory_row( + format!("{}", h.name), + format!("{}{tags}", h.address), + theme, + ) + }) + .collect(); + if filas.is_empty() { + filas.push( + View::new(Style { + size: Size { width: percent(1.0_f32), height: length(28.0_f32) }, + padding: Rect { + left: length(16.0_f32), + right: length(8.0_f32), + top: length(0.0_f32), + bottom: length(0.0_f32), + }, + align_items: Some(AlignItems::Center), + ..Default::default() + }) + .text_aligned( + "sin hosts en el inventario".to_string(), + 12.0, + theme.fg_muted, + Alignment::Start, + ), + ); + } + inventory_panel("Hosts", filas, theme) +} + +/// Lista de vhosts del inventario: dominio · upstream · TLS. +pub(super) fn vhosts_view(inv: &matilda_core::Inventory, theme: &Theme) -> View { + use llimphi_ui::llimphi_layout::taffy::AlignItems; + use llimphi_ui::llimphi_text::Alignment; + use matilda_core::Upstream; + let mut filas: Vec> = inv + .vhosts() + .map(|v| { + let up = match &v.upstream { + Upstream::Address(a) => a.clone(), + Upstream::Container { name, port } => format!("{name}:{port}"), + }; + let tls = if v.tls { " TLS" } else { "" }; + inventory_row(v.domain.clone(), format!("-> {up}{tls}"), theme) + }) + .collect(); + if filas.is_empty() { + filas.push( + View::new(Style { + size: Size { width: percent(1.0_f32), height: length(28.0_f32) }, + padding: Rect { + left: length(16.0_f32), + right: length(8.0_f32), + top: length(0.0_f32), + bottom: length(0.0_f32), + }, + align_items: Some(AlignItems::Center), + ..Default::default() + }) + .text_aligned( + "sin vhosts en el inventario".to_string(), + 12.0, + theme.fg_muted, + Alignment::Start, + ), + ); + } + inventory_panel("Vhosts", filas, theme) +} + +/// Una fila de inventario: título a la izquierda, detalle tenue a la derecha. +pub(super) fn inventory_row(titulo: String, detalle: String, theme: &Theme) -> View { + use llimphi_ui::llimphi_layout::taffy::AlignItems; + use llimphi_ui::llimphi_text::Alignment; + View::new(Style { + flex_direction: FlexDirection::Row, + size: Size { width: percent(1.0_f32), height: length(30.0_f32) }, + padding: Rect { + left: length(16.0_f32), + right: length(16.0_f32), + top: length(0.0_f32), + bottom: length(0.0_f32), + }, + align_items: Some(AlignItems::Center), + gap: Size { width: length(12.0_f32), height: length(0.0_f32) }, + ..Default::default() + }) + .hover_fill(theme.bg_row_hover) + .children(vec![ + View::new(Style { + size: Size { width: Dimension::auto(), height: percent(1.0_f32) }, + flex_grow: 1.0, + align_items: Some(AlignItems::Center), + ..Default::default() + }) + .text_aligned(titulo, 13.0, theme.fg_text, Alignment::Start), + View::new(Style { + size: Size { width: Dimension::auto(), height: percent(1.0_f32) }, + align_items: Some(AlignItems::Center), + ..Default::default() + }) + .text_aligned(detalle, 12.0, theme.fg_muted, Alignment::End), + ]) +} + +/// Marco de un panel de inventario: cabecera + filas en columna. +pub(super) fn inventory_panel(titulo: &str, filas: Vec>, theme: &Theme) -> View { + use llimphi_ui::llimphi_layout::taffy::AlignItems; + use llimphi_ui::llimphi_text::Alignment; + let header = View::new(Style { + size: Size { width: percent(1.0_f32), height: length(30.0_f32) }, + padding: Rect { + left: length(16.0_f32), + right: length(8.0_f32), + top: length(0.0_f32), + bottom: length(0.0_f32), + }, + align_items: Some(AlignItems::Center), + ..Default::default() + }) + .fill(theme.bg_panel) + .text_aligned(titulo.to_string(), 12.0, theme.fg_muted, Alignment::Start); + + let mut children = vec![header]; + children.extend(filas); + + View::new(Style { + flex_direction: FlexDirection::Column, + size: Size { width: percent(1.0_f32), height: percent(1.0_f32) }, + ..Default::default() + }) + .fill(theme.bg_app) + .children(children) +} diff --git a/02_ruway/shuma/shuma-shell-llimphi/src/workspace.rs b/02_ruway/shuma/shuma-shell-llimphi/src/workspace.rs new file mode 100644 index 0000000..6eb37b4 --- /dev/null +++ b/02_ruway/shuma/shuma-shell-llimphi/src/workspace.rs @@ -0,0 +1,916 @@ +//! `workspace` — el layout tipo **zellij** del canvas de una sesión. +//! +//! Una sesión ya no hospeda *un* shell: hospeda un [`Workspace`] con varias +//! **tabs**, cada una con un árbol **tiling** de paneles (BSP, vía +//! `llimphi-widget-panes`) y una capa de paneles **pseudo-flotantes** que se +//! superponen al tiling. Cada panel —tiled o flotante— es una `Instance` de +//! `shuma-module-shell` viva e independiente (cada una con su PTY, su cwd, su +//! historial). +//! +//! ## Modelo +//! +//! - [`Workspace`] tiene `tabs: Vec` y la tab activa. +//! - [`WsTab`] tiene: +//! - `panes: HashMap` — **todos** los paneles de la tab +//! (tiled + flotantes), por id. Única fuente de verdad del contenido. +//! - `layout: Layout` — el árbol BSP, que sólo referencia ids **tiled**. +//! - `floating: Vec` — geometría (id + rect en px) de los ids que +//! están en la capa flotante (NO viven en `layout`). +//! - `focused: PaneId` — el panel con foco (puede ser tiled o flotante); sus +//! teclas las recibe el chasis. +//! - `show_floating` — si la capa flotante se pinta arriba del tiling. +//! +//! **Invariante:** siempre hay ≥1 tab, cada tab tiene ≥1 panel, y `focused` +//! existe en `panes`. Las operaciones lo mantienen. `Session::shell()` devuelve +//! `panes[focused]` de la tab activa — por eso el resto del chasis (teclado, +//! cwd, input hospedado…) sigue operando sobre "el shell" sin enterarse de que +//! hay tiling. +//! +//! Las ops que **crean** un panel reciben la `Instance` ya construida desde el +//! caller (`update.rs`), porque armar un shell necesita el `Source`/nombre de +//! la sesión — detalle que este módulo no conoce a propósito. + +use std::collections::HashMap; + +use llimphi_widget_panes::{Axis, Layout, PaneId, Side}; + +use crate::types::Instance; + +/// Geometría de un panel flotante, en px relativos al canvas. +#[derive(Debug, Clone)] +pub(crate) struct FloatPane { + pub id: PaneId, + pub x: f32, + pub y: f32, + pub w: f32, + pub h: f32, +} + +/// Lo que una pestaña de fondo tiene para avisarte. Se levanta por **flanco** +/// (el instante en que pasa) y sobrevive hasta que visitás la pestaña — si se +/// recalculara por nivel, o parpadearía para siempre, o se apagaría sola antes +/// de que lo vieras. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub(crate) enum TabAviso { + /// El programa tocó la campana del terminal (`^G`) o pidió una + /// notificación de escritorio. Ver `shuma_module_shell::campana`. + Campana, + /// El asistente cerró su turno y espera respuesta. + Espera, + /// Terminó un comando y salió bien. + Ok, + /// Terminó un comando y falló. + Error, +} + +/// Lo que se observa de una pestaña en un tick. Separar esto de dónde salió +/// (paneles, shells, PTYs) es lo que hace testeable la máquina de avisos. +#[derive(Debug, Clone, Copy, Default)] +pub(crate) struct Senales { + /// Campanadas acumuladas por sus paneles (monótono). + pub campanadas: u64, + /// Hay un comando corriendo en algún panel. + pub corre: bool, + /// El asistente cerró su turno y espera respuesta. + pub espera: bool, + /// Resultado del último comando terminado del panel con foco. + pub ultimo_ok: Option, +} + +/// La máquina de avisos de una pestaña. Todo lo que decide **cuándo** parpadea +/// vive acá, y sólo depende de [`Senales`] + lo que arrastra del tick anterior. +#[derive(Debug, Clone, Default)] +pub(crate) struct Avisador { + /// Aviso pendiente de acuse: la pestaña parpadea hasta que la visitás. + pub aviso: Option, + /// Campanadas ya acusadas. El delta contra el total vivo es «sonó algo + /// desde la última vez que miré». + campanadas_vistas: u64, + /// `true` si en el tick anterior había un comando corriendo — el flanco de + /// bajada es «terminó algo», que es cuando vale la pena avisar. + corria: bool, + /// `true` si en el tick anterior el asistente ya estaba esperando — para no + /// re-levantar el aviso en cada tick mientras la marca sigue en pantalla. + esperaba: bool, +} + +impl Avisador { + /// Un tick. `visitada` = el usuario está mirando esta pestaña: entonces no + /// hay nada que avisar y se acusa todo lo acumulado. + /// + /// Los avisos se levantan por FLANCO y en orden de urgencia: la campana es + /// un pedido explícito del programa, la espera del asistente bloquea + /// trabajo, y el fin de un comando es informativo. + pub fn paso(&mut self, s: Senales, visitada: bool) { + if visitada { + self.aviso = None; + } else if s.campanadas > self.campanadas_vistas { + self.aviso = Some(TabAviso::Campana); + } else if s.espera && !self.esperaba { + self.aviso = Some(TabAviso::Espera); + } else if self.corria && !s.corre { + // Flanco de bajada: acaba de terminar algo. + self.aviso = Some(match s.ultimo_ok { + Some(false) => TabAviso::Error, + _ => TabAviso::Ok, + }); + } + // Las señales se acusan SIEMPRE, aunque el aviso ya estuviera levantado: + // si no, un segundo BEL lo renovaría eternamente y el flanco de «terminó» + // se dispararía de nuevo en cada tick posterior. + self.campanadas_vistas = s.campanadas; + self.corria = s.corre; + self.esperaba = s.espera; + } +} + +/// Una tab del workspace: un árbol tiling + su capa flotante. +pub(crate) struct WsTab { + /// Nombre **manual** de la pestaña. `None` = se rotula sola con el título + /// de contexto de su panel con foco (ver [`WsTab::titulo`]). + pub name: Option, + pub layout: Layout, + pub focused: PaneId, + pub panes: HashMap, + pub floating: Vec, + pub show_floating: bool, + /// Máquina de avisos — qué tiene esta pestaña para decirte de fondo. + pub avisador: Avisador, +} + +impl WsTab { + fn single(id: PaneId, inst: Instance) -> Self { + let mut panes = HashMap::new(); + panes.insert(id, inst); + Self { + name: None, + layout: Layout::single(id), + focused: id, + panes, + floating: Vec::new(), + show_floating: false, + avisador: Avisador::default(), + } + } + + /// El aviso pendiente de la pestaña (atajo hacia el avisador, que es quien + /// lo decide). + pub fn aviso(&self) -> Option { + self.avisador.aviso + } + + /// El directorio del panel con foco — lo hereda la tab que se **duplica** + /// (`Msg::TabDuplicate`): un shell fresco donde estabas parado, que es lo + /// que uno espera de «duplicar», sin arrastrar el scrollback ajeno. + pub fn cwd_enfocado(&self) -> Option { + self.shell_enfocado().map(|s| s.cwd.clone()) + } + + /// El shell del panel con foco, si el panel es un shell. + fn shell_enfocado(&self) -> Option<&shuma_module_shell::State> { + match &self.panes.get(&self.focused)?.state { + crate::types::ModuleState::Shell(s) => Some(s), + _ => None, + } + } + + /// Itera los shells de **todos** los paneles de la tab. + fn shells(&self) -> impl Iterator { + self.panes.values().filter_map(|i| match &i.state { + crate::types::ModuleState::Shell(s) => Some(s), + _ => None, + }) + } + + /// Cómo se rotula la pestaña: el nombre manual si lo tiene, si no el + /// **título de contexto** del panel con foco (título OSC del programa → + /// programa corriendo → cwd). Con varios paneles se sufija `·N`. + pub fn titulo(&self, indice: usize) -> String { + let base = self + .name + .clone() + .or_else(|| self.shell_enfocado().map(|s| s.titulo_contexto())) + .filter(|t| !t.trim().is_empty()) + .unwrap_or_else(|| (indice + 1).to_string()); + let n = self.panes.len(); + if n > 1 { + format!("{base} ·{n}") + } else { + base + } + } + + /// El cava de la pestaña: el caudal del panel **más activo**. Sumar los + /// anillos aplanaría todo contra el techo con 4 paneles; el máximo por bin + /// conserva la forma de la ráfaga, que es lo que se lee de un vistazo. + pub fn barras(&self) -> [f32; shuma_module_shell::pulso::MUESTRAS] { + let mut out = [0.0_f32; shuma_module_shell::pulso::MUESTRAS]; + for s in self.shells() { + for (o, b) in out.iter_mut().zip(s.pulso().barras()) { + *o = o.max(b); + } + } + out + } + + /// Atenuación por quietud — la pestaña dormida se apaga. Manda el panel que + /// menos tiempo lleva callado (si algo se movió recién, la tab está viva). + pub fn atenuacion(&self) -> f32 { + self.shells() + .map(|s| s.pulso().atenuacion()) + .fold(None::, |acc, v| Some(acc.map_or(v, |a| a.max(v)))) + // Una tab sin shells (lienzo, matilda) no duerme: no tiene caudal + // que medir, y apagarla sería mentir sobre su estado. + .unwrap_or(1.0) + } + + /// Lee las señales de aviso de sus paneles y las pasa por el [`Avisador`]. + pub fn refrescar_aviso(&mut self, visitada: bool) { + let senales = Senales { + campanadas: self.shells().map(|s| s.campanadas()).sum(), + corre: self.shells().any(|s| s.is_running()), + // «El asistente te espera» = cerró el turno con una sugerencia + // marcada. NO es `claude_ocupado` (ése es el spinner: está + // trabajando, no esperando — avisar ahí sería avisar de que tarda). + espera: self.shells().any(|s| s.claude_sugerencia.is_some()), + // El resultado sale del panel con foco (el que lo corrió, en el + // caso normal). + ultimo_ok: self.shell_enfocado().and_then(|s| s.ultimo_resultado()), + }; + self.avisador.paso(senales, visitada); + } + + /// `true` si `id` está en la capa flotante. + pub fn is_floating(&self, id: PaneId) -> bool { + self.floating.iter().any(|f| f.id == id) + } + + /// Reasigna el foco a un panel tiled válido (el primero del árbol). + fn refocus_tiled(&mut self) { + self.focused = self.layout.first_leaf(); + } + + /// Estado de actividad agregado de la tab para el aviso visual: claude tiene + /// prioridad, luego movimiento (algún panel corriendo), si no quieto. Mira + /// **todos** los paneles, así una tab inactiva igual avisa que algo pasa. + pub fn activity(&self) -> shuma_module_shell::Activity { + use shuma_module_shell::Activity; + let mut acc = Activity::Idle; + for inst in self.panes.values() { + if let crate::types::ModuleState::Shell(s) = &inst.state { + match s.activity() { + Activity::Claude => return Activity::Claude, + Activity::Busy => acc = Activity::Busy, + Activity::Idle => {} + } + } + } + acc + } +} + +/// El layout completo de una sesión. +pub(crate) struct Workspace { + pub tabs: Vec, + pub active_tab: usize, + /// Contador monótono de ids de panel (único por workspace). + pub next_id: PaneId, +} + +impl Workspace { + /// Workspace de un solo panel (el shell inicial de la sesión). + pub fn single(inst: Instance) -> Self { + Self { + tabs: vec![WsTab::single(0, inst)], + active_tab: 0, + next_id: 1, + } + } + + fn fresh_id(&mut self) -> PaneId { + let id = self.next_id; + self.next_id += 1; + id + } + + // ─── Lectura ──────────────────────────────────────────────────── + + /// `(índice de tab, ULID como string)` de las sesiones persistentes del + /// daemon montadas en este workspace. Un panel local (sin sesión del daemon) + /// no aparece. El gestor de sesiones lo usa para saber cuáles de las + /// sesiones que reporta el daemon YA están abiertas acá. + pub fn sesiones_montadas(&self) -> Vec<(usize, String)> { + let mut out = Vec::new(); + for (i, t) in self.tabs.iter().enumerate() { + for s in t.shells() { + if let Some(u) = s.montada_session() { + out.push((i, u.to_string())); + } + } + } + out + } + + pub fn tab(&self) -> &WsTab { + // `active_tab` se mantiene en rango; clamp defensivo igual. + let i = self.active_tab.min(self.tabs.len().saturating_sub(1)); + &self.tabs[i] + } + + pub fn tab_mut(&mut self) -> &mut WsTab { + let i = self.active_tab.min(self.tabs.len().saturating_sub(1)); + &mut self.tabs[i] + } + + /// El panel con foco de la tab activa (infalible por invariante). + pub fn focused_instance(&self) -> &Instance { + let t = self.tab(); + t.panes + .get(&t.focused) + .or_else(|| t.panes.values().next()) + .expect("workspace: toda tab tiene ≥1 panel") + } + + pub fn focused_instance_mut(&mut self) -> &mut Instance { + let t = self.tab_mut(); + if t.panes.contains_key(&t.focused) { + t.panes.get_mut(&t.focused).unwrap() + } else { + t.panes.values_mut().next().expect("workspace: toda tab tiene ≥1 panel") + } + } + + /// Instancia de un panel concreto de la tab activa. + pub fn pane(&self, id: PaneId) -> Option<&Instance> { + self.tab().panes.get(&id) + } + + pub fn pane_mut(&mut self, id: PaneId) -> Option<&mut Instance> { + self.tab_mut().panes.get_mut(&id) + } + + /// Visita **toda** instancia de panel de **todas** las tabs (para drenar + /// el output streamed: los paneles de fondo también producen salida). + pub fn for_each_pane_mut(&mut self, mut f: impl FnMut(&mut Instance)) { + for t in &mut self.tabs { + for inst in t.panes.values_mut() { + f(inst); + } + } + } + + // ─── Tiling ───────────────────────────────────────────────────── + + /// Parte el panel con foco en dos. El panel nuevo (`inst`) toma el foco. + /// `axis` `Horizontal` = lado a lado; `Vertical` = apilado. + pub fn split(&mut self, axis: Axis, inst: Instance) { + let id = self.fresh_id(); + let t = self.tab_mut(); + // Sólo se parte un panel tiled. Si el foco está en un flotante, + // partimos el primer tiled del árbol (comportamiento simple y + // predecible para el MVP). + let target = if t.is_floating(t.focused) { + t.layout.first_leaf() + } else { + t.focused + }; + if t.layout.split(target, id, axis) { + t.panes.insert(id, inst); + t.focused = id; + } + } + + /// Pone el foco en `id` (si existe en la tab activa). + pub fn focus(&mut self, id: PaneId) { + let t = self.tab_mut(); + if t.panes.contains_key(&id) { + t.focused = id; + // Enfocar un flotante lo trae al frente y enciende la capa. + if let Some(pos) = t.floating.iter().position(|f| f.id == id) { + let f = t.floating.remove(pos); + t.floating.push(f); + t.show_floating = true; + } + } + } + + /// Mueve el foco al siguiente / anterior panel tiled (ciclo). + pub fn cycle_focus(&mut self, forward: bool) { + let t = self.tab_mut(); + let ids = t.layout.leaves(); + if ids.is_empty() { + return; + } + let cur = ids.iter().position(|x| *x == t.focused); + let n = ids.len(); + let next = match cur { + Some(i) if forward => (i + 1) % n, + Some(i) => (i + n - 1) % n, + None => 0, + }; + t.focused = ids[next]; + } + + /// Cierra el panel con foco. No-op si es el último panel de la última tab. + /// Devuelve la `Instance` removida (el caller la deja caer / la usa para + /// matar el PTY si hiciera falta). + pub fn close_focused(&mut self) -> Option { + // ¿Es el último panel del workspace entero? Entonces no se cierra. + let total: usize = self.tabs.iter().map(|t| t.panes.len()).sum(); + if total <= 1 { + return None; + } + let i = self.active_tab.min(self.tabs.len().saturating_sub(1)); + let victim = self.tabs[i].focused; + let was_floating = self.tabs[i].is_floating(victim); + + if was_floating { + let t = &mut self.tabs[i]; + t.floating.retain(|f| f.id != victim); + let inst = t.panes.remove(&victim); + // Nuevo foco: otro flotante arriba, o un tiled. + t.focused = t.floating.last().map(|f| f.id).unwrap_or_else(|| t.layout.first_leaf()); + return inst; + } + + // Tiled: si la tab tiene un solo panel tiled (y nada flotante), cerrar + // ese panel cierra la tab entera (si hay más de una tab). + let only_tiled = self.tabs[i].layout.count(); + if only_tiled <= 1 { + if self.tabs[i].floating.is_empty() { + // Cerrar la tab (hay >1 porque total>1 y esta sólo tiene 1 panel). + if self.tabs.len() > 1 { + let mut tab = self.tabs.remove(i); + self.active_tab = self.active_tab.min(self.tabs.len() - 1); + return tab.panes.drain().map(|(_, v)| v).next(); + } + return None; + } else { + // Hay flotantes: el último tiled no se puede quitar del árbol + // (Layout no soporta árbol vacío); pasamos el foco a un flotante. + let t = &mut self.tabs[i]; + t.focused = t.floating.last().map(|f| f.id).unwrap(); + t.show_floating = true; + return None; + } + } + + // Caso normal: sacar el panel del árbol y recolapsar. + let t = &mut self.tabs[i]; + let layout = std::mem::replace(&mut t.layout, Layout::single(victim)); + let (new_layout, removed) = layout.without(victim); + t.layout = new_layout; + if removed { + let inst = t.panes.remove(&victim); + t.refocus_tiled(); + inst + } else { + None + } + } + + /// Ajusta el ratio del split direccionado por `path`. + pub fn resize(&mut self, path: &[Side], delta: f32) { + self.tab_mut().layout.resize(path, delta); + } + + // ─── Tabs ─────────────────────────────────────────────────────── + + /// Crea una tab nueva con un único panel (`inst`) y la activa. + pub fn new_tab(&mut self, inst: Instance) { + let id = self.fresh_id(); + self.tabs.push(WsTab::single(id, inst)); + self.active_tab = self.tabs.len() - 1; + } + + pub fn switch_tab(&mut self, i: usize) { + if i < self.tabs.len() { + self.active_tab = i; + } + } + + /// Cierra la tab `i`. No-op si es la única. Devuelve sus instancias para + /// que el caller las deje caer. + pub fn close_tab(&mut self, i: usize) -> Vec { + if self.tabs.len() <= 1 || i >= self.tabs.len() { + return Vec::new(); + } + let tab = self.tabs.remove(i); + if self.active_tab >= self.tabs.len() { + self.active_tab = self.tabs.len() - 1; + } else if self.active_tab > i { + self.active_tab -= 1; + } + tab.panes.into_values().collect() + } + + /// Cierra las tabs **a la derecha** de `keep`. Devuelve sus instancias para + /// que el caller las deje caer. Más quirúrgico que [`Self::close_others`]: + /// conserva lo que abriste antes. No-op si `keep` es la última. + pub fn close_right(&mut self, keep: usize) -> Vec { + if keep + 1 >= self.tabs.len() { + return Vec::new(); + } + let mut dropped = Vec::new(); + for tab in self.tabs.drain(keep + 1..) { + dropped.extend(tab.panes.into_values()); + } + if self.active_tab > keep { + self.active_tab = keep; + } + dropped + } + + /// Mueve la tab `i` un lugar hacia la derecha (`derecha`) o la izquierda. + /// Devuelve el índice nuevo (o el mismo si no se pudo mover, en los + /// extremos). La tab **activa sigue siendo la misma tab**, aunque cambie de + /// índice: sin esto, reordenar te cambiaba de pestaña bajo el dedo. + pub fn move_tab(&mut self, i: usize, derecha: bool) -> usize { + if i >= self.tabs.len() { + return i; + } + let j = if derecha { + if i + 1 >= self.tabs.len() { + return i; + } + i + 1 + } else { + if i == 0 { + return i; + } + i - 1 + }; + self.tabs.swap(i, j); + if self.active_tab == i { + self.active_tab = j; + } else if self.active_tab == j { + self.active_tab = i; + } + j + } + + /// Le pone (o le saca) nombre manual a la tab `i`. Un nombre en blanco la + /// devuelve al título automático (programa / cwd). `false` si no existe. + pub fn rename_tab(&mut self, i: usize, nombre: &str) -> bool { + let Some(t) = self.tabs.get_mut(i) else { + return false; + }; + let n = nombre.trim(); + t.name = if n.is_empty() { None } else { Some(n.to_string()) }; + true + } + + /// El rótulo de la tab `i` tal como lo ve el usuario (nombre manual o + /// título automático). Lo usa el encabezado del menú contextual. + pub fn titulo_de(&self, i: usize) -> String { + self.tabs.get(i).map(|t| t.titulo(i)).unwrap_or_default() + } + + /// El nombre manual de la tab `i`, si tiene (vacío = usa el automático). + pub fn tab_name(&self, i: usize) -> String { + self.tabs + .get(i) + .and_then(|t| t.name.clone()) + .unwrap_or_default() + } + + /// Cierra todas las tabs menos la `keep`, que pasa a ser la activa. + /// Devuelve las instancias de las tabs cerradas para que el caller las + /// deje caer. No-op (vector vacío) si `keep` no existe o es la única. + pub fn close_others(&mut self, keep: usize) -> Vec { + if keep >= self.tabs.len() || self.tabs.len() <= 1 { + return Vec::new(); + } + let mut dropped = Vec::new(); + let kept = self.tabs.remove(keep); + for tab in self.tabs.drain(..) { + dropped.extend(tab.panes.into_values()); + } + self.tabs.push(kept); + self.active_tab = 0; + dropped + } + + // ─── Flotantes ────────────────────────────────────────────────── + + /// Agrega un panel flotante (`inst`), lo enfoca y enciende la capa. La + /// geometría arranca en cascada según cuántos flotantes haya. + pub fn new_float(&mut self, inst: Instance) { + let id = self.fresh_id(); + let t = self.tab_mut(); + let n = t.floating.len() as f32; + let geo = FloatPane { + id, + x: 120.0 + n * 28.0, + y: 80.0 + n * 28.0, + w: 620.0, + h: 380.0, + }; + t.panes.insert(id, inst); + t.floating.push(geo); + t.focused = id; + t.show_floating = true; + } + + /// Enciende/apaga la capa flotante. Al apagarla, si el foco estaba en un + /// flotante, vuelve a un panel tiled. Al encenderla, enfoca el flotante de + /// arriba (si hay). + pub fn toggle_floating(&mut self) { + let t = self.tab_mut(); + if t.floating.is_empty() { + return; + } + t.show_floating = !t.show_floating; + if t.show_floating { + if let Some(top) = t.floating.last() { + t.focused = top.id; + } + } else if t.is_floating(t.focused) { + t.refocus_tiled(); + } + } + + /// Desplaza un panel flotante por (dx, dy) px. Lo trae al frente. + pub fn move_float(&mut self, id: PaneId, dx: f32, dy: f32) { + let t = self.tab_mut(); + if let Some(pos) = t.floating.iter().position(|f| f.id == id) { + let mut f = t.floating.remove(pos); + f.x = (f.x + dx).max(0.0); + f.y = (f.y + dy).max(0.0); + t.floating.push(f); + t.focused = id; + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + + /// Panel de prueba liviano (un canvas, sin shell/PTY/sled). + fn pane() -> Instance { + Instance::canvas("t".to_string()) + } + + fn ws() -> Workspace { + Workspace::single(pane()) + } + + // ── Máquina de avisos ─────────────────────────────────────────── + + /// Señales de una pestaña en reposo (nada corriendo, nadie llamando). + fn quieta() -> Senales { + Senales::default() + } + + #[test] + fn la_campana_levanta_aviso_y_no_se_renueva_sola() { + let mut a = Avisador::default(); + a.paso(quieta(), false); + assert_eq!(a.aviso, None, "en silencio no avisa nada"); + // El programa toca la campana. + a.paso(Senales { campanadas: 1, ..quieta() }, false); + assert_eq!(a.aviso, Some(TabAviso::Campana)); + // Sigue sonando el MISMO valor: el aviso queda, pero no se re-levanta + // desde cero (lo que importa es que sobreviva hasta la visita). + a.paso(Senales { campanadas: 1, ..quieta() }, false); + assert_eq!(a.aviso, Some(TabAviso::Campana)); + // Visitar la pestaña lo apaga… + a.paso(Senales { campanadas: 1, ..quieta() }, true); + assert_eq!(a.aviso, None); + // …y no vuelve solo con las campanadas viejas. + a.paso(Senales { campanadas: 1, ..quieta() }, false); + assert_eq!(a.aviso, None, "las campanadas viejas ya se acusaron"); + // Una campanada NUEVA sí vuelve a llamar. + a.paso(Senales { campanadas: 2, ..quieta() }, false); + assert_eq!(a.aviso, Some(TabAviso::Campana)); + } + + #[test] + fn terminar_un_comando_avisa_por_flanco_de_bajada() { + let mut a = Avisador::default(); + a.paso(Senales { corre: true, ..quieta() }, false); + assert_eq!(a.aviso, None, "mientras corre no hay nada que avisar"); + // Terminó y falló. + a.paso(Senales { corre: false, ultimo_ok: Some(false), ..quieta() }, false); + assert_eq!(a.aviso, Some(TabAviso::Error)); + // Y no se re-dispara en los ticks siguientes (el flanco ya pasó). + a.paso(Senales { corre: false, ultimo_ok: Some(false), ..quieta() }, true); + a.paso(Senales { corre: false, ultimo_ok: Some(false), ..quieta() }, false); + assert_eq!(a.aviso, None, "sin flanco nuevo, no re-avisa"); + // Otro comando que sale bien. + a.paso(Senales { corre: true, ..quieta() }, false); + a.paso(Senales { corre: false, ultimo_ok: Some(true), ..quieta() }, false); + assert_eq!(a.aviso, Some(TabAviso::Ok)); + } + + #[test] + fn la_espera_del_asistente_avisa_una_vez() { + let mut a = Avisador::default(); + a.paso(Senales { espera: true, ..quieta() }, false); + assert_eq!(a.aviso, Some(TabAviso::Espera)); + a.paso(Senales { espera: true, ..quieta() }, true); // la visito + // La marca `➜` sigue en pantalla varios ticks: no debe re-levantarse. + a.paso(Senales { espera: true, ..quieta() }, false); + assert_eq!(a.aviso, None, "misma espera, no re-avisa"); + // Turno nuevo: la espera baja y vuelve a subir → sí avisa. + a.paso(Senales { espera: false, ..quieta() }, false); + a.paso(Senales { espera: true, ..quieta() }, false); + assert_eq!(a.aviso, Some(TabAviso::Espera)); + } + + #[test] + fn la_campana_gana_sobre_el_resto() { + let mut a = Avisador::default(); + a.paso(Senales { corre: true, ..quieta() }, false); + // En el mismo tick: sonó la campana Y terminó el comando Y hay espera. + a.paso( + Senales { campanadas: 1, corre: false, espera: true, ultimo_ok: Some(false) }, + false, + ); + assert_eq!(a.aviso, Some(TabAviso::Campana), "el pedido explícito manda"); + } + + #[test] + fn la_pestana_que_mirás_nunca_avisa() { + let mut a = Avisador::default(); + a.paso(Senales { corre: true, ..quieta() }, true); + a.paso( + Senales { campanadas: 9, corre: false, espera: true, ultimo_ok: Some(false) }, + true, + ); + assert_eq!(a.aviso, None, "la estás mirando: no hay nada que avisar"); + } + + #[test] + fn single_arranca_con_un_panel_una_tab() { + let w = ws(); + assert_eq!(w.tabs.len(), 1); + assert_eq!(w.tab().panes.len(), 1); + assert_eq!(w.tab().layout.count(), 1); + } + + #[test] + fn split_agrega_panel_y_enfoca_el_nuevo() { + let mut w = ws(); + let antes = w.tab().focused; + w.split(Axis::Horizontal, pane()); + assert_eq!(w.tab().panes.len(), 2); + assert_eq!(w.tab().layout.count(), 2); + assert_ne!(w.tab().focused, antes, "el panel nuevo toma el foco"); + } + + #[test] + fn close_focused_recolapsa_y_no_borra_el_ultimo() { + let mut w = ws(); + w.split(Axis::Vertical, pane()); + assert_eq!(w.tab().panes.len(), 2); + assert!(w.close_focused().is_some()); + assert_eq!(w.tab().panes.len(), 1); + // El último panel del último tab no se cierra. + assert!(w.close_focused().is_none()); + assert_eq!(w.tab().panes.len(), 1); + } + + #[test] + fn cycle_focus_recorre_los_tiled() { + let mut w = ws(); + w.split(Axis::Horizontal, pane()); + let a = w.tab().focused; + w.cycle_focus(true); + let b = w.tab().focused; + assert_ne!(a, b); + w.cycle_focus(true); + assert_eq!(w.tab().focused, a, "dos paneles → vuelve al primero"); + } + + #[test] + fn tabs_nuevo_activa_y_cierra() { + let mut w = ws(); + w.new_tab(pane()); + assert_eq!(w.tabs.len(), 2); + assert_eq!(w.active_tab, 1); + // Cerrar la tab activa vuelve a una. + let dropped = w.close_tab(1); + assert_eq!(dropped.len(), 1); + assert_eq!(w.tabs.len(), 1); + // No se cierra la única tab. + assert!(w.close_tab(0).is_empty()); + } + + #[test] + fn floating_agrega_capa_enfoca_y_togglea() { + let mut w = ws(); + w.new_float(pane()); + assert_eq!(w.tab().floating.len(), 1); + assert!(w.tab().show_floating); + let fid = w.tab().floating[0].id; + assert_eq!(w.tab().focused, fid, "el flotante nuevo toma el foco"); + // Apagar la capa devuelve el foco a un panel tiled. + w.toggle_floating(); + assert!(!w.tab().show_floating); + assert!(!w.tab().is_floating(w.tab().focused)); + } + + #[test] + fn move_float_acumula_delta() { + let mut w = ws(); + w.new_float(pane()); + let fid = w.tab().floating[0].id; + let (x0, y0) = (w.tab().floating[0].x, w.tab().floating[0].y); + w.move_float(fid, 10.0, -5.0); + let f = &w.tab().floating[0]; + assert!((f.x - (x0 + 10.0)).abs() < 1e-3); + assert!((f.y - (y0 - 5.0)).abs() < 1e-3); + } + + #[test] + fn close_floating_quita_de_la_capa() { + let mut w = ws(); + w.new_float(pane()); + assert_eq!(w.tab().floating.len(), 1); + // El foco está en el flotante → close_focused lo quita. + assert!(w.close_focused().is_some()); + assert_eq!(w.tab().floating.len(), 0); + assert_eq!(w.tab().panes.len(), 1); + } + + // ── Operaciones de tab del menú contextual (25-jul) ────────────── + + /// Cuatro tabs, para ejercitar mover/cerrar-a-la-derecha. + fn ws4() -> Workspace { + let mut w = ws(); + for _ in 0..3 { + w.new_tab(pane()); + } + assert_eq!(w.tabs.len(), 4); + w + } + + #[test] + fn close_right_deja_lo_de_la_izquierda() { + let mut w = ws4(); + w.switch_tab(3); + let dropped = w.close_right(1); + assert_eq!(dropped.len(), 2, "devuelve las instancias cerradas"); + assert_eq!(w.tabs.len(), 2, "quedan la 0 y la 1"); + assert_eq!(w.active_tab, 1, "la activa estaba a la derecha → cae en keep"); + // Sobre la última es no-op. + assert!(w.close_right(1).is_empty()); + assert_eq!(w.tabs.len(), 2); + } + + #[test] + fn close_right_no_mueve_una_activa_de_la_izquierda() { + let mut w = ws4(); + w.switch_tab(0); + w.close_right(2); + assert_eq!(w.tabs.len(), 3); + assert_eq!(w.active_tab, 0, "la activa ya estaba a salvo: no se toca"); + } + + #[test] + fn move_tab_reordena_y_la_activa_sigue_a_su_tab() { + let mut w = ws4(); + w.switch_tab(1); + // Mover la activa: se va con ella (no te cambia de pestaña bajo el dedo). + assert_eq!(w.move_tab(1, true), 2); + assert_eq!(w.active_tab, 2); + assert_eq!(w.move_tab(2, false), 1); + assert_eq!(w.active_tab, 1); + // Mover OTRA tab hacia el lugar de la activa: la activa se corre con su + // contenido, no se queda con el índice. + w.switch_tab(0); + assert_eq!(w.move_tab(1, false), 0); + assert_eq!(w.active_tab, 1, "la que yo miraba pasó a la posición 1"); + // Extremos: no-op. + assert_eq!(w.move_tab(0, false), 0); + assert_eq!(w.move_tab(3, true), 3); + assert_eq!(w.tabs.len(), 4, "ninguna se perdió en el camino"); + } + + #[test] + fn rename_tab_pone_saca_y_recorta() { + let mut w = ws4(); + assert!(w.rename_tab(1, " build ")); + assert_eq!(w.tab_name(1), "build", "se recorta el blanco"); + assert_eq!(w.titulo_de(1), "build"); + // Vacío = vuelve al título automático (no queda un nombre en blanco). + assert!(w.rename_tab(1, " ")); + assert_eq!(w.tab_name(1), ""); + assert_ne!(w.titulo_de(1), "", "sin nombre manual igual se rotula sola"); + // Índice inexistente. + assert!(!w.rename_tab(99, "x")); + } + + #[test] + fn el_nombre_manual_sigue_a_la_tab_cuando_se_mueve() { + // Si el nombre se quedara pegado al índice, reordenar renombraría la + // pestaña equivocada. + let mut w = ws4(); + w.rename_tab(0, "cero"); + w.move_tab(0, true); + assert_eq!(w.tab_name(1), "cero"); + assert_eq!(w.tab_name(0), ""); + } +} diff --git a/02_ruway/shuma/shuma-shell-llimphi/tests/pty_full_canvas.rs b/02_ruway/shuma/shuma-shell-llimphi/tests/pty_full_canvas.rs new file mode 100644 index 0000000..60bee24 --- /dev/null +++ b/02_ruway/shuma/shuma-shell-llimphi/tests/pty_full_canvas.rs @@ -0,0 +1,128 @@ +//! Repro headless del PTY en el modelo COMPLETO (live-wire) — el modo en que +//! pata hospeda la shuma en el drawer. Teclea un comando como lo hace pata +//! (`on_key` → `update`, con un handle channel-backed cuyos efectos se drenan +//! a mano) y verifica el estado del PTY y el CONTENIDO del screen vt100. +//! +//! Nació del bug en metal "vim abre el drawer pero vacío / claude sale +//! transparente": el camino bare (módulo solo) estaba verificado, el full no. + +use llimphi_ui::{Key, KeyEvent, KeyState, Modifiers, NamedKey}; +use std::time::{Duration, Instant}; + +fn tecla_char(c: &str) -> KeyEvent { + KeyEvent { + key: Key::Character(c.into()), + state: KeyState::Pressed, + text: Some(c.to_string()), + modifiers: Modifiers::default(), + repeat: false, + } +} + +fn tecla_enter() -> KeyEvent { + KeyEvent { + key: Key::Named(NamedKey::Enter), + state: KeyState::Pressed, + text: None, + modifiers: Modifiers::default(), + repeat: false, + } +} + +/// Corre `cmd` en el modelo full y devuelve `(pty_vivo, alt_screen, filas)` +/// tras `espera` de drenar efectos. Las filas son el contenido del vt100. +fn corre_en_full(cmd: &str, espera: Duration) -> (bool, bool, Vec) { + let (tx, rx) = std::sync::mpsc::channel::(); + let tx = std::sync::Mutex::new(tx); + let handle: llimphi_ui::Handle = + llimphi_ui::Handle::<()>::for_test().lift(move |m| { + let _ = tx.lock().unwrap().send(m); + }); + let mut model = shuma_shell_llimphi::new_model(); + // Como pata: drawer chromeless con el input en la barra del host. + model.chromeless = true; + model.hosted_bar = true; + // La activa debe ser una sesión shell REAL (no el draft pendiente); si el + // perfil cargado dejó activa una pendiente, salta a la primera real. (El + // tipo `Session` es privado: se sondea por índice vía `active_shell_state`.) + if model.active_shell_state().is_none() { + for i in 0..64 { + model.active_session = i; + if model.active_shell_state().is_some() { + break; + } + } + } + assert!( + model.active_shell_state().is_some(), + "no hay sesión shell real en el perfil — el test necesita una" + ); + shuma_shell_llimphi::spawn_host_effects(&mut model, &handle); + for ch in cmd.chars() { + let s = ch.to_string(); + if let Some(m) = shuma_shell_llimphi::on_key(&model, &tecla_char(&s)) { + model = shuma_shell_llimphi::update(model, m, &handle); + } + } + if let Some(m) = shuma_shell_llimphi::on_key(&model, &tecla_enter()) { + model = shuma_shell_llimphi::update(model, m, &handle); + } + let t0 = Instant::now(); + let mut shell_ticks = 0u32; + while t0.elapsed() < espera { + if let Ok(m) = rx.recv_timeout(Duration::from_millis(50)) { + if matches!(m, shuma_shell_llimphi::Msg::ShellTick) { + shell_ticks += 1; + } + model = shuma_shell_llimphi::update(model, m, &handle); + } + } + eprintln!("shell_ticks recibidos: {shell_ticks}"); + let st = model + .active_shell_state() + .expect("la sesión activa debería ser un shell"); + let vivo = st.tiene_pty_vivo(); + let fullscreen = st.is_fullscreen_tui(); + let filas: Vec = st + .running + .as_ref() + .and_then(|arc| arc.lock().ok()) + .and_then(|g| { + g.tui.as_ref().map(|t| { + let screen = t.parser.screen(); + let (rows, cols) = screen.size(); + (0..rows) + .map(|r| { + (0..cols) + .filter_map(|c| screen.cell(r, c).map(|cell| cell.contents())) + .collect::() + }) + .collect() + }) + }) + .unwrap_or_default(); + // El PTY del run queda vivo al salir del test; matalo para no dejar vims + // huérfanos en la máquina. + if let Some(arc) = model.active_shell_state().and_then(|s| s.running.clone()) { + if let Ok(g) = arc.lock() { + g.handle.kill(); + } + } + (vivo, fullscreen, filas) +} + +#[test] +fn vim_en_full_entra_altscreen_y_pinta() { + let (vivo, fullscreen, filas) = corre_en_full("vim", Duration::from_secs(8)); + let contenido: usize = filas.iter().map(|l| l.trim().len()).sum(); + eprintln!("vim: vivo={vivo} altscreen={fullscreen} contenido={contenido}"); + for l in filas.iter().take(6) { + eprintln!("| {l}"); + } + assert!(vivo, "el PTY de vim debería seguir vivo"); + assert!(fullscreen, "vim debería haber entrado a alt-screen"); + assert!( + contenido > 0, + "el screen de vim no debería estar vacío (el bug 'drawer vacío')" + ); +} diff --git a/02_ruway/shuma/shuma-shell-llimphi/tests/taskmanager_layout.rs b/02_ruway/shuma/shuma-shell-llimphi/tests/taskmanager_layout.rs new file mode 100644 index 0000000..b66ab28 --- /dev/null +++ b/02_ruway/shuma/shuma-shell-llimphi/tests/taskmanager_layout.rs @@ -0,0 +1,158 @@ +//! El gestor de sesiones, medido: **cuánto ancho recibe cada texto** de una +//! tarjeta y en cuántas líneas cae. +//! +//! Existe por una regresión concreta: la tarjeta rotulaba con el nombre del +//! binario (`claude`) y el texto, sin piso de `min_size` ni elisión, se envolvía +//! y quedaba cortado por el alto fijo de la fila — se leían cinco caracteres. +//! Este test corre el MISMO pipeline de layout que la app (mount → +//! `compute_with_measure` con parley) y afirma sobre números, sin renderizar ni +//! mirar un PNG (CLAUDE.md §8). + +use llimphi_theme::Theme; +use llimphi_ui::llimphi_compositor::{measure_text_node, mount}; +use llimphi_ui::llimphi_layout::{taffy, LayoutTree}; +use llimphi_ui::llimphi_text::Typesetter; +use shuma_shell_llimphi::types::{EstadoTab, Msg, TaskRow}; + +/// Ancho típico del drawer de pata donde vive shuma. +const W: f32 = 1280.0; +const H: f32 = 900.0; + +fn fila(titulo_osc: Option<&str>) -> TaskRow { + TaskRow { + id: "01KYB5N5KFTVGAYGMX3CRAQARM".into(), + label: "claude --dangerously-skip-permissions".into(), + program: "claude".into(), + cmd: "claude --dangerously-skip-permissions --append-system-prompt …".into(), + cwd: "/home/sergio/tawasuyu".into(), + titulo_osc: titulo_osc.map(str::to_string), + preview: vec![ + "❯ cargo check --workspace".into(), + " Checking shuma-shell-llimphi v0.1.0".into(), + " Finished in 41.2s".into(), + ], + alive: true, + exit_code: None, + attached: 1, + created_ms: 1_700_000_000_000, + en_tab: Some((0, 2, "trabajo".into())), + } +} + +/// `(texto, ancho, alto)` de cada nodo con texto, en orden de montaje. +fn medir(v: llimphi_ui::View) -> Vec<(String, f32, f32)> { + let mut layout = LayoutTree::new(); + let mounted = mount(&mut layout, v); + let mut ts = Typesetter::new(); + let computed = { + let tmap = &mounted.text_measures; + layout + .compute_with_measure(mounted.root, (W, H), |nid, known, avail| match tmap.get(&nid) { + Some(tm) => measure_text_node(&mut ts, tm, known, avail), + None => taffy::Size::ZERO, + }) + .expect("layout") + }; + mounted + .nodes + .iter() + .filter_map(|n| { + let t = n.text.as_ref()?; + let r = computed.get(n.id)?; + Some((t.content.clone(), r.w, r.h)) + }) + .collect() +} + +#[test] +fn el_titulo_de_la_tarjeta_recibe_ancho_y_no_se_parte() { + let theme = Theme::default(); + let r = fila(Some("claude · tawasuyu — gestor de sesiones")); + let titulo = r.titulo(); + let textos = medir(shuma_shell_llimphi::view::tarjeta_de_sesion_para_test( + &r, + EstadoTab::EnPestana, + 1_700_000_100_000, + &theme, + )); + + for (c, w, h) in &textos { + println!("{w:7.1} × {h:5.1} {c:?}"); + } + let (_, w, h) = textos + .iter() + .find(|(c, _, _)| c == &titulo) + .unwrap_or_else(|| panic!("el título no está en la tarjeta; textos: {textos:#?}")); + + // Con la miniatura (232) + acciones (118) + paddings, al título le quedan + // varios cientos de px. El bug viejo le dejaba una caja angosta. + assert!( + *w > 500.0, + "el título debe recibir el ancho sobrante de la tarjeta, recibió {w} px" + ); + // Una sola línea: 14 px de cuerpo → ~17 px de alto. Dos líneas serían ~34 y + // la segunda quedaría comida por el alto fijo de la tarjeta. + assert!( + *h < 24.0, + "el título debe quedar en UNA línea (elide), midió {h} px de alto" + ); +} + +#[test] +fn el_titulo_sale_del_osc_y_cae_al_comando_cuando_no_hay() { + let r = fila(Some("nvim src/lib.rs")); + assert_eq!(r.titulo(), "nvim src/lib.rs", "manda el título OSC"); + + let sin_osc = fila(None); + assert!( + sin_osc.titulo().starts_with("claude --dangerously-skip-permissions"), + "sin OSC se rotula con el comando COMPLETO, no con el binario: {}", + sin_osc.titulo() + ); + assert!( + sin_osc.titulo().len() > 20, + "el título nunca se recorta en el modelo (recorta la vista, por ancho)" + ); +} + +#[test] +fn el_estado_distingue_abierta_de_cerrada_de_muerta() { + let mut r = fila(None); + + r.en_tab = Some((0, 2, "trabajo".into())); + assert_eq!(r.estado(), EstadoTab::EnPestana, "montada en una pestaña de esta ventana"); + + r.en_tab = None; + r.attached = 1; + assert_eq!(r.estado(), EstadoTab::EnOtroCliente, "viva y adjunta en otro lado"); + + r.attached = 0; + assert_eq!(r.estado(), EstadoTab::AlFondo, "viva sin nadie mirándola = la cerraste"); + + r.alive = false; + r.exit_code = Some(1); + assert_eq!(r.estado(), EstadoTab::Terminada, "el proceso salió"); +} + +#[test] +fn la_miniatura_pinta_una_linea_por_fila_de_pantalla() { + let theme = Theme::default(); + let r = fila(None); + let textos = medir(shuma_shell_llimphi::view::tarjeta_de_sesion_para_test( + &r, + EstadoTab::AlFondo, + 1_700_000_100_000, + &theme, + )); + for l in &r.preview { + assert!( + textos.iter().any(|(c, _, _)| c == l), + "la miniatura debe pintar la línea {l:?}; textos: {textos:#?}" + ); + } + // Y cada línea entra entera en el ancho de la miniatura (no se envuelve a + // dos, que es lo que rompería el apilado de 8 líneas). + for (c, _, h) in textos.iter().filter(|(c, _, _)| r.preview.contains(c)) { + assert!(*h < 16.0, "la línea {c:?} de la miniatura envolvió ({h} px)"); + } +} diff --git a/Cargo.lock b/Cargo.lock index 22fda42..bdfbe67 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -18,6 +18,114 @@ version = "0.1.10" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "366ffbaa4442f4684d91e2cd7c5ea7c4ed8add41959a31447066e279e432b618" +[[package]] +name = "accesskit" +version = "0.24.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d3b7f7f85a7e5f68090000ed7622545829afd484d210358702ae4cb97dd0c320" +dependencies = [ + "uuid", +] + +[[package]] +name = "accesskit_atspi_common" +version = "0.19.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "023da0e5097f46df7092d5280b02efb9bbf8d93298daeced42652463e357d636" +dependencies = [ + "accesskit", + "accesskit_consumer", + "atspi-common", + "phf", + "serde", + "zvariant", +] + +[[package]] +name = "accesskit_consumer" +version = "0.38.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5d10a236f96f87d70732e44520046785431ef01d5bcd6b041317bfadd2f88245" +dependencies = [ + "accesskit", + "hashbrown 0.16.1", +] + +[[package]] +name = "accesskit_ios" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "750c4e9f6ce888dfe8a10c0f1b5ceb646a7854fd46579d74919219d1bb314083" +dependencies = [ + "accesskit", + "accesskit_consumer", + "hashbrown 0.16.1", + "objc2 0.5.2", + "objc2-foundation 0.2.2", + "objc2-ui-kit", +] + +[[package]] +name = "accesskit_macos" +version = "0.26.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ce02dc63b43f0c9296af9ac946312a2dc8814427d7a64d2d600971dac55b6076" +dependencies = [ + "accesskit", + "accesskit_consumer", + "hashbrown 0.16.1", + "objc2 0.5.2", + "objc2-app-kit 0.2.2", + "objc2-foundation 0.2.2", +] + +[[package]] +name = "accesskit_unix" +version = "0.22.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "03e156ed3802e35eefe894ef2671bc6c889303d8a7e110b5e1b48f504b91362f" +dependencies = [ + "accesskit", + "accesskit_atspi_common", + "async-channel", + "async-executor", + "async-task", + "atspi", + "futures-lite", + "futures-util", + "serde", + "zbus", +] + +[[package]] +name = "accesskit_windows" +version = "0.34.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "106c2b961215864d1c2e703ee63269c25c4e80a577ffb2c1017b9c17dcdf83a1" +dependencies = [ + "accesskit", + "accesskit_consumer", + "hashbrown 0.16.1", + "static_assertions", + "windows 0.62.2", + "windows-core 0.62.2", +] + +[[package]] +name = "accesskit_winit" +version = "0.33.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d5b41e63a69f36d9f1f41e70464c7e5f72eee485ef26aab19f0b4f86e6c0a84c" +dependencies = [ + "accesskit", + "accesskit_ios", + "accesskit_macos", + "accesskit_unix", + "accesskit_windows", + "raw-window-handle", + "winit", +] + [[package]] name = "adler2" version = "2.0.1" @@ -59,6 +167,38 @@ dependencies = [ "subtle", ] +[[package]] +name = "agora-core" +version = "0.1.0" +source = "git+https://git.tawasuyu.net/tawasuyu/tawasuyu.git?tag=v0.2.0#a446bb3a73a0e022158cc0ec7deb65173859644b" +dependencies = [ + "blake3", + "ed25519-dalek", + "format", + "serde", + "thiserror 2.0.19", + "umbral", +] + +[[package]] +name = "agora-graph" +version = "0.1.0" +source = "git+https://git.tawasuyu.net/tawasuyu/tawasuyu.git?tag=v0.2.0#a446bb3a73a0e022158cc0ec7deb65173859644b" +dependencies = [ + "agora-core", + "fork-proof", + "serde", +] + +[[package]] +name = "agora-petnames" +version = "0.1.0" +source = "git+https://git.tawasuyu.net/tawasuyu/tawasuyu.git?tag=v0.2.0#a446bb3a73a0e022158cc0ec7deb65173859644b" +dependencies = [ + "agora-core", + "serde", +] + [[package]] name = "ahash" version = "0.8.12" @@ -87,6 +227,28 @@ version = "0.2.21" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "683d7910e743518b0e34f1186f92494becacb047c7b6bf616c96772180fef923" +[[package]] +name = "alsa" +version = "0.9.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed7572b7ba83a31e20d1b48970ee402d2e3e0537dcfe0a3ff4d6eb7508617d43" +dependencies = [ + "alsa-sys", + "bitflags 2.13.1", + "cfg-if", + "libc", +] + +[[package]] +name = "alsa-sys" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "db8fee663d06c4e303404ef5f40488a53e062f89ba8bfed81f42325aafad1527" +dependencies = [ + "libc", + "pkg-config", +] + [[package]] name = "android-activity" version = "0.6.1" @@ -94,16 +256,16 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0f2a1bb052857d5dd49572219344a7332b31b76405648eabac5bc68978251bcd" dependencies = [ "android-properties", - "bitflags 2.12.1", + "bitflags 2.13.1", "cc", - "jni", + "jni 0.22.4", "libc", "log", - "ndk", + "ndk 0.9.0", "ndk-context", "ndk-sys 0.6.0+11769913", "num_enum", - "thiserror 2.0.18", + "thiserror 2.0.19", ] [[package]] @@ -173,14 +335,14 @@ dependencies = [ [[package]] name = "anyhow" -version = "1.0.102" +version = "1.0.104" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7f202df86484c868dbad7eaa557ef785d5c66295e41b460ef922eca0723b842c" +checksum = "330a5ed07fa54e4702c9d6c4174f74427fc0ef6e214bbd677ae50a5099946470" [[package]] name = "app-bus" version = "0.1.0" -source = "git+https://gitea.tawasuyu.net/sergio/gioser.git?tag=v0.1.0#692b40336c1e9cb12ab561764d6943cb65905ab3" +source = "git+https://git.tawasuyu.net/tawasuyu/tawasuyu.git?tag=v0.2.0#a446bb3a73a0e022158cc0ec7deb65173859644b" dependencies = [ "directories", "serde", @@ -204,6 +366,7 @@ dependencies = [ "parking_lot 0.12.5", "percent-encoding", "windows-sys 0.60.2", + "wl-clipboard-rs", "x11rb", ] @@ -219,19 +382,87 @@ dependencies = [ "password-hash", ] +[[package]] +name = "arje-applaunch" +version = "0.1.0" +source = "git+https://git.tawasuyu.net/tawasuyu/tawasuyu.git?tag=v0.2.0#a446bb3a73a0e022158cc0ec7deb65173859644b" +dependencies = [ + "app-bus", + "arje-bus", + "arje-card", + "tokio", +] + +[[package]] +name = "arje-bus" +version = "0.0.1" +source = "git+https://git.tawasuyu.net/tawasuyu/tawasuyu.git?tag=v0.2.0#a446bb3a73a0e022158cc0ec7deb65173859644b" +dependencies = [ + "anyhow", + "arje-card", + "postcard", + "serde", + "tokio", + "ulid", +] + +[[package]] +name = "arje-card" +version = "0.0.1" +source = "git+https://git.tawasuyu.net/tawasuyu/tawasuyu.git?tag=v0.2.0#a446bb3a73a0e022158cc0ec7deb65173859644b" +dependencies = [ + "card-core", +] + +[[package]] +name = "arje-card-builder" +version = "0.1.0" +source = "git+https://git.tawasuyu.net/tawasuyu/tawasuyu.git?tag=v0.2.0#a446bb3a73a0e022158cc0ec7deb65173859644b" +dependencies = [ + "card-core", + "serde", + "serde_json", + "thiserror 2.0.19", + "ulid", +] + +[[package]] +name = "arje-cas" +version = "0.0.1" +source = "git+https://git.tawasuyu.net/tawasuyu/tawasuyu.git?tag=v0.2.0#a446bb3a73a0e022158cc0ec7deb65173859644b" +dependencies = [ + "anyhow", + "blake3", + "tracing", +] + [[package]] name = "arje-incarnate" version = "0.1.0" -source = "git+https://gitea.tawasuyu.net/sergio/gioser.git?tag=v0.1.0#692b40336c1e9cb12ab561764d6943cb65905ab3" +source = "git+https://git.tawasuyu.net/tawasuyu/tawasuyu.git?tag=v0.2.0#a446bb3a73a0e022158cc0ec7deb65173859644b" dependencies = [ "anyhow", "card-core", "libc", "nix 0.29.0", - "thiserror 2.0.18", + "thiserror 2.0.19", "tracing", ] +[[package]] +name = "arje-wasm" +version = "0.0.1" +source = "git+https://git.tawasuyu.net/tawasuyu/tawasuyu.git?tag=v0.2.0#a446bb3a73a0e022158cc0ec7deb65173859644b" +dependencies = [ + "anyhow", + "arje-card", + "kikin-wasmi", + "tracing", + "ulid", + "wasmi", + "wat", +] + [[package]] name = "arrayref" version = "0.3.9" @@ -240,9 +471,9 @@ checksum = "76a2e8124351fda1ef8aaaa3bbd7ebbcb486bbcd4225aca0aa0d84bb2db8fecb" [[package]] name = "arrayvec" -version = "0.7.6" +version = "0.7.8" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7c02d123df017efcdfbd739ef81735b36c5ba83ec3c59c80a9d7ecc718f92e50" +checksum = "d3fb67a6e08acf24fdeccbac2cb6ac4305825bd1f117462e0e6f2f193345ad56" [[package]] name = "as-raw-xcb-connection" @@ -268,10 +499,10 @@ dependencies = [ "asn1-rs-derive", "asn1-rs-impl", "displaydoc", - "nom", + "nom 7.1.3", "num-traits", "rusticata-macros", - "thiserror 2.0.18", + "thiserror 2.0.19", "time", ] @@ -283,7 +514,7 @@ checksum = "3109e49b1e4909e9db6515a30c633684d68cdeaa252f215214cb4fa1a5bfee2c" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 2.0.119", "synstructure", ] @@ -295,7 +526,45 @@ checksum = "7b18050c2cd6fe86c3a76584ef5e0baf286d038cda203eb6223df2cc413565f7" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 2.0.119", +] + +[[package]] +name = "async-broadcast" +version = "0.7.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "435a87a52755b8f27fcf321ac4f04b2802e337c8c4872923137471ec39c37532" +dependencies = [ + "event-listener", + "event-listener-strategy", + "futures-core", + "pin-project-lite", +] + +[[package]] +name = "async-channel" +version = "2.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "924ed96dd52d1b75e9c1a3e6275715fd320f5f9439fb5a4a11fa51f4221158d2" +dependencies = [ + "concurrent-queue", + "event-listener-strategy", + "futures-core", + "pin-project-lite", +] + +[[package]] +name = "async-executor" +version = "1.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c96bf972d85afc50bf5ab8fe2d54d1586b4e0b46c97c50a0c9e71e2f7bcd812a" +dependencies = [ + "async-task", + "concurrent-queue", + "fastrand", + "futures-lite", + "pin-project-lite", + "slab", ] [[package]] @@ -317,14 +586,78 @@ dependencies = [ ] [[package]] -name = "async-trait" -version = "0.1.89" +name = "async-lock" +version = "3.4.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9035ad2d096bed7955a320ee7e2230574d28fd3c3a0f186cbea1ff3c7eed5dbb" +checksum = "290f7f2596bd5b78a9fec8088ccd89180d7f9f55b94b0576823bbbdc72ee8311" +dependencies = [ + "event-listener", + "event-listener-strategy", + "pin-project-lite", +] + +[[package]] +name = "async-process" +version = "2.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fc50921ec0055cdd8a16de48773bfeec5c972598674347252c0399676be7da75" +dependencies = [ + "async-channel", + "async-io", + "async-lock", + "async-signal", + "async-task", + "blocking", + "cfg-if", + "event-listener", + "futures-lite", + "rustix 1.1.4", +] + +[[package]] +name = "async-recursion" +version = "1.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3b43422f69d8ff38f95f1b2bb76517c91589a924d1559a0e935d7c8ce0274c11" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 2.0.119", +] + +[[package]] +name = "async-signal" +version = "0.2.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "52b5aaafa020cf5053a01f2a60e8ff5dccf550f0f77ec54a4e47285ac2bab485" +dependencies = [ + "async-io", + "async-lock", + "atomic-waker", + "cfg-if", + "futures-core", + "futures-io", + "rustix 1.1.4", + "signal-hook-registry", + "slab", + "windows-sys 0.61.2", +] + +[[package]] +name = "async-task" +version = "4.7.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8b75356056920673b02621b35afd0f7dda9306d03c79a30f5c56c44cf256e3de" + +[[package]] +name = "async-trait" +version = "0.1.91" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ae36dc4177970ef04fde5178d3e2429882def40e57a451f919c098f72baa6cec" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.3", ] [[package]] @@ -340,6 +673,15 @@ dependencies = [ "pin-project-lite", ] +[[package]] +name = "atipay" +version = "0.1.0" +source = "git+https://git.tawasuyu.net/tawasuyu/tawasuyu.git?tag=v0.2.0#a446bb3a73a0e022158cc0ec7deb65173859644b" +dependencies = [ + "serde", + "serde_json", +] + [[package]] name = "atomic-polyfill" version = "1.0.3" @@ -355,13 +697,50 @@ version = "1.1.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1505bd5d3d116872e7271a6d4e16d81d0c8570876c8de68093a09ac269d8aac0" +[[package]] +name = "atspi" +version = "0.29.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c77886257be21c9cd89a4ae7e64860c6f0eefca799bb79127913052bd0eefb3d" +dependencies = [ + "atspi-common", + "atspi-proxies", +] + +[[package]] +name = "atspi-common" +version = "0.13.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "20c5617155740c98003016429ad13fe43ce7a77b007479350a9f8bf95a29f63d" +dependencies = [ + "enumflags2", + "serde", + "static_assertions", + "zbus", + "zbus-lockstep", + "zbus-lockstep-macros", + "zbus_names", + "zvariant", +] + +[[package]] +name = "atspi-proxies" +version = "0.13.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2230e48787ed3eb4088996eab66a32ca20c0b67bbd4fd6cdfe79f04f1f04c9fc" +dependencies = [ + "atspi-common", + "serde", + "zbus", +] + [[package]] name = "attohttpc" version = "0.30.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "16e2cdb6d5ed835199484bb92bb8b3edd526effe995c61732580439c1a67e2e9" dependencies = [ - "base64", + "base64 0.22.1", "http", "log", "url", @@ -375,9 +754,9 @@ checksum = "f2032f911046de80f0a198e0901378627c33f59ea0ac00e363d481118bd70a53" [[package]] name = "aws-lc-rs" -version = "1.17.0" +version = "1.17.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5ec2f1fc3ec205783a5da9a7e6c1509cc69dedf09a1949e412c1e18469326d00" +checksum = "00bdb5da18dac48ca2cc7cd4a98e533e8635a58e2361d13a1a4ee3888e0d72f1" dependencies = [ "aws-lc-sys", "untrusted 0.7.1", @@ -386,14 +765,73 @@ dependencies = [ [[package]] name = "aws-lc-sys" -version = "0.41.0" +version = "0.43.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1a2f9779ce85b93ab6170dd940ad0169b5766ff848247aff13bb788b832fe3f4" +checksum = "43103168cc76fe62678a375e722fc9cb3a0146159ac5828bc4f0dfd755c2224c" dependencies = [ "cc", "cmake", "dunce", "fs_extra", + "pkg-config", +] + +[[package]] +name = "axum" +version = "0.7.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "edca88bc138befd0323b20752846e6587272d3b03b0343c8ea28a6f819e6e71f" +dependencies = [ + "async-trait", + "axum-core", + "base64 0.22.1", + "bytes", + "futures-util", + "http", + "http-body", + "http-body-util", + "hyper", + "hyper-util", + "itoa", + "matchit", + "memchr", + "mime", + "percent-encoding", + "pin-project-lite", + "rustversion", + "serde", + "serde_json", + "serde_path_to_error", + "serde_urlencoded", + "sha1", + "sync_wrapper", + "tokio", + "tokio-tungstenite", + "tower", + "tower-layer", + "tower-service", + "tracing", +] + +[[package]] +name = "axum-core" +version = "0.4.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "09f2bd6146b97ae3359fa0cc6d6b376d9539582c7b4220f041a33ec24c226199" +dependencies = [ + "async-trait", + "bytes", + "futures-util", + "http", + "http-body", + "http-body-util", + "mime", + "pin-project-lite", + "rustversion", + "sync_wrapper", + "tower-layer", + "tower-service", + "tracing", ] [[package]] @@ -418,6 +856,18 @@ dependencies = [ "match-lookup", ] +[[package]] +name = "base45" +version = "3.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "240e56f4d3c453c36faacb695c535a4d5f8c7d23dac175014f32eb0a71012a03" + +[[package]] +name = "base64" +version = "0.21.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9d297deb1925b89f2ccc13d7635fa0714f12c87adce1c75356b39ca9b7178567" + [[package]] name = "base64" version = "0.22.1" @@ -441,6 +891,24 @@ dependencies = [ "sha2", ] +[[package]] +name = "bindgen" +version = "0.72.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "993776b509cfb49c750f11b8f07a46fa23e0a1386ffc01fb1e7d343efc387895" +dependencies = [ + "bitflags 2.13.1", + "cexpr", + "clang-sys", + "itertools 0.13.0", + "proc-macro2", + "quote", + "regex", + "rustc-hash 2.1.3", + "shlex 1.3.0", + "syn 2.0.119", +] + [[package]] name = "bit-set" version = "0.8.0" @@ -456,6 +924,16 @@ version = "0.8.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "5e764a1d40d510daf35e07be9eb06e75770908c27d411ee6c92109c9840eaaf7" +[[package]] +name = "bitacora" +version = "0.1.0" +source = "git+https://git.tawasuyu.net/tawasuyu/tawasuyu.git?tag=v0.2.0#a446bb3a73a0e022158cc0ec7deb65173859644b" +dependencies = [ + "libc", + "tracing", + "tracing-subscriber", +] + [[package]] name = "bitflags" version = "1.3.2" @@ -464,9 +942,9 @@ checksum = "bef38d45163c2f1dde094a7dfd33ccf595c92905c8f8f4fdc18d06fb1037718a" [[package]] name = "bitflags" -version = "2.12.1" +version = "2.13.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "84d7ced0ae9557296835c32bf1b1e02b44c746701f898460fb000d7eaa84f00a" +checksum = "b588b76d00fde79687d7646a9b5bdf3cc0f655e0bbd080335a95d7e96f3587da" dependencies = [ "serde_core", ] @@ -527,6 +1005,19 @@ dependencies = [ "objc2 0.5.2", ] +[[package]] +name = "blocking" +version = "1.6.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e83f8d02be6967315521be875afa792a316e28d57b5a2d401897e2a7921b7f21" +dependencies = [ + "async-channel", + "async-task", + "futures-io", + "futures-lite", + "piper", +] + [[package]] name = "blowfish" version = "0.9.1" @@ -554,22 +1045,22 @@ checksum = "72f5acc6cb2ba439de613abc23857ec3d78374d8ed5ac84e9d11336e87da8649" [[package]] name = "bytemuck" -version = "1.25.0" +version = "1.25.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c8efb64bd706a16a1bdde310ae86b351e4d21550d98d056f22f8a7f7a2183fec" +checksum = "95832e849adfb21180ccb6826a99da14e5d266ae5c2e668e1602cf234f153797" dependencies = [ "bytemuck_derive", ] [[package]] name = "bytemuck_derive" -version = "1.10.2" +version = "1.11.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f9abbd1bc6865053c427f7198e6af43bfdedc55ab791faed4fbd361d789575ff" +checksum = "f65693059b6b9c588b9f62fed1cedbf0a8b805631457ea162d68f0de186f3de5" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 2.0.119", ] [[package]] @@ -586,9 +1077,9 @@ checksum = "8f1fe948ff07f4bd06c30984e69f5b4899c516a3ef74f34df92a2df2ab535495" [[package]] name = "bytes" -version = "1.11.1" +version = "1.12.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1e748733b7cbc798e1434b6ac524f0c1ff2ab456fe201501e6497c8417a4fc33" +checksum = "fc652a48c352aef3ea3aed32080501cf3ef6ed5da78602a020c991775b0aff04" [[package]] name = "calloop" @@ -596,7 +1087,7 @@ version = "0.13.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b99da2f8558ca23c71f4fd15dc57c906239752dd27ff3c00a1d56b685b7cbfec" dependencies = [ - "bitflags 2.12.1", + "bitflags 2.13.1", "log", "polling", "rustix 0.38.44", @@ -619,11 +1110,12 @@ dependencies = [ [[package]] name = "card-core" version = "0.1.0" -source = "git+https://gitea.tawasuyu.net/sergio/gioser.git?tag=v0.1.0#692b40336c1e9cb12ab561764d6943cb65905ab3" +source = "git+https://git.tawasuyu.net/tawasuyu/tawasuyu.git?tag=v0.2.0#a446bb3a73a0e022158cc0ec7deb65173859644b" dependencies = [ + "format", "serde", "serde_json", - "thiserror 2.0.18", + "thiserror 2.0.19", "toml", "ulid", ] @@ -631,7 +1123,7 @@ dependencies = [ [[package]] name = "card-handshake" version = "0.1.0" -source = "git+https://gitea.tawasuyu.net/sergio/gioser.git?tag=v0.1.0#692b40336c1e9cb12ab561764d6943cb65905ab3" +source = "git+https://git.tawasuyu.net/tawasuyu/tawasuyu.git?tag=v0.2.0#a446bb3a73a0e022158cc0ec7deb65173859644b" dependencies = [ "blake3", "card-core", @@ -641,7 +1133,7 @@ dependencies = [ "notify", "postcard", "serde", - "thiserror 2.0.18", + "thiserror 2.0.19", "tokio", "tokio-util", "tracing", @@ -651,7 +1143,7 @@ dependencies = [ [[package]] name = "card-net" version = "0.1.0" -source = "git+https://gitea.tawasuyu.net/sergio/gioser.git?tag=v0.1.0#692b40336c1e9cb12ab561764d6943cb65905ab3" +source = "git+https://git.tawasuyu.net/tawasuyu/tawasuyu.git?tag=v0.2.0#a446bb3a73a0e022158cc0ec7deb65173859644b" dependencies = [ "blake3", "futures", @@ -659,19 +1151,19 @@ dependencies = [ "libp2p-allow-block-list", "libp2p-stream", "serde", - "thiserror 2.0.18", + "thiserror 2.0.19", "tokio", ] [[package]] name = "card-sidecar" version = "0.1.0" -source = "git+https://gitea.tawasuyu.net/sergio/gioser.git?tag=v0.1.0#692b40336c1e9cb12ab561764d6943cb65905ab3" +source = "git+https://git.tawasuyu.net/tawasuyu/tawasuyu.git?tag=v0.2.0#a446bb3a73a0e022158cc0ec7deb65173859644b" dependencies = [ "card-core", "card-handshake", "card-net", - "thiserror 2.0.18", + "thiserror 2.0.19", "tokio", "tracing", ] @@ -687,14 +1179,29 @@ dependencies = [ [[package]] name = "cc" -version = "1.2.63" +version = "1.4.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "556e016178bb5662a08681bbe0f00f8e17631781a4dfc8c45e466e4b185ec27f" +checksum = "5add81bb678e6cb321aff7fa0dc7689ad82b112dbc032cea19f91d6b8e3582b9" dependencies = [ "find-msvc-tools", "jobserver", "libc", - "shlex", + "shlex 2.0.1", +] + +[[package]] +name = "cesu8" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6d43a04d8753f35258c91f8ec639f792891f748a1edbd759cf1dcea3382ad83c" + +[[package]] +name = "cexpr" +version = "0.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6fac387a98bb7c37292057cffc56d62ecb629900026402633ae9160df93a8766" +dependencies = [ + "nom 7.1.3", ] [[package]] @@ -711,9 +1218,9 @@ checksum = "fd16c4719339c4530435d38e511904438d07cce7950afa3718a84ac36c10e89e" [[package]] name = "cfg_aliases" -version = "0.2.1" +version = "0.2.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "613afe47fcd5fac7ccf1db93babcb082c5994d996f20b8b159f2ad1658eb5724" +checksum = "f079e83a288787bcd14a6aea84cee5c87a67c5a3e660c30f557a3d24761b3527" [[package]] name = "chacha20" @@ -726,6 +1233,17 @@ dependencies = [ "cpufeatures 0.2.17", ] +[[package]] +name = "chacha20" +version = "0.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d524456ba66e72eb8b115ff89e01e497f8e6d11d78b70b1aa13c0fbd97540a81" +dependencies = [ + "cfg-if", + "cpufeatures 0.3.0", + "rand_core 0.10.1", +] + [[package]] name = "chacha20poly1305" version = "0.10.1" @@ -733,7 +1251,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "10cd79432192d1c0f4e1a0fef9527696cc039165d729fb41b3f4f4f354c2dc35" dependencies = [ "aead", - "chacha20", + "chacha20 0.9.1", "cipher", "poly1305", "zeroize", @@ -742,7 +1260,7 @@ dependencies = [ [[package]] name = "chasqui-broker" version = "0.1.0" -source = "git+https://gitea.tawasuyu.net/sergio/gioser.git?tag=v0.1.0#692b40336c1e9cb12ab561764d6943cb65905ab3" +source = "git+https://git.tawasuyu.net/tawasuyu/tawasuyu.git?tag=v0.2.0#a446bb3a73a0e022158cc0ec7deb65173859644b" dependencies = [ "card-core", "serde", @@ -751,9 +1269,9 @@ dependencies = [ [[package]] name = "chrono" -version = "0.4.44" +version = "0.4.45" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c673075a2e0e5f4a1dde27ce9dee1ea4558c7ffe648f576438a20ca1d2acc4b0" +checksum = "1aa79e62e7697b8e29b513a68abacf485adcd1fe8284a4316c5ae868e6633327" dependencies = [ "iana-time-zone", "js-sys", @@ -774,10 +1292,21 @@ dependencies = [ ] [[package]] -name = "clap" -version = "4.6.1" +name = "clang-sys" +version = "1.8.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1ddb117e43bbf7dacf0a4190fef4d345b9bad68dfc649cb349e7d17d28428e51" +checksum = "0b023947811758c97c59bf9d1c188fd619ad4718dcaa767947df1cadb14f39f4" +dependencies = [ + "glob", + "libc", + "libloading", +] + +[[package]] +name = "clap" +version = "4.6.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d91e0c145792ef73a6ad36d27c75ac09f1832222a3c209689d90f534685ee5b7" dependencies = [ "clap_builder", "clap_derive", @@ -785,9 +1314,9 @@ dependencies = [ [[package]] name = "clap_builder" -version = "4.6.0" +version = "4.6.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "714a53001bf66416adb0e2ef5ac857140e7dc3a0c48fb28b2f10762fc4b5069f" +checksum = "f09628afdcc538b57f3c6341e9c8e9970f18e4a481690a64974d7023bd33548b" dependencies = [ "anstream", "anstyle", @@ -797,14 +1326,14 @@ dependencies = [ [[package]] name = "clap_derive" -version = "4.6.1" +version = "4.6.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f2ce8604710f6733aa641a2b3731eaa1e8b3d9973d5e3565da11800813f997a9" +checksum = "d012d2b9d65aca7f18f4d9878a045bc17899bba951561ba5ec3c2ba1eed9a061" dependencies = [ "heck", "proc-macro2", "quote", - "syn", + "syn 3.0.3", ] [[package]] @@ -837,17 +1366,18 @@ version = "0.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0fa961b519f0b462e3a3b4a34b64d119eeaca1d59af726fe450bbba07a9fc0a1" dependencies = [ - "thiserror 2.0.18", + "thiserror 2.0.19", ] [[package]] name = "codespan-reporting" -version = "0.11.1" +version = "0.12.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3538270d33cc669650c4b093848450d380def10c331d38c768e34cac80576e6e" +checksum = "fe6d2e5af09e8c8ad56c969f2157a3d4238cebc7c55f0a517728c38f7b200f81" dependencies = [ + "serde", "termcolor", - "unicode-width 0.1.14", + "unicode-width", ] [[package]] @@ -856,6 +1386,12 @@ version = "0.3.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "2ec7c5eb7a16992b1904d76c517d170ab353b0e0b3d5a0c81a8a0cd1037893cf" +[[package]] +name = "color_quant" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3d7b894f5411737b7867f4827955924d7c254fc9f4d91a6aad6b097804b1018b" + [[package]] name = "colorchoice" version = "1.0.5" @@ -909,6 +1445,16 @@ dependencies = [ "libc", ] +[[package]] +name = "core-foundation" +version = "0.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b2a6cd9ae233e7f62ba4e9353e81a88df7fc8a5987b8d445b4d90c879bd156f6" +dependencies = [ + "core-foundation-sys", + "libc", +] + [[package]] name = "core-foundation-sys" version = "0.8.7" @@ -922,9 +1468,9 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c07782be35f9e1140080c6b96f0d44b739e2278479f64e02fdab4e32dfd8b081" dependencies = [ "bitflags 1.3.2", - "core-foundation", - "core-graphics-types", - "foreign-types", + "core-foundation 0.9.4", + "core-graphics-types 0.1.3", + "foreign-types 0.5.0", "libc", ] @@ -935,10 +1481,73 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "45390e6114f68f718cc7a830514a96f903cccd70d02a8f6d9f643ac4ba45afaf" dependencies = [ "bitflags 1.3.2", - "core-foundation", + "core-foundation 0.9.4", "libc", ] +[[package]] +name = "core-graphics-types" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3d44a101f213f6c4cdc1853d4b78aef6db6bdfa3468798cc1d9912f4735013eb" +dependencies = [ + "bitflags 2.13.1", + "core-foundation 0.10.1", + "libc", +] + +[[package]] +name = "core_maths" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "77745e017f5edba1a9c1d854f6f3a52dac8a12dd5af5d2f54aecf61e43d80d30" +dependencies = [ + "libm", +] + +[[package]] +name = "coreaudio-rs" +version = "0.11.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "321077172d79c662f64f5071a03120748d5bb652f5231570141be24cfcd2bace" +dependencies = [ + "bitflags 1.3.2", + "core-foundation-sys", + "coreaudio-sys", +] + +[[package]] +name = "coreaudio-sys" +version = "0.2.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b9b4739a805a62757a83e5654fa3faabec0442666b263bb2287d5a8185bfd953" +dependencies = [ + "bindgen", +] + +[[package]] +name = "cpal" +version = "0.15.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "873dab07c8f743075e57f524c583985fbaf745602acbe916a01539364369a779" +dependencies = [ + "alsa", + "core-foundation-sys", + "coreaudio-rs", + "dasp_sample", + "jni 0.21.1", + "js-sys", + "libc", + "mach2", + "ndk 0.8.0", + "ndk-context", + "oboe", + "wasm-bindgen", + "wasm-bindgen-futures", + "web-sys", + "windows 0.54.0", +] + [[package]] name = "cpufeatures" version = "0.2.17" @@ -966,6 +1575,15 @@ dependencies = [ "cfg-if", ] +[[package]] +name = "crdt" +version = "0.1.0" +source = "git+https://git.tawasuyu.net/tawasuyu/tawasuyu.git?tag=v0.2.0#a446bb3a73a0e022158cc0ec7deb65173859644b" +dependencies = [ + "postcard", + "serde", +] + [[package]] name = "critical-section" version = "1.2.0" @@ -974,27 +1592,27 @@ checksum = "790eea4361631c5e7d22598ecd5723ff611904e3344ce8720784c93e3d83d40b" [[package]] name = "crossbeam-channel" -version = "0.5.15" +version = "0.5.16" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "82b8f8f868b36967f9606790d1903570de9ceaf870a7bf9fbbd3016d636a2cb2" +checksum = "d85363c37faeca707aef026efa9f3b34d077bce547e48f770770625c6013679e" dependencies = [ "crossbeam-utils", ] [[package]] name = "crossbeam-epoch" -version = "0.9.18" +version = "0.9.20" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5b82ac4a3c2ca9c3460964f020e1402edd5753411d7737aa39c3714ad1b5420e" +checksum = "2d6914041f254d6e9176c01941b21115dcfb7089e55135a35411081bd106ef3f" dependencies = [ "crossbeam-utils", ] [[package]] name = "crossbeam-utils" -version = "0.8.21" +version = "0.8.22" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d0a5c400df2834b80a4c3327b3aad3a4c4cd4de0629063962b03235697506a28" +checksum = "61803da095bee82a81bb1a452ecc25d3b2f1416d1897eb86430c6159ef717c17" [[package]] name = "crunchy" @@ -1064,9 +1682,15 @@ checksum = "f46882e17999c6cc590af592290432be3bce0428cb0d5f8b6715e4dc7b383eb3" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 2.0.119", ] +[[package]] +name = "dasp_sample" +version = "0.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0c87e182de0887fd5361989c677c4e8f5000cd9491d6d563161a8f3a5519fc7f" + [[package]] name = "data-encoding" version = "2.11.0" @@ -1090,9 +1714,15 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ccc2776f0c61eca1ca32528f85548abd1a4be8fb53d1b21c013e4f18da1e7090" dependencies = [ "data-encoding", - "syn", + "syn 2.0.119", ] +[[package]] +name = "data-url" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "be1e0bca6c3637f992fc1cc7cbc52a78c1ef6db076dbf1059c4323d6a2048376" + [[package]] name = "delegate" version = "0.13.5" @@ -1101,7 +1731,7 @@ checksum = "780eb241654bf097afb00fc5f054a09b687dad862e485fdcf8399bb056565370" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 2.0.119", ] [[package]] @@ -1123,7 +1753,7 @@ checksum = "07da5016415d5a3c4dd39b11ed26f915f52fc4e0dc197d87908bc916e51bc1a6" dependencies = [ "asn1-rs", "displaydoc", - "nom", + "nom 7.1.3", "num-bigint", "num-traits", "rusticata-macros", @@ -1134,9 +1764,6 @@ name = "deranged" version = "0.5.8" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7cd812cc2bc1d69d4764bd80df88b4317eaef9e773c75226407d9bc0876b211c" -dependencies = [ - "powerfmt", -] [[package]] name = "digest" @@ -1183,7 +1810,7 @@ version = "0.3.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1e0e367e4e7da84520dedcac1901e4da967309406d1e51017ae1abfb97adbd38" dependencies = [ - "bitflags 2.12.1", + "bitflags 2.13.1", "objc2 0.6.4", ] @@ -1195,7 +1822,7 @@ checksum = "1ac70aa55017e108007fbaf5aa0f54b021c98f92ff8af59d42eda9da96e3dd4f" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 2.0.119", ] [[package]] @@ -1264,6 +1891,12 @@ dependencies = [ "signature", ] +[[package]] +name = "ed25519-compact" +version = "2.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5c24599140dc39d7a81e4476e7573d41bbc18e07c803900298e522a5fbcfbfb6" + [[package]] name = "ed25519-dalek" version = "2.2.0" @@ -1281,9 +1914,9 @@ dependencies = [ [[package]] name = "either" -version = "1.16.0" +version = "1.17.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "91622ff5e7162018101f2fea40d6ebf4a78bbe5a49736a2020649edf9693679e" +checksum = "9e5e8f6c15a24b9a3ee5efec809ccd006d3b30e8b3bb63c39af737c7f87daa1d" [[package]] name = "elliptic-curve" @@ -1318,6 +1951,21 @@ version = "0.6.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "edd0f118536f44f5ccd48bcb8b111bdc3de888b58c74639dfb034a357d0f206d" +[[package]] +name = "encoding_rs" +version = "0.8.35" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "75030f3c4f45dafd7586dd6780965a8c7e8e285a5ecb86713e63a79c5b2766f3" +dependencies = [ + "cfg-if", +] + +[[package]] +name = "endi" +version = "1.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "66b7e2430c6dff6a955451e2cfc438f09cea1965a9d6f87f7e3b90decc014099" + [[package]] name = "enum-as-inner" version = "0.6.1" @@ -1327,7 +1975,7 @@ dependencies = [ "heck", "proc-macro2", "quote", - "syn", + "syn 2.0.119", ] [[package]] @@ -1339,7 +1987,28 @@ dependencies = [ "once_cell", "proc-macro2", "quote", - "syn", + "syn 2.0.119", +] + +[[package]] +name = "enumflags2" +version = "0.7.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1027f7680c853e056ebcec683615fb6fbbc07dbaa13b4d5d9442b146ded4ecef" +dependencies = [ + "enumflags2_derive", + "serde", +] + +[[package]] +name = "enumflags2_derive" +version = "0.7.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "67c78a4d8fdf9953a5c9d458f9efe940fd97a0cab0941c075a813ac594733827" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", ] [[package]] @@ -1374,10 +2043,43 @@ dependencies = [ ] [[package]] -name = "fastrand" -version = "2.4.1" +name = "event-listener" +version = "5.4.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9f1f227452a390804cdb637b74a86990f2a7d7ba4b7d5693aac9b4dd6defd8d6" +checksum = "e13b66accf52311f30a0db42147dadea9850cb48cd070028831ae5f5d4b856ab" +dependencies = [ + "concurrent-queue", + "parking", + "pin-project-lite", +] + +[[package]] +name = "event-listener-strategy" +version = "0.5.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8be9f3dfaaffdae2972880079a491a1a8bb7cbed0b8dd7a347f668b4150a3b93" +dependencies = [ + "event-listener", + "pin-project-lite", +] + +[[package]] +name = "fastbloom" +version = "0.17.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ef975e30683b2d965054bb0a836f8973857c4ebf6acf274fe46617cd285060d8" +dependencies = [ + "foldhash 0.2.0", + "libm", + "portable-atomic", + "siphasher", +] + +[[package]] +name = "fastrand" +version = "2.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "da7c62ceae207dd37ea5b845da6a0696c799f85e97da1ab5b7910be3c1c80223" [[package]] name = "fax" @@ -1437,6 +2139,12 @@ version = "0.1.9" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "5baebc0774151f905a1a2cc41989300b1e6fbb29aff0ceffa1064fdd3088d582" +[[package]] +name = "fixedbitset" +version = "0.5.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1d674e81391d1e1ab681a28d99df07927c6d4aa5b027d7da16ba32d1d21ecd99" + [[package]] name = "flate2" version = "1.1.9" @@ -1447,6 +2155,12 @@ dependencies = [ "miniz_oxide", ] +[[package]] +name = "float-cmp" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "98de4bbd547a563b716d8dfa9aad1cb19bfab00f4fa09a6a4ed21dbcf44ce9c4" + [[package]] name = "fluent-bundle" version = "0.15.3" @@ -1494,10 +2208,16 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d9c4f5dac5e15c24eb999c26181a6ca40b39fe946cbe4c263c7209467bc83af2" [[package]] -name = "font-types" -version = "0.9.0" +name = "foldhash" +version = "0.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "02a596f5713680923a2080d86de50fe472fb290693cf0f701187a1c8b36996b7" +checksum = "77ce24cb58228fbb8aa041425bb1050850ac19177686ea6e0f41a70416f56fdb" + +[[package]] +name = "font-types" +version = "0.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "39a654f404bbcbd48ea58c617c2993ee91d1cb63727a37bf2323a4edeed1b8c5" dependencies = [ "bytemuck", ] @@ -1512,36 +2232,77 @@ dependencies = [ ] [[package]] -name = "fontconfig-cache-parser" -version = "0.2.0" +name = "font-types" +version = "0.12.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a7f8afb20c8069fd676d27b214559a337cc619a605d25a87baa90b49a06f3b18" +checksum = "0a7299a780854a6d391be2ae1c8521c9368471b559dbfd6a8dbd9f407eaff100" dependencies = [ "bytemuck", - "thiserror 1.0.69", +] + +[[package]] +name = "fontconfig-parser" +version = "0.5.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bbc773e24e02d4ddd8395fd30dc147524273a83e54e0f312d986ea30de5f5646" +dependencies = [ + "roxmltree 0.20.0", +] + +[[package]] +name = "fontdb" +version = "0.23.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "457e789b3d1202543297a350643cf459f836cade38934e7a4cf6a39e7cde2905" +dependencies = [ + "fontconfig-parser", + "log", + "memmap2 0.9.11", + "slotmap", + "tinyvec", + "ttf-parser 0.25.1", +] + +[[package]] +name = "fontdue" +version = "0.9.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2e57e16b3fe8ff4364c0661fdaac543fb38b29ea9bc9c2f45612d90adf931d2b" +dependencies = [ + "hashbrown 0.15.5", + "ttf-parser 0.21.1", ] [[package]] name = "fontique" -version = "0.4.0" +version = "0.6.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "64763d1f274c8383333851435b6cdf071c31cfcdb39fd5860d20943205a007a7" +checksum = "ff3336bc0b87fe42305047263fa60d2eabd650d29cbe62fdeb2a66c7a0a595f9" dependencies = [ "bytemuck", - "fontconfig-cache-parser", "hashbrown 0.15.5", - "icu_locid", - "memmap2", + "icu_locale_core", + "linebender_resource_handle", + "memmap2 0.9.11", "objc2 0.6.4", "objc2-core-foundation", "objc2-core-text", "objc2-foundation 0.3.2", - "peniko", - "read-fonts 0.29.3", - "roxmltree", + "read-fonts 0.35.0", + "roxmltree 0.20.0", "smallvec", "windows 0.58.0", "windows-core 0.58.0", + "yeslogic-fontconfig-sys", +] + +[[package]] +name = "foreign-types" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f6f339eb8adc052cd2ca78910fda869aefa38d22d5cb648e6485e4d3fc06f3b1" +dependencies = [ + "foreign-types-shared 0.1.1", ] [[package]] @@ -1551,26 +2312,41 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d737d9aa519fb7b749cbc3b962edcf310a8dd1f4b67c91c4f83975dbdd17d965" dependencies = [ "foreign-types-macros", - "foreign-types-shared", + "foreign-types-shared 0.3.1", ] [[package]] name = "foreign-types-macros" -version = "0.2.3" +version = "0.2.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1a5c6c585bc94aaf2c7b51dd4c2ba22680844aba4c687be581871a6f518c5742" +checksum = "ea5190182e6915eb873ddbc16e23b711b6eb1f9c00a0d0a3a91b5f6228475225" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 3.0.3", ] +[[package]] +name = "foreign-types-shared" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "00b0228411908ca8685dba7fc2cdd70ec9990a6e753e89b6ac91a84c40fbaf4b" + [[package]] name = "foreign-types-shared" version = "0.3.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "aa9a19cbb55df58761df49b23516a86d432839add4af60fc256da840f66ed35b" +[[package]] +name = "fork-proof" +version = "0.1.0" +source = "git+https://git.tawasuyu.net/tawasuyu/tawasuyu.git?tag=v0.2.0#a446bb3a73a0e022158cc0ec7deb65173859644b" +dependencies = [ + "ed25519-compact", + "format", +] + [[package]] name = "form_urlencoded" version = "1.2.2" @@ -1580,6 +2356,17 @@ dependencies = [ "percent-encoding", ] +[[package]] +name = "format" +version = "0.1.0" +source = "git+https://git.tawasuyu.net/tawasuyu/tawasuyu.git?tag=v0.2.0#a446bb3a73a0e022158cc0ec7deb65173859644b" +dependencies = [ + "blake3", + "postcard", + "serde", + "serde-big-array", +] + [[package]] name = "fs2" version = "0.4.3" @@ -1622,9 +2409,9 @@ dependencies = [ [[package]] name = "futures" -version = "0.3.32" +version = "0.3.33" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8b147ee9d1f6d097cef9ce628cd2ee62288d963e16fb287bd9286455b241382d" +checksum = "a88cf1f829d945f548cf8fec32c61b1f202b6d93b45848602fc02af4b12ad218" dependencies = [ "futures-channel", "futures-core", @@ -1647,9 +2434,9 @@ dependencies = [ [[package]] name = "futures-channel" -version = "0.3.32" +version = "0.3.33" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "07bbe89c50d7a535e539b8c17bc0b49bdb77747034daa8087407d655f3f7cc1d" +checksum = "262590f4fe6afeb0bc83be1daa64e52657fe185690a958af7f3ad0e92085c5ae" dependencies = [ "futures-core", "futures-sink", @@ -1657,15 +2444,15 @@ dependencies = [ [[package]] name = "futures-core" -version = "0.3.32" +version = "0.3.33" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7e3450815272ef58cec6d564423f6e755e25379b217b0bc688e295ba24df6b1d" +checksum = "2cd50c473c80f6d7c3670a752354b8e569b1a7cbfdc0419ec88e5edad85e0dc7" [[package]] name = "futures-executor" -version = "0.3.32" +version = "0.3.33" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "baf29c38818342a3b26b5b923639e7b1f4a61fc5e76102d4b1981c6dc7a7579d" +checksum = "6754879cc9f2c66f88c6e5c35344bb0bdb0708b0352b1201815667c7eabc7458" dependencies = [ "futures-core", "futures-task", @@ -1685,9 +2472,9 @@ dependencies = [ [[package]] name = "futures-io" -version = "0.3.32" +version = "0.3.33" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "cecba35d7ad927e23624b22ad55235f2239cfa44fd10428eecbeba6d6a717718" +checksum = "4577ecaa3c4f96589d473f679a71b596316f6641bc350038b962a5daf0085d7a" [[package]] name = "futures-lite" @@ -1695,19 +2482,22 @@ version = "2.6.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f78e10609fe0e0b3f4157ffab1876319b5b0db102a2c60dc4626306dc46b44ad" dependencies = [ + "fastrand", "futures-core", + "futures-io", + "parking", "pin-project-lite", ] [[package]] name = "futures-macro" -version = "0.3.32" +version = "0.3.33" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e835b70203e41293343137df5c0664546da5745f82ec9b84d40be8336958447b" +checksum = "2d6d3cde68c518367be28956066ddfef33813991b77a55005a69dae04bf3b10b" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 2.0.119", ] [[package]] @@ -1723,15 +2513,15 @@ dependencies = [ [[package]] name = "futures-sink" -version = "0.3.32" +version = "0.3.33" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c39754e157331b013978ec91992bde1ac089843443c49cbc7f46150b0fad0893" +checksum = "e34418ac499d6305c2fb5ad0ed2f6ac998c5f8ca209b4510f7f94242c647e307" [[package]] name = "futures-task" -version = "0.3.32" +version = "0.3.33" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "037711b3d59c33004d3856fbdc83b99d4ff37a24768fa1be9ce3538a1cde4393" +checksum = "b231ed28831efb4a61a08580c4bc233ec56bc009f4cd8f52da2c3cb97df0c109" [[package]] name = "futures-timer" @@ -1741,9 +2531,9 @@ checksum = "af43fadb8a98512d547e37b4e92e0ced13e205c061b87b4623eff01d918d6968" [[package]] name = "futures-util" -version = "0.3.32" +version = "0.3.33" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "389ca41296e6190b48053de0321d02a77f32f8a5d2461dd38762c0593805c6d6" +checksum = "a77a90a256fce34da66415271e30f94ee91c57b04b8a2c042d9cf3220179deaa" dependencies = [ "futures-channel", "futures-core", @@ -1806,24 +2596,23 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "899def5c37c4fd7b2664648c28120ecec138e4d395b459e5ca34f9cce2dd77fd" dependencies = [ "cfg-if", - "js-sys", "libc", "r-efi 5.3.0", "wasip2", - "wasm-bindgen", ] [[package]] name = "getrandom" -version = "0.4.2" +version = "0.4.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0de51e6874e94e7bf76d726fc5d13ba782deca734ff60d5bb2fb2607c7406555" +checksum = "300e883d756b2e4ec94e02791f39b04b522276138852cfc41d9fb7e904106099" dependencies = [ "cfg-if", + "js-sys", "libc", "r-efi 6.0.0", - "wasip2", - "wasip3", + "rand_core 0.10.1", + "wasm-bindgen", ] [[package]] @@ -1836,6 +2625,16 @@ dependencies = [ "polyval", ] +[[package]] +name = "gif" +version = "0.14.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ee8cfcc411d9adbbaba82fb72661cc1bcca13e8bba98b364e62b2dba8f960159" +dependencies = [ + "color_quant", + "weezl", +] + [[package]] name = "gl_generator" version = "0.14.0" @@ -1847,6 +2646,12 @@ dependencies = [ "xml-rs", ] +[[package]] +name = "glob" +version = "0.3.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e4eba85ea1d0a966a983acd07deee566e67395d2d96b6fb39e62b5a833f1eb0b" + [[package]] name = "glow" version = "0.16.0" @@ -1870,21 +2675,21 @@ dependencies = [ [[package]] name = "gpu-alloc" -version = "0.6.0" +version = "0.6.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "fbcd2dba93594b227a1f57ee09b8b9da8892c34d55aa332e034a228d0fe6a171" +checksum = "45cf04b2726f02df5508c6de726acdc90cdf97ac771a9a0ffd8ba10a6e696bf9" dependencies = [ - "bitflags 2.12.1", + "bitflags 2.13.1", "gpu-alloc-types", ] [[package]] name = "gpu-alloc-types" -version = "0.3.0" +version = "0.3.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "98ff03b468aa837d70984d55f5d3f846f6ec31fe34bbb97c4f85219caeee1ca4" +checksum = "b2bbed164dd10ed526c2e4fe3e721ca4a71c61730e5aafac6844b417b3227058" dependencies = [ - "bitflags 2.12.1", + "bitflags 2.13.1", ] [[package]] @@ -1905,7 +2710,7 @@ version = "0.3.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b89c83349105e3732062a895becfc71a8f921bb71ecbbdd8ff99263e3b53a0ca" dependencies = [ - "bitflags 2.12.1", + "bitflags 2.13.1", "gpu-descriptor-types", "hashbrown 0.15.5", ] @@ -1916,9 +2721,14 @@ version = "0.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "fdf242682df893b86f33a73828fb09ca4b2d3bb6cc95249707fc684d27484b91" dependencies = [ - "bitflags 2.12.1", + "bitflags 2.13.1", ] +[[package]] +name = "grafo-nav" +version = "0.1.0" +source = "git+https://git.tawasuyu.net/tawasuyu/tawasuyu.git?tag=v0.2.0#a446bb3a73a0e022158cc0ec7deb65173859644b" + [[package]] name = "grid" version = "1.0.1" @@ -1948,9 +2758,9 @@ dependencies = [ [[package]] name = "h2" -version = "0.4.14" +version = "0.4.15" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "171fefbc92fe4a4de27e0698d6a5b392d6a0e333506bc49133760b3bcf948733" +checksum = "6cb093c84e8bd9b188d4c4a8cb6579fc016968d14c99882163cd3ff402a4f155" dependencies = [ "atomic-waker", "bytes", @@ -1973,9 +2783,23 @@ checksum = "6ea2d84b969582b4b1864a92dc5d27cd2b77b622a8d79306834f1be5ba20d84b" dependencies = [ "cfg-if", "crunchy", + "num-traits", "zerocopy", ] +[[package]] +name = "harfrust" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "92c020db12c71d8a12a3fe7607873cade3a01a6287e29d540c8723276221b9d8" +dependencies = [ + "bitflags 2.13.1", + "bytemuck", + "core_maths", + "read-fonts 0.35.0", + "smallvec", +] + [[package]] name = "hash32" version = "0.2.1" @@ -1993,7 +2817,16 @@ checksum = "9229cfe53dfd69f0609a49f65461bd93001ea1ef889cd5529dd176593f5338a1" dependencies = [ "allocator-api2", "equivalent", - "foldhash", + "foldhash 0.1.5", +] + +[[package]] +name = "hashbrown" +version = "0.16.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "841d1cc9bed7f9236f321df977030373f4a4163ae1a7dbfe1a51a2c1a51d9100" +dependencies = [ + "foldhash 0.2.0", ] [[package]] @@ -2071,10 +2904,10 @@ dependencies = [ "idna", "ipnet", "once_cell", - "rand 0.9.4", + "rand 0.9.5", "ring", "socket2 0.5.10", - "thiserror 2.0.18", + "thiserror 2.0.19", "tinyvec", "tokio", "tracing", @@ -2094,10 +2927,10 @@ dependencies = [ "moka", "once_cell", "parking_lot 0.12.5", - "rand 0.9.4", + "rand 0.9.5", "resolv-conf", "smallvec", - "thiserror 2.0.18", + "thiserror 2.0.19", "tokio", "tracing", ] @@ -2131,9 +2964,9 @@ dependencies = [ [[package]] name = "http" -version = "1.4.1" +version = "1.4.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8be7462df143984c4598a256ef469b251d7d7f9e271135073e78fc535414f3d0" +checksum = "6970f50e31d6fc17d3fa27329444bfa74e196cf62e95052a3f6fee181dba6425" dependencies = [ "bytes", "itoa", @@ -2141,9 +2974,9 @@ dependencies = [ [[package]] name = "http-body" -version = "1.0.1" +version = "1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1efedce1fb8e6913f23e0c92de8e62cd5b772a67e7b3946df930a62566c93184" +checksum = "ca2a8f2913ee65f60facd6a5905613afaa448497a0230cc41ce022d93290bc2c" dependencies = [ "bytes", "http", @@ -2151,9 +2984,9 @@ dependencies = [ [[package]] name = "http-body-util" -version = "0.1.3" +version = "0.1.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b021d93e26becf5dc7e1b75b1bed1fd93124b374ceb73f43d4d4eafec896a64a" +checksum = "e9f41fd6a08e4d4ec69df65976da761afd5ad5e58a9d4acb46bd1c953a9e3ff2" dependencies = [ "bytes", "futures-core", @@ -2169,10 +3002,16 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "6dbf3de79e51f3d586ab4cb9d5c3e2c14aa28ed23d180cf89b4df0454a69cc87" [[package]] -name = "hyper" -version = "1.10.1" +name = "httpdate" +version = "1.0.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "55281c53a1894c864990125767da440a4e630446785086f52523b20033b74498" +checksum = "df3b46402a9d5adb4c86a0cf463f42e19994e3ee891101b1841f30a545cb49a9" + +[[package]] +name = "hyper" +version = "1.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d22053281f852e11534f5198498373cbb59295120a20771d90f7ed1897490a72" dependencies = [ "atomic-waker", "bytes", @@ -2182,6 +3021,7 @@ dependencies = [ "http", "http-body", "httparse", + "httpdate", "itoa", "pin-project-lite", "smallvec", @@ -2189,24 +3029,61 @@ dependencies = [ "want", ] +[[package]] +name = "hyper-rustls" +version = "0.27.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "33ca68d021ef39cf6463ab54c1d0f5daf03377b70561305bb89a8f83aab66e0f" +dependencies = [ + "http", + "hyper", + "hyper-util", + "rustls", + "tokio", + "tokio-rustls", + "tower-service", + "webpki-roots 1.0.9", +] + +[[package]] +name = "hyper-tls" +version = "0.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "70206fc6890eaca9fde8a0bf71caa2ddfc9fe045ac9e5c70df101a7dbde866e0" +dependencies = [ + "bytes", + "http-body-util", + "hyper", + "hyper-util", + "native-tls", + "tokio", + "tokio-native-tls", + "tower-service", +] + [[package]] name = "hyper-util" version = "0.1.20" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "96547c2556ec9d12fb1578c4eaf448b04993e7fb79cbaad930a656880a6bdfa0" dependencies = [ + "base64 0.22.1", "bytes", "futures-channel", "futures-util", "http", "http-body", "hyper", + "ipnet", "libc", + "percent-encoding", "pin-project-lite", - "socket2 0.6.4", + "socket2 0.6.5", + "system-configuration", "tokio", "tower-service", "tracing", + "windows-registry", ] [[package]] @@ -2254,24 +3131,13 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "92219b62b3e2b4d88ac5119f8904c10f8f61bf7e95b640d25ba3075e6cac2c29" dependencies = [ "displaydoc", - "litemap 0.8.2", - "tinystr 0.8.3", - "writeable 0.6.3", + "litemap", + "serde", + "tinystr", + "writeable", "zerovec", ] -[[package]] -name = "icu_locid" -version = "1.5.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "13acbb8371917fc971be86fc8057c41a64b521c184808a698c02acc242dbf637" -dependencies = [ - "displaydoc", - "litemap 0.7.5", - "tinystr 0.7.6", - "writeable 0.5.5", -] - [[package]] name = "icu_normalizer" version = "2.2.0" @@ -2320,19 +3186,13 @@ checksum = "139c4cf31c8b5f33d7e199446eff9c1e02decfc2f0eec2c8d71f65befa45b421" dependencies = [ "displaydoc", "icu_locale_core", - "writeable 0.6.3", + "writeable", "yoke", "zerofrom", "zerotrie", "zerovec", ] -[[package]] -name = "id-arena" -version = "2.3.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3d3067d79b975e8844ca9eb072e16b31c3c1c36928edf9c6789548c524d0d954" - [[package]] name = "idna" version = "1.1.0" @@ -2371,7 +3231,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "71c02a5161c313f0cbdbadc511611893584a10a7b6153cb554bdf83ddce99ec2" dependencies = [ "async-io", - "core-foundation", + "core-foundation 0.9.4", "fnv", "futures", "if-addrs", @@ -2402,7 +3262,7 @@ dependencies = [ "hyper", "hyper-util", "log", - "rand 0.9.4", + "rand 0.9.5", "tokio", "url", "xmltree", @@ -2416,12 +3276,34 @@ checksum = "85ab80394333c02fe689eaf900ab500fbd0c2213da414687ebf995a65d5a6104" dependencies = [ "bytemuck", "byteorder-lite", + "color_quant", + "gif", + "image-webp", "moxcms", "num-traits", "png 0.18.1", + "qoi", "tiff", + "zune-core", + "zune-jpeg", ] +[[package]] +name = "image-webp" +version = "0.2.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "525e9ff3e1a4be2fbea1fdf0e98686a6d98b4d8f937e1bf7402245af1909e8c3" +dependencies = [ + "byteorder-lite", + "quick-error", +] + +[[package]] +name = "imagesize" +version = "0.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "09e54e57b4c48b40f7aec75635392b12b3421fa26fe8b4332e63138ed278459c" + [[package]] name = "indexmap" version = "2.14.0" @@ -2430,8 +3312,16 @@ checksum = "d466e9454f08e4a911e14806c24e16fba1b4c121d1ea474396f396069cf949d9" dependencies = [ "equivalent", "hashbrown 0.17.1", +] + +[[package]] +name = "iniy-core" +version = "0.1.0" +source = "git+https://git.tawasuyu.net/tawasuyu/tawasuyu.git?tag=v0.2.0#a446bb3a73a0e022158cc0ec7deb65173859644b" +dependencies = [ "serde", - "serde_core", + "thiserror 2.0.19", + "ulid", ] [[package]] @@ -2447,9 +3337,9 @@ dependencies = [ [[package]] name = "inotify-sys" -version = "0.1.5" +version = "0.1.8" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e05c02b5e89bff3b946cedeca278abc628fe811e604f027c45a8aa3cf793d0eb" +checksum = "c033f80b2c113cdf91ab7a33faa9cbc014726dcad99880c8609af2a370edf37d" dependencies = [ "libc", ] @@ -2526,7 +3416,7 @@ version = "0.3.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "4d40460c0ce33d6ce4b0630ad68ff63d6661961c48b6dba35e5a4d81cfb48222" dependencies = [ - "socket2 0.6.4", + "socket2 0.6.5", "widestring", "windows-registry", "windows-result 0.4.1", @@ -2545,6 +3435,15 @@ version = "1.70.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a6cb138bb79a146c1bd460005623e142ef0181e3d0219cb493e02f7d08a35695" +[[package]] +name = "itertools" +version = "0.13.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "413ee7dfc52ee1a4949ceeb7dbc8a33f2d6c088194d9f922fb8318faf1f01186" +dependencies = [ + "either", +] + [[package]] name = "itertools" version = "0.14.0" @@ -2560,6 +3459,22 @@ version = "1.0.18" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682" +[[package]] +name = "jni" +version = "0.21.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1a87aa2bb7d2af34197c04845522473242e1aa17c12f4935d5856491a7fb8c97" +dependencies = [ + "cesu8", + "cfg-if", + "combine", + "jni-sys 0.3.1", + "log", + "thiserror 1.0.69", + "walkdir", + "windows-sys 0.45.0", +] + [[package]] name = "jni" version = "0.22.4" @@ -2572,7 +3487,7 @@ dependencies = [ "jni-sys 0.4.1", "log", "simd_cesu8", - "thiserror 2.0.18", + "thiserror 2.0.19", "walkdir", "windows-link 0.2.1", ] @@ -2587,7 +3502,7 @@ dependencies = [ "quote", "rustc_version", "simd_cesu8", - "syn", + "syn 2.0.119", ] [[package]] @@ -2615,28 +3530,27 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "38c0b942f458fe50cdac086d2f946512305e5631e720728f2a61aabcd47a6264" dependencies = [ "quote", - "syn", + "syn 2.0.119", ] [[package]] name = "jobserver" -version = "0.1.34" +version = "0.1.35" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9afb3de4395d6b3e67a780b6de64b51c978ecf11cb9a462c66be7d4ca9039d33" +checksum = "1c00acbd29eabad4a2392fa0e921c874934dbbf4194312ad20f04a0ed67a3cb3" dependencies = [ - "getrandom 0.3.4", + "getrandom 0.4.3", "libc", ] [[package]] name = "js-sys" -version = "0.3.99" +version = "0.3.103" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "142bc4740e452c1e57ade0cbc129f139c9093e354346f0872ef985f4f5cf5f11" +checksum = "53b44bfcdb3f8d5837a46dae1ca9660a837176eee74a28b229bc626816589102" dependencies = [ "cfg-if", "futures-util", - "once_cell", "wasm-bindgen", ] @@ -2657,6 +3571,23 @@ version = "3.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e2db585e1d738fc771bf08a151420d3ed193d9d895a36df7f6f8a9456b911ddc" +[[package]] +name = "kikin-core" +version = "0.1.0" +source = "git+https://git.tawasuyu.net/tawasuyu/tawasuyu.git?tag=v0.2.0#a446bb3a73a0e022158cc0ec7deb65173859644b" +dependencies = [ + "format", +] + +[[package]] +name = "kikin-wasmi" +version = "0.1.0" +source = "git+https://git.tawasuyu.net/tawasuyu/tawasuyu.git?tag=v0.2.0#a446bb3a73a0e022158cc0ec7deb65173859644b" +dependencies = [ + "kikin-core", + "wasmi", +] + [[package]] name = "kqueue" version = "1.2.0" @@ -2673,18 +3604,19 @@ version = "1.1.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "07293a4e297ac234359b510362495713f75ea345d5307140414f20c69ffeb087" dependencies = [ - "bitflags 2.12.1", + "bitflags 2.13.1", "libc", ] [[package]] name = "kurbo" -version = "0.11.3" +version = "0.13.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c62026ae44756f8a599ba21140f350303d4f08dcdcc71b5ad9c9bb8128c13c62" +checksum = "4b60dfc32f652b926df6192e55525b16d186c69d47876c3ead4da5cc9f8450e2" dependencies = [ "arrayvec", "euclid", + "polycool", "smallvec", ] @@ -2705,9 +3637,9 @@ checksum = "09edd9e8b54e49e587e4f6295a7d29c3ea94d469cb40ab8ca70b288248a81db2" [[package]] name = "libc" -version = "0.2.186" +version = "0.2.189" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "68ab91017fe16c622486840e4c83c9a37afeff978bd239b5293d61ece587de66" +checksum = "3eaf3ede3fee6db1a4c2ee091bf8a8b4dccdc6d17f656fb07896ee72867612f2" [[package]] name = "libloading" @@ -2757,7 +3689,7 @@ dependencies = [ "multiaddr", "pin-project", "rw-stream-sink", - "thiserror 2.0.18", + "thiserror 2.0.19", ] [[package]] @@ -2789,9 +3721,9 @@ dependencies = [ "libp2p-swarm", "quick-protobuf", "quick-protobuf-codec", - "rand 0.8.6", + "rand 0.8.7", "rand_core 0.6.4", - "thiserror 2.0.18", + "thiserror 2.0.19", "tracing", "web-time", ] @@ -2824,9 +3756,9 @@ dependencies = [ "parking_lot 0.12.5", "pin-project", "quick-protobuf", - "rand 0.8.6", + "rand 0.8.7", "rw-stream-sink", - "thiserror 2.0.18", + "thiserror 2.0.19", "tracing", "unsigned-varint 0.8.0", "web-time", @@ -2849,7 +3781,7 @@ dependencies = [ "libp2p-swarm", "quick-protobuf", "quick-protobuf-codec", - "thiserror 2.0.18", + "thiserror 2.0.19", "tracing", "web-time", ] @@ -2887,7 +3819,7 @@ dependencies = [ "quick-protobuf", "quick-protobuf-codec", "smallvec", - "thiserror 2.0.18", + "thiserror 2.0.19", "tracing", ] @@ -2902,9 +3834,9 @@ dependencies = [ "hkdf", "multihash", "prost", - "rand 0.8.6", + "rand 0.8.7", "sha2", - "thiserror 2.0.18", + "thiserror 2.0.19", "tracing", "zeroize", ] @@ -2927,10 +3859,10 @@ dependencies = [ "libp2p-swarm", "quick-protobuf", "quick-protobuf-codec", - "rand 0.8.6", + "rand 0.8.7", "sha2", "smallvec", - "thiserror 2.0.18", + "thiserror 2.0.19", "tracing", "uint", "web-time", @@ -2948,7 +3880,7 @@ dependencies = [ "libp2p-core", "libp2p-identity", "libp2p-swarm", - "rand 0.8.6", + "rand 0.8.7", "smallvec", "socket2 0.5.10", "tokio", @@ -2988,10 +3920,10 @@ dependencies = [ "multiaddr", "multihash", "quick-protobuf", - "rand 0.8.6", + "rand 0.8.7", "snow", "static_assertions", - "thiserror 2.0.18", + "thiserror 2.0.19", "tracing", "x25519-dalek", "zeroize", @@ -2999,9 +3931,9 @@ dependencies = [ [[package]] name = "libp2p-quic" -version = "0.13.0" +version = "0.13.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8dc448b2de9f4745784e3751fe8bc6c473d01b8317edd5ababcb0dec803d843f" +checksum = "9dcc597d70bf7f6f30cbe07081802c836184e48416e89e9ce73a0ba2c56a319e" dependencies = [ "futures", "futures-timer", @@ -3010,11 +3942,12 @@ dependencies = [ "libp2p-identity", "libp2p-tls", "quinn", - "rand 0.8.6", + "quinn-proto", + "rand 0.8.7", "ring", "rustls", "socket2 0.5.10", - "thiserror 2.0.18", + "thiserror 2.0.19", "tokio", "tracing", ] @@ -3036,9 +3969,9 @@ dependencies = [ "libp2p-swarm", "quick-protobuf", "quick-protobuf-codec", - "rand 0.8.6", + "rand 0.8.7", "static_assertions", - "thiserror 2.0.18", + "thiserror 2.0.19", "tracing", "web-time", ] @@ -3055,7 +3988,7 @@ dependencies = [ "libp2p-core", "libp2p-identity", "libp2p-swarm", - "rand 0.8.6", + "rand 0.8.7", "smallvec", "tracing", ] @@ -3070,7 +4003,7 @@ dependencies = [ "libp2p-core", "libp2p-identity", "libp2p-swarm", - "rand 0.8.6", + "rand 0.8.7", "tracing", ] @@ -3089,7 +4022,7 @@ dependencies = [ "libp2p-identity", "libp2p-swarm-derive", "multistream-select", - "rand 0.8.6", + "rand 0.8.7", "smallvec", "tokio", "tracing", @@ -3104,7 +4037,7 @@ checksum = "dd297cf53f0cb3dee4d2620bb319ae47ef27c702684309f682bdb7e55a18ae9c" dependencies = [ "heck", "quote", - "syn", + "syn 2.0.119", ] [[package]] @@ -3118,7 +4051,7 @@ dependencies = [ "if-watch", "libc", "libp2p-core", - "socket2 0.6.4", + "socket2 0.6.5", "tokio", "tracing", ] @@ -3137,7 +4070,7 @@ dependencies = [ "ring", "rustls", "rustls-webpki", - "thiserror 2.0.18", + "thiserror 2.0.19", "x509-parser", "yasna", ] @@ -3166,7 +4099,7 @@ dependencies = [ "either", "futures", "libp2p-core", - "thiserror 2.0.18", + "thiserror 2.0.19", "tracing", "yamux 0.12.1", "yamux 0.13.10", @@ -3174,14 +4107,14 @@ dependencies = [ [[package]] name = "libredox" -version = "0.1.17" +version = "0.1.18" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f02ab6bace2054fb888a3c16f990117b579d14a3088e472d63c6011fa185c9d3" +checksum = "c943259e342f1e06ff2da7a83eabdfe7f92ce10262688dbf1895ff0b3e6e4652" dependencies = [ - "bitflags 2.12.1", + "bitflags 2.13.1", "libc", "plain", - "redox_syscall 0.8.1", + "redox_syscall 0.9.0", ] [[package]] @@ -3202,12 +4135,6 @@ version = "0.12.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "32a66949e030da00e8c7d4434b251670a91556f4144941d37452769c25d58a53" -[[package]] -name = "litemap" -version = "0.7.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "23fb14cb19457329c82206317a5663005a4d404783dc74f4252769b0d5f42856" - [[package]] name = "litemap" version = "0.8.2" @@ -3220,10 +4147,19 @@ version = "1.0.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "11d3d7f243d5c5a8b9bb5d6dd2b1602c0cb0b9db1621bafc7ed66e35ff9fe092" +[[package]] +name = "llimphi-clipboard" +version = "0.1.0" +source = "git+https://git.tawasuyu.net/tawasuyu/tawasuyu.git?tag=v0.2.0#a446bb3a73a0e022158cc0ec7deb65173859644b" +dependencies = [ + "arboard", + "llimphi-widget-text-editor", +] + [[package]] name = "llimphi-compositor" version = "0.1.0" -source = "git+https://gitea.tawasuyu.net/sergio/gioser.git?tag=v0.1.0#692b40336c1e9cb12ab561764d6943cb65905ab3" +source = "git+https://git.tawasuyu.net/tawasuyu/tawasuyu.git?tag=v0.2.0#a446bb3a73a0e022158cc0ec7deb65173859644b" dependencies = [ "llimphi-layout", "llimphi-text", @@ -3234,7 +4170,7 @@ dependencies = [ [[package]] name = "llimphi-hal" version = "0.1.0" -source = "git+https://gitea.tawasuyu.net/sergio/gioser.git?tag=v0.1.0#692b40336c1e9cb12ab561764d6943cb65905ab3" +source = "git+https://git.tawasuyu.net/tawasuyu/tawasuyu.git?tag=v0.2.0#a446bb3a73a0e022158cc0ec7deb65173859644b" dependencies = [ "pollster", "raw-window-handle", @@ -3245,15 +4181,36 @@ dependencies = [ [[package]] name = "llimphi-icons" version = "0.1.0" -source = "git+https://gitea.tawasuyu.net/sergio/gioser.git?tag=v0.1.0#692b40336c1e9cb12ab561764d6943cb65905ab3" +source = "git+https://git.tawasuyu.net/tawasuyu/tawasuyu.git?tag=v0.2.0#a446bb3a73a0e022158cc0ec7deb65173859644b" dependencies = [ "llimphi-ui", ] +[[package]] +name = "llimphi-image" +version = "0.1.0" +source = "git+https://git.tawasuyu.net/tawasuyu/tawasuyu.git?tag=v0.2.0#a446bb3a73a0e022158cc0ec7deb65173859644b" +dependencies = [ + "image", + "llimphi-raster", +] + +[[package]] +name = "llimphi-layer" +version = "0.1.0" +source = "git+https://git.tawasuyu.net/tawasuyu/tawasuyu.git?tag=v0.2.0#a446bb3a73a0e022158cc0ec7deb65173859644b" +dependencies = [ + "llimphi-ui", + "pollster", + "raw-window-handle", + "smithay-client-toolkit", + "wayland-client", +] + [[package]] name = "llimphi-layout" version = "0.1.0" -source = "git+https://gitea.tawasuyu.net/sergio/gioser.git?tag=v0.1.0#692b40336c1e9cb12ab561764d6943cb65905ab3" +source = "git+https://git.tawasuyu.net/tawasuyu/tawasuyu.git?tag=v0.2.0#a446bb3a73a0e022158cc0ec7deb65173859644b" dependencies = [ "taffy", ] @@ -3261,7 +4218,7 @@ dependencies = [ [[package]] name = "llimphi-motion" version = "0.1.0" -source = "git+https://gitea.tawasuyu.net/sergio/gioser.git?tag=v0.1.0#692b40336c1e9cb12ab561764d6943cb65905ab3" +source = "git+https://git.tawasuyu.net/tawasuyu/tawasuyu.git?tag=v0.2.0#a446bb3a73a0e022158cc0ec7deb65173859644b" dependencies = [ "llimphi-theme", "llimphi-ui", @@ -3270,17 +4227,36 @@ dependencies = [ [[package]] name = "llimphi-raster" version = "0.1.0" -source = "git+https://gitea.tawasuyu.net/sergio/gioser.git?tag=v0.1.0#692b40336c1e9cb12ab561764d6943cb65905ab3" +source = "git+https://git.tawasuyu.net/tawasuyu/tawasuyu.git?tag=v0.2.0#a446bb3a73a0e022158cc0ec7deb65173859644b" dependencies = [ "llimphi-hal", "pollster", "vello", ] +[[package]] +name = "llimphi-svg" +version = "0.1.0" +source = "git+https://git.tawasuyu.net/tawasuyu/tawasuyu.git?tag=v0.2.0#a446bb3a73a0e022158cc0ec7deb65173859644b" +dependencies = [ + "llimphi-ui", + "vello_svg", +] + +[[package]] +name = "llimphi-term-graphics" +version = "0.1.0" +source = "git+https://git.tawasuyu.net/tawasuyu/tawasuyu.git?tag=v0.2.0#a446bb3a73a0e022158cc0ec7deb65173859644b" +dependencies = [ + "base64 0.22.1", + "flate2", + "image", +] + [[package]] name = "llimphi-text" version = "0.1.0" -source = "git+https://gitea.tawasuyu.net/sergio/gioser.git?tag=v0.1.0#692b40336c1e9cb12ab561764d6943cb65905ab3" +source = "git+https://git.tawasuyu.net/tawasuyu/tawasuyu.git?tag=v0.2.0#a446bb3a73a0e022158cc0ec7deb65173859644b" dependencies = [ "parley", "vello", @@ -3289,7 +4265,7 @@ dependencies = [ [[package]] name = "llimphi-theme" version = "0.1.0" -source = "git+https://gitea.tawasuyu.net/sergio/gioser.git?tag=v0.1.0#692b40336c1e9cb12ab561764d6943cb65905ab3" +source = "git+https://git.tawasuyu.net/tawasuyu/tawasuyu.git?tag=v0.2.0#a446bb3a73a0e022158cc0ec7deb65173859644b" dependencies = [ "llimphi-raster", ] @@ -3297,20 +4273,33 @@ dependencies = [ [[package]] name = "llimphi-ui" version = "0.1.0" -source = "git+https://gitea.tawasuyu.net/sergio/gioser.git?tag=v0.1.0#692b40336c1e9cb12ab561764d6943cb65905ab3" +source = "git+https://git.tawasuyu.net/tawasuyu/tawasuyu.git?tag=v0.2.0#a446bb3a73a0e022158cc0ec7deb65173859644b" dependencies = [ + "accesskit", + "accesskit_winit", + "arboard", "llimphi-compositor", "llimphi-hal", "llimphi-layout", "llimphi-raster", "llimphi-text", + "log", "pollster", + "uuid", +] + +[[package]] +name = "llimphi-widget-badge" +version = "0.1.0" +source = "git+https://git.tawasuyu.net/tawasuyu/tawasuyu.git?tag=v0.2.0#a446bb3a73a0e022158cc0ec7deb65173859644b" +dependencies = [ + "llimphi-ui", ] [[package]] name = "llimphi-widget-button" version = "0.1.0" -source = "git+https://gitea.tawasuyu.net/sergio/gioser.git?tag=v0.1.0#692b40336c1e9cb12ab561764d6943cb65905ab3" +source = "git+https://git.tawasuyu.net/tawasuyu/tawasuyu.git?tag=v0.2.0#a446bb3a73a0e022158cc0ec7deb65173859644b" dependencies = [ "llimphi-theme", "llimphi-ui", @@ -3319,7 +4308,7 @@ dependencies = [ [[package]] name = "llimphi-widget-card" version = "0.1.0" -source = "git+https://gitea.tawasuyu.net/sergio/gioser.git?tag=v0.1.0#692b40336c1e9cb12ab561764d6943cb65905ab3" +source = "git+https://git.tawasuyu.net/tawasuyu/tawasuyu.git?tag=v0.2.0#a446bb3a73a0e022158cc0ec7deb65173859644b" dependencies = [ "llimphi-theme", "llimphi-ui", @@ -3329,17 +4318,38 @@ dependencies = [ [[package]] name = "llimphi-widget-context-menu" version = "0.1.0" -source = "git+https://gitea.tawasuyu.net/sergio/gioser.git?tag=v0.1.0#692b40336c1e9cb12ab561764d6943cb65905ab3" +source = "git+https://git.tawasuyu.net/tawasuyu/tawasuyu.git?tag=v0.2.0#a446bb3a73a0e022158cc0ec7deb65173859644b" dependencies = [ + "llimphi-icons", "llimphi-theme", "llimphi-ui", "llimphi-widget-panel", ] +[[package]] +name = "llimphi-widget-dock-rail" +version = "0.1.0" +source = "git+https://git.tawasuyu.net/tawasuyu/tawasuyu.git?tag=v0.2.0#a446bb3a73a0e022158cc0ec7deb65173859644b" +dependencies = [ + "llimphi-theme", + "llimphi-ui", + "llimphi-widget-badge", +] + +[[package]] +name = "llimphi-widget-empty" +version = "0.1.0" +source = "git+https://git.tawasuyu.net/tawasuyu/tawasuyu.git?tag=v0.2.0#a446bb3a73a0e022158cc0ec7deb65173859644b" +dependencies = [ + "llimphi-icons", + "llimphi-theme", + "llimphi-ui", +] + [[package]] name = "llimphi-widget-menubar" version = "0.1.0" -source = "git+https://gitea.tawasuyu.net/sergio/gioser.git?tag=v0.1.0#692b40336c1e9cb12ab561764d6943cb65905ab3" +source = "git+https://git.tawasuyu.net/tawasuyu/tawasuyu.git?tag=v0.2.0#a446bb3a73a0e022158cc0ec7deb65173859644b" dependencies = [ "app-bus", "llimphi-theme", @@ -3348,10 +4358,79 @@ dependencies = [ "llimphi-widget-context-menu", ] +[[package]] +name = "llimphi-widget-modal" +version = "0.1.0" +source = "git+https://git.tawasuyu.net/tawasuyu/tawasuyu.git?tag=v0.2.0#a446bb3a73a0e022158cc0ec7deb65173859644b" +dependencies = [ + "llimphi-theme", + "llimphi-ui", + "llimphi-widget-panel", +] + [[package]] name = "llimphi-widget-panel" version = "0.1.0" -source = "git+https://gitea.tawasuyu.net/sergio/gioser.git?tag=v0.1.0#692b40336c1e9cb12ab561764d6943cb65905ab3" +source = "git+https://git.tawasuyu.net/tawasuyu/tawasuyu.git?tag=v0.2.0#a446bb3a73a0e022158cc0ec7deb65173859644b" +dependencies = [ + "llimphi-theme", + "llimphi-ui", +] + +[[package]] +name = "llimphi-widget-panes" +version = "0.1.0" +source = "git+https://git.tawasuyu.net/tawasuyu/tawasuyu.git?tag=v0.2.0#a446bb3a73a0e022158cc0ec7deb65173859644b" +dependencies = [ + "llimphi-theme", + "llimphi-ui", +] + +[[package]] +name = "llimphi-widget-rag-sidebar" +version = "0.1.0" +source = "git+https://git.tawasuyu.net/tawasuyu/tawasuyu.git?tag=v0.2.0#a446bb3a73a0e022158cc0ec7deb65173859644b" +dependencies = [ + "llimphi-icons", + "llimphi-theme", + "llimphi-ui", + "llimphi-widget-dock-rail", + "llimphi-widget-segmented", +] + +[[package]] +name = "llimphi-widget-scroll" +version = "0.1.0" +source = "git+https://git.tawasuyu.net/tawasuyu/tawasuyu.git?tag=v0.2.0#a446bb3a73a0e022158cc0ec7deb65173859644b" +dependencies = [ + "llimphi-theme", + "llimphi-ui", +] + +[[package]] +name = "llimphi-widget-segmented" +version = "0.1.0" +source = "git+https://git.tawasuyu.net/tawasuyu/tawasuyu.git?tag=v0.2.0#a446bb3a73a0e022158cc0ec7deb65173859644b" +dependencies = [ + "llimphi-theme", + "llimphi-ui", +] + +[[package]] +name = "llimphi-widget-select" +version = "0.1.0" +source = "git+https://git.tawasuyu.net/tawasuyu/tawasuyu.git?tag=v0.2.0#a446bb3a73a0e022158cc0ec7deb65173859644b" +dependencies = [ + "llimphi-theme", + "llimphi-ui", + "llimphi-widget-badge", + "llimphi-widget-panel", +] + +[[package]] +name = "llimphi-widget-skeleton" +version = "0.1.0" +source = "git+https://git.tawasuyu.net/tawasuyu/tawasuyu.git?tag=v0.2.0#a446bb3a73a0e022158cc0ec7deb65173859644b" dependencies = [ "llimphi-theme", "llimphi-ui", @@ -3360,7 +4439,7 @@ dependencies = [ [[package]] name = "llimphi-widget-splitter" version = "0.1.0" -source = "git+https://gitea.tawasuyu.net/sergio/gioser.git?tag=v0.1.0#692b40336c1e9cb12ab561764d6943cb65905ab3" +source = "git+https://git.tawasuyu.net/tawasuyu/tawasuyu.git?tag=v0.2.0#a446bb3a73a0e022158cc0ec7deb65173859644b" dependencies = [ "llimphi-theme", "llimphi-ui", @@ -3369,7 +4448,7 @@ dependencies = [ [[package]] name = "llimphi-widget-stat-card" version = "0.1.0" -source = "git+https://gitea.tawasuyu.net/sergio/gioser.git?tag=v0.1.0#692b40336c1e9cb12ab561764d6943cb65905ab3" +source = "git+https://git.tawasuyu.net/tawasuyu/tawasuyu.git?tag=v0.2.0#a446bb3a73a0e022158cc0ec7deb65173859644b" dependencies = [ "llimphi-theme", "llimphi-ui", @@ -3377,30 +4456,31 @@ dependencies = [ ] [[package]] -name = "llimphi-widget-tabs" +name = "llimphi-widget-terminal" version = "0.1.0" -source = "git+https://gitea.tawasuyu.net/sergio/gioser.git?tag=v0.1.0#692b40336c1e9cb12ab561764d6943cb65905ab3" +source = "git+https://git.tawasuyu.net/tawasuyu/tawasuyu.git?tag=v0.2.0#a446bb3a73a0e022158cc0ec7deb65173859644b" dependencies = [ + "fontdue", + "llimphi-hal", "llimphi-theme", "llimphi-ui", - "llimphi-widget-panel", + "llimphi-widget-scroll", ] [[package]] name = "llimphi-widget-text-editor" version = "0.1.0" -source = "git+https://gitea.tawasuyu.net/sergio/gioser.git?tag=v0.1.0#692b40336c1e9cb12ab561764d6943cb65905ab3" +source = "git+https://git.tawasuyu.net/tawasuyu/tawasuyu.git?tag=v0.2.0#a446bb3a73a0e022158cc0ec7deb65173859644b" dependencies = [ "llimphi-theme", "llimphi-ui", "llimphi-widget-text-editor-core", - "tree-sitter", ] [[package]] name = "llimphi-widget-text-editor-core" version = "0.1.0" -source = "git+https://gitea.tawasuyu.net/sergio/gioser.git?tag=v0.1.0#692b40336c1e9cb12ab561764d6943cb65905ab3" +source = "git+https://git.tawasuyu.net/tawasuyu/tawasuyu.git?tag=v0.2.0#a446bb3a73a0e022158cc0ec7deb65173859644b" dependencies = [ "peniko", "ropey", @@ -3412,7 +4492,7 @@ dependencies = [ [[package]] name = "llimphi-widget-text-input" version = "0.1.0" -source = "git+https://gitea.tawasuyu.net/sergio/gioser.git?tag=v0.1.0#692b40336c1e9cb12ab561764d6943cb65905ab3" +source = "git+https://git.tawasuyu.net/tawasuyu/tawasuyu.git?tag=v0.2.0#a446bb3a73a0e022158cc0ec7deb65173859644b" dependencies = [ "llimphi-theme", "llimphi-ui", @@ -3430,9 +4510,9 @@ dependencies = [ [[package]] name = "log" -version = "0.4.32" +version = "0.4.33" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "953f07c43838f8e6f9758cab68bf5bed85465e7587ebe0b823f1bcd81978ad3a" +checksum = "0ceec5bc11778974d1bcb055b18002eba7f4b3518b6a0081b3af5f21666da9ad" [[package]] name = "lru-slab" @@ -3440,6 +4520,15 @@ version = "0.1.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "112b39cec0b298b6c1999fee3e31427f74f676e4cb9879ed1a121b43661a4154" +[[package]] +name = "mach2" +version = "0.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d640282b302c0bb0a2a8e0233ead9035e3bed871f0b7e81fe4a1ec829765db44" +dependencies = [ + "libc", +] + [[package]] name = "malloc_buf" version = "0.0.6" @@ -3457,7 +4546,7 @@ checksum = "757aee279b8bdbb9f9e676796fd459e4207a1f986e87886700abf589f5abf771" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 2.0.119", ] [[package]] @@ -3469,13 +4558,19 @@ dependencies = [ "regex-automata", ] +[[package]] +name = "matchit" +version = "0.7.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0e7465ac9959cc2b1404e8e2367b43684a6d13790fe23056cc8c6c5a6b7bcb94" + [[package]] name = "matilda" version = "0.1.0" dependencies = [ + "bitacora", "clap", "matilda-apply", - "matilda-config", "matilda-core", "matilda-discover", "matilda-ghost", @@ -3550,17 +4645,54 @@ version = "0.7.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "490cc448043f947bae3cbee9c203358d62dbee0db12107a74be5c30ccfd09771" +[[package]] +name = "media-audio-cpal" +version = "0.1.0" +source = "git+https://git.tawasuyu.net/tawasuyu/tawasuyu.git?tag=v0.2.0#a446bb3a73a0e022158cc0ec7deb65173859644b" +dependencies = [ + "cpal", + "media-core", + "parking_lot 0.12.5", +] + +[[package]] +name = "media-core" +version = "0.1.0" +source = "git+https://git.tawasuyu.net/tawasuyu/tawasuyu.git?tag=v0.2.0#a446bb3a73a0e022158cc0ec7deb65173859644b" +dependencies = [ + "serde", +] + +[[package]] +name = "media-source-capture" +version = "0.1.0" +source = "git+https://git.tawasuyu.net/tawasuyu/tawasuyu.git?tag=v0.2.0#a446bb3a73a0e022158cc0ec7deb65173859644b" +dependencies = [ + "cpal", + "image", + "media-core", +] + [[package]] name = "memchr" -version = "2.8.1" +version = "2.8.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6b947ae49db0d222b1dbc6b113ce7248a3fc3a6ca21b696717bfc000ba4484d8" +checksum = "cf8baf1c55e62ffcace7a9f06f4bd9cd3f0c4beb022d3b367256b91b87513d98" [[package]] name = "memmap2" -version = "0.9.10" +version = "0.8.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "714098028fe011992e1c3962653c96b2d578c4b4bce9036e15ff220319b1e0e3" +checksum = "43a5a03cefb0d953ec0be133036f14e109412fa594edc2f77227249db66cc3ed" +dependencies = [ + "libc", +] + +[[package]] +name = "memmap2" +version = "0.9.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d1219ed1b7f229ee7104d281dd01d6802fe28bb6e95d292942c4daacdeb798c0" dependencies = [ "libc", ] @@ -3576,32 +4708,60 @@ dependencies = [ [[package]] name = "metal" -version = "0.31.0" +version = "0.32.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f569fb946490b5743ad69813cb19629130ce9374034abe31614a36402d18f99e" +checksum = "00c15a6f673ff72ddcc22394663290f870fb224c1bfce55734a75c414150e605" dependencies = [ - "bitflags 2.12.1", + "bitflags 2.13.1", "block", - "core-graphics-types", - "foreign-types", + "core-graphics-types 0.2.0", + "foreign-types 0.5.0", "log", "objc", "paste", ] +[[package]] +name = "mime" +version = "0.3.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6877bb514081ee2a7ff5ef9de3281f14a4dd4bceac4c09388074a6b5df8a139a" + +[[package]] +name = "mime_guess" +version = "2.0.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f7c44f8e672c00fe5308fa235f821cb4198414e1c77935c1ab6948d3fd78550e" +dependencies = [ + "mime", + "unicase", +] + +[[package]] +name = "minga-commit" +version = "0.1.0" +source = "git+https://git.tawasuyu.net/tawasuyu/tawasuyu.git?tag=v0.2.0#a446bb3a73a0e022158cc0ec7deb65173859644b" +dependencies = [ + "format", + "grafo-nav", + "postcard", + "serde", + "serde-big-array", +] + [[package]] name = "minga-core" version = "0.1.0" -source = "git+https://gitea.tawasuyu.net/sergio/gioser.git?tag=v0.1.0#692b40336c1e9cb12ab561764d6943cb65905ab3" +source = "git+https://git.tawasuyu.net/tawasuyu/tawasuyu.git?tag=v0.2.0#a446bb3a73a0e022158cc0ec7deb65173859644b" dependencies = [ "aes-gcm", "argon2", "blake3", "ed25519-dalek", - "rand 0.8.6", + "rand 0.8.7", "serde", "serde-big-array", - "thiserror 2.0.18", + "thiserror 2.0.19", "tree-sitter", "tree-sitter-go", "tree-sitter-javascript", @@ -3611,20 +4771,73 @@ dependencies = [ ] [[package]] -name = "minga-store" +name = "minga-index" version = "0.1.0" -source = "git+https://gitea.tawasuyu.net/sergio/gioser.git?tag=v0.1.0#692b40336c1e9cb12ab561764d6943cb65905ab3" +source = "git+https://git.tawasuyu.net/tawasuyu/tawasuyu.git?tag=v0.2.0#a446bb3a73a0e022158cc0ec7deb65173859644b" dependencies = [ + "format", "minga-core", "postcard", + "rimay-verbo-core", + "rimay-verbo-index", + "serde", +] + +[[package]] +name = "minga-merge" +version = "0.1.0" +source = "git+https://git.tawasuyu.net/tawasuyu/tawasuyu.git?tag=v0.2.0#a446bb3a73a0e022158cc0ec7deb65173859644b" +dependencies = [ + "minga-core", +] + +[[package]] +name = "minga-object" +version = "0.1.0" +source = "git+https://git.tawasuyu.net/tawasuyu/tawasuyu.git?tag=v0.2.0#a446bb3a73a0e022158cc0ec7deb65173859644b" +dependencies = [ + "format", +] + +[[package]] +name = "minga-review" +version = "0.1.0" +source = "git+https://git.tawasuyu.net/tawasuyu/tawasuyu.git?tag=v0.2.0#a446bb3a73a0e022158cc0ec7deb65173859644b" +dependencies = [ + "format", + "iniy-core", + "postcard", + "serde", + "serde-big-array", + "umbral", +] + +[[package]] +name = "minga-store" +version = "0.1.0" +source = "git+https://git.tawasuyu.net/tawasuyu/tawasuyu.git?tag=v0.2.0#a446bb3a73a0e022158cc0ec7deb65173859644b" +dependencies = [ + "agora-core", + "agora-graph", + "agora-petnames", + "crdt", + "format", + "minga-commit", + "minga-core", + "minga-index", + "minga-merge", + "minga-object", + "minga-review", + "postcard", + "serde", "sled", - "thiserror 2.0.18", + "thiserror 2.0.19", ] [[package]] name = "minga-vfs" version = "0.1.0" -source = "git+https://gitea.tawasuyu.net/sergio/gioser.git?tag=v0.1.0#692b40336c1e9cb12ab561764d6943cb65905ab3" +source = "git+https://git.tawasuyu.net/tawasuyu/tawasuyu.git?tag=v0.2.0#a446bb3a73a0e022158cc0ec7deb65173859644b" dependencies = [ "fuser", "libc", @@ -3662,15 +4875,20 @@ dependencies = [ [[package]] name = "mio" -version = "1.2.1" +version = "1.2.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "02bd0af71c67b473010cbbc60715ee815645a4dc942899111f494b4b737d6fda" +checksum = "30d65c71f1ce40ab09135ce117d742b9f8a19ff91a41a8b57ed50bc2de59c427" dependencies = [ "libc", "wasi", "windows-sys 0.61.2", ] +[[package]] +name = "mirada-procedural" +version = "0.1.0" +source = "git+https://git.tawasuyu.net/tawasuyu/tawasuyu.git?tag=v0.2.0#a446bb3a73a0e022158cc0ec7deb65173859644b" + [[package]] name = "moka" version = "0.12.15" @@ -3719,12 +4937,13 @@ dependencies = [ [[package]] name = "multibase" -version = "0.9.2" +version = "0.9.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8694bb4835f452b0e3bb06dbebb1d6fc5385b6ca1caf2e55fd165c042390ec77" +checksum = "7e0e4a371cbf1dfd666b658ba137763edb23c45beb43cfe369b5593cd6b437b6" dependencies = [ "base-x", "base256emoji", + "base45", "data-encoding", "data-encoding-macro", ] @@ -3754,24 +4973,59 @@ dependencies = [ [[package]] name = "naga" -version = "24.0.0" +version = "27.0.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e380993072e52eef724eddfcde0ed013b0c023c3f0417336ed041aa9f076994e" +checksum = "066cf25f0e8b11ee0df221219010f213ad429855f57c494f995590c861a9a7d8" dependencies = [ "arrayvec", "bit-set", - "bitflags 2.12.1", - "cfg_aliases 0.2.1", + "bitflags 2.13.1", + "cfg-if", + "cfg_aliases 0.2.2", "codespan-reporting", + "half", + "hashbrown 0.16.1", "hexf-parse", "indexmap", + "libm", "log", + "num-traits", + "once_cell", "rustc-hash 1.1.0", "spirv", - "strum", - "termcolor", - "thiserror 2.0.18", - "unicode-xid", + "thiserror 2.0.19", + "unicode-ident", +] + +[[package]] +name = "native-tls" +version = "0.2.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "465500e14ea162429d264d44189adc38b199b62b1c21eea9f69e4b73cb03bbf2" +dependencies = [ + "libc", + "log", + "openssl", + "openssl-probe", + "openssl-sys", + "schannel", + "security-framework", + "security-framework-sys", + "tempfile", +] + +[[package]] +name = "ndk" +version = "0.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2076a31b7010b17a38c01907c45b945e8f11495ee4dd588309718901b1f7a5b7" +dependencies = [ + "bitflags 2.13.1", + "jni-sys 0.3.1", + "log", + "ndk-sys 0.5.0+25.2.9519653", + "num_enum", + "thiserror 1.0.69", ] [[package]] @@ -3780,7 +5034,7 @@ version = "0.9.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c3f42e7bbe13d351b6bead8286a43aac9534b82bd3cc43e47037f012ebfd62d4" dependencies = [ - "bitflags 2.12.1", + "bitflags 2.13.1", "jni-sys 0.3.1", "log", "ndk-sys 0.6.0+11769913", @@ -3828,7 +5082,7 @@ version = "0.28.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "4ce3636fa715e988114552619582b530481fd5ef176a1e5c1bf024077c2c9445" dependencies = [ - "bitflags 2.12.1", + "bitflags 2.13.1", "libc", "log", "netlink-packet-core", @@ -3836,16 +5090,17 @@ dependencies = [ [[package]] name = "netlink-proto" -version = "0.12.0" +version = "0.12.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b65d130ee111430e47eed7896ea43ca693c387f097dd97376bffafbf25812128" +checksum = "e6f7398dddf5f152d2a91a2921a134c6097056e292c0d4b9906007855e7cece6" dependencies = [ "bytes", - "futures", + "futures-channel", + "futures-util", "log", "netlink-packet-core", "netlink-sys", - "thiserror 2.0.18", + "thiserror 2.0.19", ] [[package]] @@ -3867,7 +5122,7 @@ version = "0.28.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ab2156c4fce2f8df6c499cc1c763e4394b7482525bf2a9701c9d79d215f519e4" dependencies = [ - "bitflags 2.12.1", + "bitflags 2.13.1", "cfg-if", "cfg_aliases 0.1.1", "libc", @@ -3879,9 +5134,9 @@ version = "0.29.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "71e2746dc3a24dd78b3cfcb7be93368c6de9963d30f43a6a73998a9cf4b17b46" dependencies = [ - "bitflags 2.12.1", + "bitflags 2.13.1", "cfg-if", - "cfg_aliases 0.2.1", + "cfg_aliases 0.2.2", "libc", "memoffset", ] @@ -3892,9 +5147,9 @@ version = "0.30.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "74523f3a35e05aba87a1d978330aef40f67b0304ac79c1c00b294c9830543db6" dependencies = [ - "bitflags 2.12.1", + "bitflags 2.13.1", "cfg-if", - "cfg_aliases 0.2.1", + "cfg_aliases 0.2.2", "libc", ] @@ -3914,13 +5169,22 @@ dependencies = [ "minimal-lexical", ] +[[package]] +name = "nom" +version = "8.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "df9761775871bdef83bee530e60050f7e54b1105350d6884eb0fb4f46c2f9405" +dependencies = [ + "memchr", +] + [[package]] name = "notify" version = "6.1.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "6205bd8bb1e454ad2e27422015fb5e4f2bcc7e08fa8f27058670d208324a4d2d" dependencies = [ - "bitflags 2.12.1", + "bitflags 2.13.1", "crossbeam-channel", "filetime", "fsevent-sys", @@ -3954,13 +5218,13 @@ dependencies = [ [[package]] name = "num-bigint" -version = "0.4.6" +version = "0.4.8" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a5e44f723f1133c9deac646763579fdb3ac745e418f2a7af9cd0c431da1f20b9" +checksum = "c89e69e7e0f03bea5ef08013795c25018e101932225a656383bd384495ecc367" dependencies = [ "num-integer", "num-traits", - "rand 0.8.6", + "rand 0.8.7", ] [[package]] @@ -3974,7 +5238,7 @@ dependencies = [ "num-integer", "num-iter", "num-traits", - "rand 0.8.6", + "rand 0.8.7", "smallvec", "zeroize", ] @@ -3985,6 +5249,17 @@ version = "0.2.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "521739c6d2bac4aa25192232afe6841231376b2b26d4d9fae5ecf8ca5772e441" +[[package]] +name = "num-derive" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed3955f1a9c7c0c15e092f9c887db08b1fc683305fdf6eb6684f22555355e202" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + [[package]] name = "num-integer" version = "0.1.46" @@ -3996,11 +5271,10 @@ dependencies = [ [[package]] name = "num-iter" -version = "0.1.45" +version = "0.1.46" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1429034a0490724d0075ebb2bc9e875d6503c3cf69e235a8941aa757d83ef5bf" +checksum = "c92800bd69a1eac91786bcfe9da64a897eb72911b8dc3095decbd07429e8048b" dependencies = [ - "autocfg", "num-integer", "num-traits", ] @@ -4034,7 +5308,7 @@ dependencies = [ "proc-macro-crate", "proc-macro2", "quote", - "syn", + "syn 2.0.119", ] [[package]] @@ -4077,7 +5351,7 @@ version = "0.2.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e4e89ad9e3d7d297152b17d39ed92cd50ca8063a89a9fa569046d41568891eff" dependencies = [ - "bitflags 2.12.1", + "bitflags 2.13.1", "block2", "libc", "objc2 0.5.2", @@ -4093,7 +5367,7 @@ version = "0.3.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d49e936b501e5c5bf01fda3a9452ff86dc3ea98ad5f283e1455153142d97518c" dependencies = [ - "bitflags 2.12.1", + "bitflags 2.13.1", "objc2 0.6.4", "objc2-core-graphics", "objc2-foundation 0.3.2", @@ -4105,7 +5379,7 @@ version = "0.2.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "74dd3b56391c7a0596a295029734d3c1c5e7e510a4cb30245f8221ccea96b009" dependencies = [ - "bitflags 2.12.1", + "bitflags 2.13.1", "block2", "objc2 0.5.2", "objc2-core-location", @@ -4129,7 +5403,7 @@ version = "0.2.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "617fbf49e071c178c0b24c080767db52958f716d9eabdf0890523aeae54773ef" dependencies = [ - "bitflags 2.12.1", + "bitflags 2.13.1", "block2", "objc2 0.5.2", "objc2-foundation 0.2.2", @@ -4141,7 +5415,7 @@ version = "0.3.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "2a180dd8642fa45cdb7dd721cd4c11b1cadd4929ce112ebd8b9f5803cc79d536" dependencies = [ - "bitflags 2.12.1", + "bitflags 2.13.1", "dispatch2", "objc2 0.6.4", ] @@ -4152,7 +5426,7 @@ version = "0.3.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e022c9d066895efa1345f8e33e584b9f958da2fd4cd116792e15e07e4720a807" dependencies = [ - "bitflags 2.12.1", + "bitflags 2.13.1", "dispatch2", "objc2 0.6.4", "objc2-core-foundation", @@ -4189,7 +5463,7 @@ version = "0.3.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0cde0dfb48d25d2b4862161a4d5fcc0e3c24367869ad306b0c9ec0073bfed92d" dependencies = [ - "bitflags 2.12.1", + "bitflags 2.13.1", "objc2-core-foundation", ] @@ -4205,7 +5479,7 @@ version = "0.2.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0ee638a5da3799329310ad4cfa62fbf045d5f56e3ef5ba4149e7452dcf89d5a8" dependencies = [ - "bitflags 2.12.1", + "bitflags 2.13.1", "block2", "dispatch", "libc", @@ -4218,7 +5492,7 @@ version = "0.3.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e3e0adef53c21f888deb4fa59fc59f7eb17404926ee8a6f59f5df0fd7f9f3272" dependencies = [ - "bitflags 2.12.1", + "bitflags 2.13.1", "objc2 0.6.4", "objc2-core-foundation", ] @@ -4229,7 +5503,7 @@ version = "0.3.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "180788110936d59bab6bd83b6060ffdfffb3b922ba1396b312ae795e1de9d81d" dependencies = [ - "bitflags 2.12.1", + "bitflags 2.13.1", "objc2 0.6.4", "objc2-core-foundation", ] @@ -4252,7 +5526,7 @@ version = "0.2.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "dd0cba1276f6023976a406a14ffa85e1fdd19df6b0f737b063b95f6c8c7aadd6" dependencies = [ - "bitflags 2.12.1", + "bitflags 2.13.1", "block2", "objc2 0.5.2", "objc2-foundation 0.2.2", @@ -4264,7 +5538,7 @@ version = "0.2.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e42bee7bff906b14b167da2bac5efe6b6a07e6f7c0a21a7308d40c960242dc7a" dependencies = [ - "bitflags 2.12.1", + "bitflags 2.13.1", "block2", "objc2 0.5.2", "objc2-foundation 0.2.2", @@ -4287,7 +5561,7 @@ version = "0.2.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b8bb46798b20cd6b91cbd113524c490f1686f4c4e8f49502431415f3512e2b6f" dependencies = [ - "bitflags 2.12.1", + "bitflags 2.13.1", "block2", "objc2 0.5.2", "objc2-cloud-kit", @@ -4319,13 +5593,36 @@ version = "0.2.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "76cfcbf642358e8689af64cee815d139339f3ed8ad05103ed5eaf73db8d84cb3" dependencies = [ - "bitflags 2.12.1", + "bitflags 2.13.1", "block2", "objc2 0.5.2", "objc2-core-location", "objc2-foundation 0.2.2", ] +[[package]] +name = "oboe" +version = "0.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e8b61bebd49e5d43f5f8cc7ee2891c16e0f41ec7954d36bcb6c14c5e0de867fb" +dependencies = [ + "jni 0.21.1", + "ndk 0.8.0", + "ndk-context", + "num-derive", + "num-traits", + "oboe-sys", +] + +[[package]] +name = "oboe-sys" +version = "0.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6c8bb09a4a2b1d668170cfe0a7d5bc103f8999fb316c98099b6a9939c9f2e79d" +dependencies = [ + "cc", +] + [[package]] name = "oid-registry" version = "0.8.1" @@ -4357,6 +5654,49 @@ version = "0.3.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c08d65885ee38876c4f86fa503fb49d7b507c2b62552df7c70b2fce627e06381" +[[package]] +name = "openssl" +version = "0.10.81" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "77823a27f0babb03091cb9ed9ef80af3b39dbc82f97e8fa530374b7dafd87a45" +dependencies = [ + "bitflags 2.13.1", + "cfg-if", + "foreign-types 0.3.2", + "libc", + "openssl-macros", + "openssl-sys", +] + +[[package]] +name = "openssl-macros" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a948666b637a0f465e8564c73e89d4dde00d72d4d473cc972f390fc3dcee7d9c" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "openssl-probe" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7c87def4c32ab89d880effc9e097653c8da5d6ef28e6b539d313baaacfbafcbe" + +[[package]] +name = "openssl-sys" +version = "0.9.117" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b47e7e6bb2c38cd930d25a23b40fa52e068c10e85f3e03a7f5ba5aaca5713695" +dependencies = [ + "cc", + "libc", + "pkg-config", + "vcpkg", +] + [[package]] name = "option-ext" version = "0.2.0" @@ -4375,20 +5715,40 @@ dependencies = [ [[package]] name = "ordered-float" -version = "4.6.0" +version = "5.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7bb71e1b3fa6ca1c61f383464aaf2bb0e2f8e772a1f01d486832464de363b951" +checksum = "b7d950ca161dc355eaf28f82b11345ed76c6e1f6eb1f4f4479e0323b9e2fbd0e" dependencies = [ "num-traits", ] +[[package]] +name = "ordered-stream" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9aa2b01e1d916879f73a53d01d1d6cee68adbb31d6d9177a8cfce093cced1d50" +dependencies = [ + "futures-core", + "pin-project-lite", +] + +[[package]] +name = "os_pipe" +version = "1.2.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7d8fae84b431384b68627d0f9b3b1245fcf9f46f6c0e3dc902e9dce64edd1967" +dependencies = [ + "libc", + "windows-sys 0.61.2", +] + [[package]] name = "owned_ttf_parser" version = "0.25.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "36820e9051aca1014ddc75770aab4d68bc1e9e632f0f5627c4086bc216fb583b" dependencies = [ - "ttf-parser", + "ttf-parser 0.25.1", ] [[package]] @@ -4450,7 +5810,7 @@ dependencies = [ "delegate", "futures", "log", - "rand 0.8.6", + "rand 0.8.7", "thiserror 1.0.69", "tokio", "windows 0.59.0", @@ -4512,14 +5872,15 @@ dependencies = [ [[package]] name = "parley" -version = "0.4.0" +version = "0.6.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e28dadbe655332fd7d996794ec8d0c376695f6ca47bc75aa01e0967c7f28e42a" +checksum = "26746861bb76dbc9bcd5ed1b0b55d2fedf291100961251702a031ab2abd2ce52" dependencies = [ "fontique", + "harfrust", "hashbrown 0.15.5", - "peniko", - "skrifa 0.31.3", + "linebender_resource_handle", + "skrifa 0.37.0", "swash", ] @@ -4543,7 +5904,7 @@ checksum = "57c0d7b74b563b49d38dae00a0c37d4d6de9b432382b2892f0574ddcae73fd0a" [[package]] name = "pata-host" version = "0.1.0" -source = "git+https://gitea.tawasuyu.net/sergio/gioser.git?tag=v0.1.0#692b40336c1e9cb12ab561764d6943cb65905ab3" +source = "git+https://git.tawasuyu.net/tawasuyu/tawasuyu.git?tag=v0.2.0#a446bb3a73a0e022158cc0ec7deb65173859644b" dependencies = [ "postcard", "serde", @@ -4565,7 +5926,7 @@ version = "3.0.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1d30c53c26bc5b31a98cd02d20f25a7c8567146caf63ed593a9d87b2775291be" dependencies = [ - "base64", + "base64 0.22.1", "serde_core", ] @@ -4580,9 +5941,9 @@ dependencies = [ [[package]] name = "peniko" -version = "0.4.1" +version = "0.6.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9b44f9ddd2f480176b34278eb653ec1c8062f3b143a4e16eeff5ffac3334e288" +checksum = "839c8299360d2e998bdb106dc0a6cd71dcc5f4df51df1b620361bf50e283cca6" dependencies = [ "color", "kurbo", @@ -4596,6 +5957,66 @@ version = "2.3.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9b4f627cb1b25917193a259e49bdad08f671f8d9708acfd5fe0a8c1455d87220" +[[package]] +name = "petgraph" +version = "0.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8701b58ea97060d5e5b155d383a69952a60943f0e6dfe30b04c287beb0b27455" +dependencies = [ + "fixedbitset", + "hashbrown 0.15.5", + "indexmap", +] + +[[package]] +name = "phf" +version = "0.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c1562dc717473dbaa4c1f85a36410e03c047b2e7df7f45ee938fbef64ae7fadf" +dependencies = [ + "phf_macros", + "phf_shared", + "serde", +] + +[[package]] +name = "phf_generator" +version = "0.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "135ace3a761e564ec88c03a77317a7c6b80bb7f7135ef2544dbe054243b89737" +dependencies = [ + "fastrand", + "phf_shared", +] + +[[package]] +name = "phf_macros" +version = "0.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "812f032b54b1e759ccd5f8b6677695d5268c588701effba24601f6932f8269ef" +dependencies = [ + "phf_generator", + "phf_shared", + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "phf_shared" +version = "0.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e57fef6bc5981e38c2ce2d63bfa546861309f875b8a75f092d1d54ae2d64f266" +dependencies = [ + "siphasher", +] + +[[package]] +name = "pico-args" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5be167a7af36ee22fe3115051bc51f6e6c7054c9348e28deb4f49bd6f705a315" + [[package]] name = "pin-project" version = "1.1.13" @@ -4613,7 +6034,7 @@ checksum = "c96395f0a926bc13b1c17622aaddda1ecb55d49c8f1bf9777e4d877800a43f8b" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 2.0.119", ] [[package]] @@ -4625,11 +6046,22 @@ checksum = "a89322df9ebe1c1578d689c92318e070967d1042b512afbe49518723f4e6d5cd" [[package]] name = "pineal-render" version = "0.1.0" -source = "git+https://gitea.tawasuyu.net/sergio/gioser.git?tag=v0.1.0#692b40336c1e9cb12ab561764d6943cb65905ab3" +source = "git+https://git.tawasuyu.net/tawasuyu/tawasuyu.git?tag=v0.2.0#a446bb3a73a0e022158cc0ec7deb65173859644b" dependencies = [ "llimphi-ui", ] +[[package]] +name = "piper" +version = "0.2.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c835479a4443ded371d6c535cbfd8d31ad92c5d23ae9770a61bc155e4992a3c1" +dependencies = [ + "atomic-waker", + "fastrand", + "futures-io", +] + [[package]] name = "pkcs1" version = "0.7.5" @@ -4680,6 +6112,157 @@ version = "0.2.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b4596b6d070b27117e987119b4dac604f3c58cfb0b191112e24771b2faeac1a6" +[[package]] +name = "pluma-align" +version = "0.1.0" +source = "git+https://git.tawasuyu.net/tawasuyu/tawasuyu.git?tag=v0.2.0#a446bb3a73a0e022158cc0ec7deb65173859644b" +dependencies = [ + "pluma-cuerpo", + "pluma-fidelidad-tipos", + "postcard", + "serde", + "uuid", +] + +[[package]] +name = "pluma-core" +version = "0.1.0" +source = "git+https://git.tawasuyu.net/tawasuyu/tawasuyu.git?tag=v0.2.0#a446bb3a73a0e022158cc0ec7deb65173859644b" +dependencies = [ + "serde", + "sha2", + "uuid", +] + +[[package]] +name = "pluma-cotejo" +version = "0.1.0" +source = "git+https://git.tawasuyu.net/tawasuyu/tawasuyu.git?tag=v0.2.0#a446bb3a73a0e022158cc0ec7deb65173859644b" +dependencies = [ + "pluma-align", + "pluma-core", + "pluma-cuerpo", + "serde", + "uuid", +] + +[[package]] +name = "pluma-cuerpo" +version = "0.1.0" +source = "git+https://git.tawasuyu.net/tawasuyu/tawasuyu.git?tag=v0.2.0#a446bb3a73a0e022158cc0ec7deb65173859644b" +dependencies = [ + "grafo-nav", + "postcard", + "serde", + "uuid", +] + +[[package]] +name = "pluma-fidelidad-tipos" +version = "0.1.0" +source = "git+https://git.tawasuyu.net/tawasuyu/tawasuyu.git?tag=v0.2.0#a446bb3a73a0e022158cc0ec7deb65173859644b" +dependencies = [ + "postcard", + "serde", +] + +[[package]] +name = "pluma-llm" +version = "0.1.0" +source = "git+https://git.tawasuyu.net/tawasuyu/tawasuyu.git?tag=v0.2.0#a446bb3a73a0e022158cc0ec7deb65173859644b" +dependencies = [ + "pluma-llm-anthropic", + "pluma-llm-claude-cli", + "pluma-llm-cohere", + "pluma-llm-core", + "pluma-llm-gemini", + "pluma-llm-mock", + "pluma-llm-openai-compatible", + "serde", + "thiserror 2.0.19", +] + +[[package]] +name = "pluma-llm-anthropic" +version = "0.1.0" +source = "git+https://git.tawasuyu.net/tawasuyu/tawasuyu.git?tag=v0.2.0#a446bb3a73a0e022158cc0ec7deb65173859644b" +dependencies = [ + "async-trait", + "pluma-llm-core", + "reqwest", + "serde", + "serde_json", +] + +[[package]] +name = "pluma-llm-claude-cli" +version = "0.1.0" +source = "git+https://git.tawasuyu.net/tawasuyu/tawasuyu.git?tag=v0.2.0#a446bb3a73a0e022158cc0ec7deb65173859644b" +dependencies = [ + "async-trait", + "base64 0.22.1", + "pluma-llm-core", + "serde_json", + "tokio", +] + +[[package]] +name = "pluma-llm-cohere" +version = "0.1.0" +source = "git+https://git.tawasuyu.net/tawasuyu/tawasuyu.git?tag=v0.2.0#a446bb3a73a0e022158cc0ec7deb65173859644b" +dependencies = [ + "async-trait", + "pluma-llm-core", + "reqwest", + "serde", + "serde_json", +] + +[[package]] +name = "pluma-llm-core" +version = "0.1.0" +source = "git+https://git.tawasuyu.net/tawasuyu/tawasuyu.git?tag=v0.2.0#a446bb3a73a0e022158cc0ec7deb65173859644b" +dependencies = [ + "async-trait", + "base64 0.22.1", + "serde", + "thiserror 2.0.19", +] + +[[package]] +name = "pluma-llm-gemini" +version = "0.1.0" +source = "git+https://git.tawasuyu.net/tawasuyu/tawasuyu.git?tag=v0.2.0#a446bb3a73a0e022158cc0ec7deb65173859644b" +dependencies = [ + "async-trait", + "pluma-llm-core", + "reqwest", + "serde", + "serde_json", + "tokio", +] + +[[package]] +name = "pluma-llm-mock" +version = "0.1.0" +source = "git+https://git.tawasuyu.net/tawasuyu/tawasuyu.git?tag=v0.2.0#a446bb3a73a0e022158cc0ec7deb65173859644b" +dependencies = [ + "async-trait", + "pluma-llm-core", +] + +[[package]] +name = "pluma-llm-openai-compatible" +version = "0.1.0" +source = "git+https://git.tawasuyu.net/tawasuyu/tawasuyu.git?tag=v0.2.0#a446bb3a73a0e022158cc0ec7deb65173859644b" +dependencies = [ + "async-trait", + "pluma-llm-core", + "reqwest", + "serde", + "serde_json", +] + [[package]] name = "png" version = "0.17.16" @@ -4699,7 +6282,7 @@ version = "0.18.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "60769b8b31b2a9f263dae2776c37b1b28ae246943cf719eb6946a1db05128a61" dependencies = [ - "bitflags 2.12.1", + "bitflags 2.13.1", "crc32fast", "fdeflate", "flate2", @@ -4737,6 +6320,15 @@ dependencies = [ "universal-hash", ] +[[package]] +name = "polycool" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "50596ddc09eb5ad5f75cacd40209568e66df71baf86e1499a0e99c4cff12a5a6" +dependencies = [ + "arrayvec", +] + [[package]] name = "polyval" version = "0.6.2" @@ -4751,9 +6343,18 @@ dependencies = [ [[package]] name = "portable-atomic" -version = "1.13.1" +version = "1.14.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c33a9471896f1c69cecef8d20cbe2f7accd12527ce60845ff44c153bb2a21b49" +checksum = "3d20d5497ef88037a52ff98267d066e7f11fcc5e99bbfbd58a42336193aacec3" + +[[package]] +name = "portable-atomic-util" +version = "0.2.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c2a106d1259c23fac8e543272398ae0e3c0b8d33c88ed73d0cc71b0f1d902618" +dependencies = [ + "portable-atomic", +] [[package]] name = "portable-pty" @@ -4819,16 +6420,6 @@ version = "0.3.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e8cf8e6a8aa66ce33f63993ffc4ea4271eb5b0530a9002db8455ea6050c77bfa" -[[package]] -name = "prettyplease" -version = "0.2.37" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "479ca8adacdd7ce8f1fb39ce9ecccbfe93a3f1344b3d0d97f20bc0196208f62b" -dependencies = [ - "proc-macro2", - "syn", -] - [[package]] name = "primeorder" version = "0.13.6" @@ -4844,7 +6435,7 @@ version = "3.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e67ba7e9b2b56446f1d419b1d807906278ffa1a658a8a5d8a39dcb1f5a78614f" dependencies = [ - "toml_edit 0.25.12+spec-1.1.0", + "toml_edit 0.25.13+spec-1.1.0", ] [[package]] @@ -4855,9 +6446,9 @@ checksum = "dc375e1527247fe1a97d8b7156678dfe7c1af2fc075c9a4db3690ecd2a148068" [[package]] name = "proc-macro2" -version = "1.0.106" +version = "1.0.107" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8fd00f0bb2e90d81d1044c2b32617f68fcb9fa3bb7640c23e9c748e53fb30934" +checksum = "985e7ec9bb745e6ce6535b544d84d6cd6f7ad8bd711c398938ae983b91a766d9" dependencies = [ "unicode-ident", ] @@ -4888,14 +6479,14 @@ checksum = "440f724eba9f6996b75d63681b0a92b06947f1457076d503a4d2e2c8f56442b8" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 2.0.119", ] [[package]] name = "prost" -version = "0.14.3" +version = "0.14.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d2ea70524a2f82d518bce41317d0fae74151505651af45faf1ffbd6fd33f0568" +checksum = "528ac67416ff8646872a3c02cad9cc4ee5dc9f9540c9b10771855c95cb2e5ae1" dependencies = [ "bytes", "prost-derive", @@ -4903,22 +6494,31 @@ dependencies = [ [[package]] name = "prost-derive" -version = "0.14.3" +version = "0.14.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "27c6023962132f4b30eb4c172c91ce92d933da334c59c23cddee82358ddafb0b" +checksum = "b570b25f7617e43d59005d0990ccb79e950a423952cea19671b7a876da390adf" dependencies = [ "anyhow", - "itertools", + "itertools 0.14.0", "proc-macro2", "quote", - "syn", + "syn 2.0.119", ] [[package]] name = "pxfm" -version = "0.1.29" +version = "0.1.30" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e0c5ccf5294c6ccd63a74f1565028353830a9c2f5eb0c682c355c471726a6e3f" +checksum = "d55d956fa96f5ec02be2e13af0e20391a5aa83d6a074e3ad368959d0fab299ea" + +[[package]] +name = "qoi" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7f6d64c71eb498fe9eae14ce4ec935c555749aef511cca85b5568910d6e48001" +dependencies = [ + "bytemuck", +] [[package]] name = "quick-error" @@ -4950,29 +6550,29 @@ dependencies = [ [[package]] name = "quick-xml" -version = "0.39.4" +version = "0.41.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "cdcc8dd4e2f670d309a5f0e83fe36dfdc05af317008fea29144da1a2ac858e5e" +checksum = "e660451e55124f798a69a5af3f49ccfbefbd41910eefd25caf2393e1f3473ec1" dependencies = [ "memchr", ] [[package]] name = "quinn" -version = "0.11.9" +version = "0.11.11" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b9e20a958963c291dc322d98411f541009df2ced7b5a4f2bd52337638cfccf20" +checksum = "0c1a41e437b6bbd489372cd4971de128e85c855f56c57f283d20ff016cf7c0a8" dependencies = [ "bytes", - "cfg_aliases 0.2.1", + "cfg_aliases 0.2.2", "futures-io", "pin-project-lite", "quinn-proto", "quinn-udp", - "rustc-hash 2.1.2", + "rustc-hash 2.1.3", "rustls", - "socket2 0.6.4", - "thiserror 2.0.18", + "socket2 0.6.5", + "thiserror 2.0.19", "tokio", "tracing", "web-time", @@ -4980,20 +6580,22 @@ dependencies = [ [[package]] name = "quinn-proto" -version = "0.11.14" +version = "0.11.16" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "434b42fec591c96ef50e21e886936e66d3cc3f737104fdb9b737c40ffb94c098" +checksum = "2f4bfc015262b9df63c8845072ce59068853ff5872180c2ce2f13038b970e560" dependencies = [ "bytes", - "getrandom 0.3.4", + "fastbloom", + "getrandom 0.4.3", "lru-slab", - "rand 0.9.4", + "rand 0.10.2", + "rand_pcg", "ring", - "rustc-hash 2.1.2", + "rustc-hash 2.1.3", "rustls", "rustls-pki-types", "slab", - "thiserror 2.0.18", + "thiserror 2.0.19", "tinyvec", "tracing", "web-time", @@ -5001,23 +6603,23 @@ dependencies = [ [[package]] name = "quinn-udp" -version = "0.5.14" +version = "0.5.15" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "addec6a0dcad8a8d96a771f815f0eaf55f9d1805756410b39f5fa81332574cbd" +checksum = "35a133f956daabe89a61a685c2649f13d82d5aa4bd5d12d1277e1072a21c0694" dependencies = [ - "cfg_aliases 0.2.1", + "cfg_aliases 0.2.2", "libc", "once_cell", - "socket2 0.6.4", + "socket2 0.6.5", "tracing", - "windows-sys 0.60.2", + "windows-sys 0.61.2", ] [[package]] name = "quote" -version = "1.0.45" +version = "1.0.47" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "41f2619966050689382d2b44f664f4bc593e129785a36d6ee376ddf37259b924" +checksum = "1fbf4db142a473a8d80c26bbf18454ed458bf8d26c8219c331daecfdbd079001" dependencies = [ "proc-macro2", ] @@ -5036,9 +6638,9 @@ checksum = "f8dcc9c7d52a811697d2151c701e0d08956f92b0e24136cf4cf27b57a6a0d9bf" [[package]] name = "rand" -version = "0.8.6" +version = "0.8.7" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5ca0ecfa931c29007047d1bc58e623ab12e5590e8c7cc53200d5202b69266d8a" +checksum = "22f6172bdec972074665ed81ed53b71da00bfc44b65a753cfde883ec4c702a1a" dependencies = [ "libc", "rand_chacha 0.3.1", @@ -5047,14 +6649,25 @@ dependencies = [ [[package]] name = "rand" -version = "0.9.4" +version = "0.9.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "44c5af06bb1b7d3216d91932aed5265164bf384dc89cd6ba05cf59a35f5f76ea" +checksum = "b9ef1d0d795eb7d84685bca4f72f3649f064e6641543d3a8c415898726a57b41" dependencies = [ "rand_chacha 0.9.0", "rand_core 0.9.5", ] +[[package]] +name = "rand" +version = "0.10.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c7f5fa3a058cd35567ef9bfa5e75732bee0f9e4c55fa90477bef2dfcdbc4be80" +dependencies = [ + "chacha20 0.10.1", + "getrandom 0.4.3", + "rand_core 0.10.1", +] + [[package]] name = "rand_chacha" version = "0.3.1" @@ -5093,6 +6706,21 @@ dependencies = [ "getrandom 0.3.4", ] +[[package]] +name = "rand_core" +version = "0.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "63b8176103e19a2643978565ca18b50549f6101881c443590420e4dc998a3c69" + +[[package]] +name = "rand_pcg" +version = "0.10.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "caa0f4137e1c0a72f4c651489402276c8e8e1cf081f3b0ba156d2cbeef09e86a" +dependencies = [ + "rand_core 0.10.1", +] + [[package]] name = "range-alloc" version = "0.1.5" @@ -5120,22 +6748,13 @@ dependencies = [ [[package]] name = "read-fonts" -version = "0.29.3" +version = "0.35.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "04ca636dac446b5664bd16c069c00a9621806895b8bb02c2dc68542b23b8f25d" +checksum = "6717cf23b488adf64b9d711329542ba34de147df262370221940dfabc2c91358" dependencies = [ "bytemuck", - "font-types 0.9.0", -] - -[[package]] -name = "read-fonts" -version = "0.33.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "50ea612a55c08586a1d15134be8a776186c440c312ebda3b9e8efbfe4255b7f4" -dependencies = [ - "bytemuck", - "font-types 0.9.0", + "core_maths", + "font-types 0.10.1", ] [[package]] @@ -5148,6 +6767,17 @@ dependencies = [ "font-types 0.11.3", ] +[[package]] +name = "read-fonts" +version = "0.41.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "046a7d674daf459825b32f5062056d6882db0d2f5a479fbd76ccfc870ac18709" +dependencies = [ + "bytemuck", + "font-types 0.12.2", + "once_cell", +] + [[package]] name = "redox_syscall" version = "0.2.16" @@ -5172,16 +6802,16 @@ version = "0.5.18" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ed2bf2547551a7053d6fdfafda3f938979645c44812fbfcda098faae3f1a362d" dependencies = [ - "bitflags 2.12.1", + "bitflags 2.13.1", ] [[package]] name = "redox_syscall" -version = "0.8.1" +version = "0.9.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5b44b894f2a6e36457d665d1e08c3866add6ed5e70050c1b4ba8a8ddedb02ce7" +checksum = "c5102a6aaa05aa011a238e178e6bca86d2cb56fc9f586d37cb80f5bca6e07759" dependencies = [ - "bitflags 2.12.1", + "bitflags 2.13.1", ] [[package]] @@ -5197,9 +6827,9 @@ dependencies = [ [[package]] name = "regex" -version = "1.12.3" +version = "1.13.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e10754a14b9137dd7b1e3e5b0493cc9171fdd105e0ab477f51b72e7f3ac0e276" +checksum = "f020237b6c8eed93db2e2cb53c00c60a8e1bc73da7d073199a1180401450218d" dependencies = [ "aho-corasick", "memchr", @@ -5209,9 +6839,9 @@ dependencies = [ [[package]] name = "regex-automata" -version = "0.4.14" +version = "0.4.16" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6e1dd4122fc1595e8162618945476892eefca7b88c52820e74af6262213cae8f" +checksum = "8fcfdb36bda0c880c5931cdc7a2bcdc8ba4556847b9d912bca70bc94708711ad" dependencies = [ "aho-corasick", "memchr", @@ -5220,9 +6850,9 @@ dependencies = [ [[package]] name = "regex-syntax" -version = "0.8.10" +version = "0.8.11" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "dc897dd8d9e8bd1ed8cdad82b5966c3e0ecae09fb1907d58efaa013543185d0a" +checksum = "d6f6ff9a378485b298a5286656da665ba74413d36db0979633275d2e708145d4" [[package]] name = "renderdoc-sys" @@ -5230,6 +6860,52 @@ version = "1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "19b30a45b0cd0bcca8037f3d0dc3421eaf95327a17cad11964fb8179b4fc4832" +[[package]] +name = "reqwest" +version = "0.12.28" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "eddd3ca559203180a307f12d114c268abf583f59b03cb906fd0b3ff8646c1147" +dependencies = [ + "base64 0.22.1", + "bytes", + "encoding_rs", + "futures-core", + "futures-util", + "h2", + "http", + "http-body", + "http-body-util", + "hyper", + "hyper-rustls", + "hyper-tls", + "hyper-util", + "js-sys", + "log", + "mime", + "mime_guess", + "native-tls", + "percent-encoding", + "pin-project-lite", + "quinn", + "rustls", + "rustls-pki-types", + "serde", + "serde_json", + "serde_urlencoded", + "sync_wrapper", + "tokio", + "tokio-native-tls", + "tokio-rustls", + "tower", + "tower-http", + "tower-service", + "url", + "wasm-bindgen", + "wasm-bindgen-futures", + "web-sys", + "webpki-roots 1.0.9", +] + [[package]] name = "resolv-conf" version = "0.7.6" @@ -5249,17 +6925,137 @@ dependencies = [ [[package]] name = "rimay-localize" version = "0.1.0" -source = "git+https://gitea.tawasuyu.net/sergio/gioser.git?tag=v0.1.0#692b40336c1e9cb12ab561764d6943cb65905ab3" +source = "git+https://git.tawasuyu.net/tawasuyu/tawasuyu.git?tag=v0.2.0#a446bb3a73a0e022158cc0ec7deb65173859644b" dependencies = [ + "directories", "fluent-bundle", "once_cell", "parking_lot 0.12.5", "sys-locale", - "thiserror 2.0.18", + "thiserror 2.0.19", "tracing", "unic-langid", ] +[[package]] +name = "rimay-verbo" +version = "0.1.0" +source = "git+https://git.tawasuyu.net/tawasuyu/tawasuyu.git?tag=v0.2.0#a446bb3a73a0e022158cc0ec7deb65173859644b" +dependencies = [ + "rimay-verbo-core", + "rimay-verbo-daemon", + "rimay-verbo-mock", +] + +[[package]] +name = "rimay-verbo-core" +version = "0.1.0" +source = "git+https://git.tawasuyu.net/tawasuyu/tawasuyu.git?tag=v0.2.0#a446bb3a73a0e022158cc0ec7deb65173859644b" +dependencies = [ + "async-trait", + "serde", + "thiserror 2.0.19", +] + +[[package]] +name = "rimay-verbo-daemon" +version = "0.1.0" +source = "git+https://git.tawasuyu.net/tawasuyu/tawasuyu.git?tag=v0.2.0#a446bb3a73a0e022158cc0ec7deb65173859644b" +dependencies = [ + "async-trait", + "postcard", + "rimay-verbo-core", + "serde", + "tokio", +] + +[[package]] +name = "rimay-verbo-index" +version = "0.1.0" +source = "git+https://git.tawasuyu.net/tawasuyu/tawasuyu.git?tag=v0.2.0#a446bb3a73a0e022158cc0ec7deb65173859644b" +dependencies = [ + "postcard", + "rimay-verbo-core", + "serde", + "thiserror 2.0.19", +] + +[[package]] +name = "rimay-verbo-mock" +version = "0.1.0" +source = "git+https://git.tawasuyu.net/tawasuyu/tawasuyu.git?tag=v0.2.0#a446bb3a73a0e022158cc0ec7deb65173859644b" +dependencies = [ + "async-trait", + "rimay-verbo-core", +] + +[[package]] +name = "rimay-voz" +version = "0.1.0" +source = "git+https://git.tawasuyu.net/tawasuyu/tawasuyu.git?tag=v0.2.0#a446bb3a73a0e022158cc0ec7deb65173859644b" +dependencies = [ + "rimay-voz-core", + "rimay-voz-daemon", + "rimay-voz-mock", + "rimay-voz-nube", +] + +[[package]] +name = "rimay-voz-core" +version = "0.1.0" +source = "git+https://git.tawasuyu.net/tawasuyu/tawasuyu.git?tag=v0.2.0#a446bb3a73a0e022158cc0ec7deb65173859644b" +dependencies = [ + "async-trait", + "serde", + "thiserror 2.0.19", +] + +[[package]] +name = "rimay-voz-daemon" +version = "0.1.0" +source = "git+https://git.tawasuyu.net/tawasuyu/tawasuyu.git?tag=v0.2.0#a446bb3a73a0e022158cc0ec7deb65173859644b" +dependencies = [ + "async-trait", + "postcard", + "rimay-voz-core", + "serde", + "tokio", +] + +[[package]] +name = "rimay-voz-host" +version = "0.1.0" +source = "git+https://git.tawasuyu.net/tawasuyu/tawasuyu.git?tag=v0.2.0#a446bb3a73a0e022158cc0ec7deb65173859644b" +dependencies = [ + "media-audio-cpal", + "media-core", + "media-source-capture", + "parking_lot 0.12.5", + "rimay-voz", + "tokio", +] + +[[package]] +name = "rimay-voz-mock" +version = "0.1.0" +source = "git+https://git.tawasuyu.net/tawasuyu/tawasuyu.git?tag=v0.2.0#a446bb3a73a0e022158cc0ec7deb65173859644b" +dependencies = [ + "async-trait", + "rimay-voz-core", +] + +[[package]] +name = "rimay-voz-nube" +version = "0.1.0" +source = "git+https://git.tawasuyu.net/tawasuyu/tawasuyu.git?tag=v0.2.0#a446bb3a73a0e022158cc0ec7deb65173859644b" +dependencies = [ + "async-trait", + "reqwest", + "rimay-voz-core", + "serde", + "serde_json", +] + [[package]] name = "ring" version = "0.17.14" @@ -5274,6 +7070,18 @@ dependencies = [ "windows-sys 0.52.0", ] +[[package]] +name = "ron" +version = "0.8.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b91f7eff05f748767f183df4320a63d6936e9c6107d97c9e6bdd9784f4289c94" +dependencies = [ + "base64 0.21.7", + "bitflags 2.13.1", + "serde", + "serde_derive", +] + [[package]] name = "ropey" version = "1.6.1" @@ -5290,6 +7098,15 @@ version = "0.20.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "6c20b6793b5c2fa6553b250154b78d6d0db37e72700ae35fad9387a46f487c97" +[[package]] +name = "roxmltree" +version = "0.21.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f1964b10c76125c36f8afe190065a4bf9a87bf324842c05701330bba9f1cacbb" +dependencies = [ + "memchr", +] + [[package]] name = "rsa" version = "0.9.10" @@ -5338,7 +7155,7 @@ dependencies = [ "aes", "aws-lc-rs", "base64ct", - "bitflags 2.12.1", + "bitflags 2.13.1", "block-padding", "byteorder", "bytes", @@ -5374,7 +7191,7 @@ dependencies = [ "pkcs1", "pkcs5", "pkcs8", - "rand 0.8.6", + "rand 0.8.7", "rand_core 0.6.4", "rsa", "russh-cryptovec", @@ -5425,9 +7242,9 @@ checksum = "08d43f7aa6b08d49f382cde6a7982047c3426db949b1424bc4b7ec9ae12c6ce2" [[package]] name = "rustc-hash" -version = "2.1.2" +version = "2.1.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "94300abf3f1ae2e2b8ffb7b58043de3d399c73fa6f4b73826402a5c457614dbe" +checksum = "6b1e7f9a428571be2dc5bc0505c13fb6bf936822b894ec87abf8a08a4e51742d" [[package]] name = "rustc_version" @@ -5444,7 +7261,7 @@ version = "4.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "faf0c4a6ece9950b9abdb62b1cfcf2a68b3b67a10ba445b3bb85be2a293d0632" dependencies = [ - "nom", + "nom 7.1.3", ] [[package]] @@ -5453,7 +7270,7 @@ version = "0.38.44" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "fdb5bc1ae2baa591800df16c9ca78619bf65c0488b41b96ccec5d11220d8c154" dependencies = [ - "bitflags 2.12.1", + "bitflags 2.13.1", "errno", "libc", "linux-raw-sys 0.4.15", @@ -5466,7 +7283,7 @@ version = "1.1.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b6fe4565b9518b83ef4f91bb47ce29620ca828bd32cb7e408f0062e9930ba190" dependencies = [ - "bitflags 2.12.1", + "bitflags 2.13.1", "errno", "libc", "linux-raw-sys 0.12.1", @@ -5475,10 +7292,11 @@ dependencies = [ [[package]] name = "rustls" -version = "0.23.40" +version = "0.23.42" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ef86cd5876211988985292b91c96a8f2d298df24e75989a43a3c73f2d4d8168b" +checksum = "3c54fcab019b409d04215d3a17cb438fd7fbf192ee61461f20f4fe18704bc138" dependencies = [ + "log", "once_cell", "ring", "rustls-pki-types", @@ -5489,9 +7307,9 @@ dependencies = [ [[package]] name = "rustls-pki-types" -version = "1.14.1" +version = "1.15.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "30a7197ae7eb376e574fe940d068c30fe0462554a3ddbe4eca7838e049c937a9" +checksum = "2f4925028c7eb5d1fcdaf196971378ed9d2c1c4efc7dc5d011256f76c99c0a96" dependencies = [ "web-time", "zeroize", @@ -5510,9 +7328,27 @@ dependencies = [ [[package]] name = "rustversion" -version = "1.0.22" +version = "1.0.23" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b39cdef0fa800fc44525c84ccb54a029961a8215f9619753635a9c0d2538d46d" +checksum = "cf54715a573b99ac80df0bc206da022bcd442c974952c7b9720069370852e21f" + +[[package]] +name = "rustybuzz" +version = "0.20.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fd3c7c96f8a08ee34eff8857b11b49b07d71d1c3f4e88f8a88d4c9e9f90b1702" +dependencies = [ + "bitflags 2.13.1", + "bytemuck", + "core_maths", + "log", + "smallvec", + "ttf-parser 0.25.1", + "unicode-bidi-mirroring", + "unicode-ccc", + "unicode-properties", + "unicode-script", +] [[package]] name = "rw-stream-sink" @@ -5525,6 +7361,12 @@ dependencies = [ "static_assertions", ] +[[package]] +name = "ryu" +version = "1.0.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9774ba4a74de5f7b1c1451ed6cd5285a32eddb5cccb8cc655a4e50009e06477f" + [[package]] name = "salsa20" version = "0.10.2" @@ -5546,20 +7388,33 @@ dependencies = [ [[package]] name = "sandokan-core" version = "0.1.0" -source = "git+https://gitea.tawasuyu.net/sergio/gioser.git?tag=v0.1.0#692b40336c1e9cb12ab561764d6943cb65905ab3" +source = "git+https://git.tawasuyu.net/tawasuyu/tawasuyu.git?tag=v0.2.0#a446bb3a73a0e022158cc0ec7deb65173859644b" dependencies = [ "async-trait", "card-core", "sandokan-lifecycle", "serde", - "thiserror 2.0.18", + "thiserror 2.0.19", + "ulid", +] + +[[package]] +name = "sandokan-journal" +version = "0.1.0" +source = "git+https://git.tawasuyu.net/tawasuyu/tawasuyu.git?tag=v0.2.0#a446bb3a73a0e022158cc0ec7deb65173859644b" +dependencies = [ + "sandokan-core", + "sandokan-lifecycle", + "serde", + "serde_json", + "thiserror 2.0.19", "ulid", ] [[package]] name = "sandokan-lifecycle" version = "0.1.0" -source = "git+https://gitea.tawasuyu.net/sergio/gioser.git?tag=v0.1.0#692b40336c1e9cb12ab561764d6943cb65905ab3" +source = "git+https://git.tawasuyu.net/tawasuyu/tawasuyu.git?tag=v0.2.0#a446bb3a73a0e022158cc0ec7deb65173859644b" dependencies = [ "serde", ] @@ -5567,14 +7422,18 @@ dependencies = [ [[package]] name = "sandokan-local" version = "0.1.0" -source = "git+https://gitea.tawasuyu.net/sergio/gioser.git?tag=v0.1.0#692b40336c1e9cb12ab561764d6943cb65905ab3" +source = "git+https://git.tawasuyu.net/tawasuyu/tawasuyu.git?tag=v0.2.0#a446bb3a73a0e022158cc0ec7deb65173859644b" dependencies = [ + "arje-card-builder", + "arje-cas", "arje-incarnate", + "arje-wasm", "async-trait", "card-core", "libc", "nix 0.29.0", "sandokan-core", + "sandokan-journal", "sandokan-lifecycle", "serde", "serde_json", @@ -5582,6 +7441,15 @@ dependencies = [ "ulid", ] +[[package]] +name = "schannel" +version = "0.1.29" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "91c1b7e4904c873ef0710c1f407dde2e6287de2bebc1bbbf7d430bb7cbffd939" +dependencies = [ + "windows-sys 0.61.2", +] + [[package]] name = "scoped-tls" version = "1.0.1" @@ -5613,7 +7481,7 @@ checksum = "b6277f0217056f77f1d8f49f2950ac6c278c0d607c45f5ee99328d792ede24ec" dependencies = [ "ab_glyph", "log", - "memmap2", + "memmap2 0.9.11", "smithay-client-toolkit", "tiny-skia", ] @@ -5632,20 +7500,43 @@ dependencies = [ "zeroize", ] +[[package]] +name = "security-framework" +version = "3.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b7f4bc775c73d9a02cde8bf7b2ec4c9d12743edf609006c7facc23998404cd1d" +dependencies = [ + "bitflags 2.13.1", + "core-foundation 0.10.1", + "core-foundation-sys", + "libc", + "security-framework-sys", +] + +[[package]] +name = "security-framework-sys" +version = "2.17.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6ce2691df843ecc5d231c0b14ece2acc3efb62c0a398c7e1d875f3983ce020e3" +dependencies = [ + "core-foundation-sys", + "libc", +] + [[package]] name = "self_cell" version = "0.10.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e14e4d63b804dc0c7ec4a1e52bcb63f02c7ac94476755aa579edac21e01f915d" dependencies = [ - "self_cell 1.2.2", + "self_cell 1.3.0", ] [[package]] name = "self_cell" -version = "1.2.2" +version = "1.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b12e76d157a900eb52e81bc6e9f3069344290341720e9178cde2407113ac8d89" +checksum = "2ab42ca02749e120097e328d91d415325bdf43b1c72c4c8badf37375fe40a813" [[package]] name = "semver" @@ -5655,9 +7546,9 @@ checksum = "8a7852d02fc848982e0c167ef163aaff9cd91dc640ba85e263cb1ce46fae51cd" [[package]] name = "serde" -version = "1.0.228" +version = "1.0.229" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9a8e94ea7f378bd32cbbd37198a4a91436180c5bb472411e48b5ec2e2124ae9e" +checksum = "4148590afebada386688f18773da617792bf2ef03ffc1e4cbd2b1d45b023e0ba" dependencies = [ "serde_core", "serde_derive", @@ -5674,29 +7565,29 @@ dependencies = [ [[package]] name = "serde_core" -version = "1.0.228" +version = "1.0.229" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "41d385c7d4ca58e59fc732af25c3983b67ac852c1a25000afe1175de458b67ad" +checksum = "67dca2c9c51e58a4791a4b1ed58308b39c64224d349a935ab5039aa360942a48" dependencies = [ "serde_derive", ] [[package]] name = "serde_derive" -version = "1.0.228" +version = "1.0.229" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d540f220d3187173da220f885ab66608367b6574e925011a9353e4badda91d79" +checksum = "e7a5d71263a5a7d47b41f6b3f06ba276f10cc18b0931f1799f710578e2309348" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 3.0.3", ] [[package]] name = "serde_json" -version = "1.0.150" +version = "1.0.151" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e8014e44b4736ed0538adeecded0fce2a272f22dc9578a7eb6b2d9993c74cfb9" +checksum = "c841b55ecdae098c80dcae9cf767f6f8a0c2cdb3416bbef72181df4d0fe73f14" dependencies = [ "itoa", "memchr", @@ -5705,6 +7596,28 @@ dependencies = [ "zmij", ] +[[package]] +name = "serde_path_to_error" +version = "0.1.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "10a9ff822e371bb5403e391ecd83e182e0e77ba7f6fe0160b795797109d1b457" +dependencies = [ + "itoa", + "serde", + "serde_core", +] + +[[package]] +name = "serde_repr" +version = "0.1.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8d3b1629de253c70a0508c3899572da79ca359fdab27c7920ff00406df418906" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.3", +] + [[package]] name = "serde_spanned" version = "0.6.9" @@ -5714,6 +7627,18 @@ dependencies = [ "serde", ] +[[package]] +name = "serde_urlencoded" +version = "0.7.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d3491c14715ca2294c4d6a88f15e84739788c1d030eed8c110436aafdaa2f3fd" +dependencies = [ + "form_urlencoded", + "itoa", + "ryu", + "serde", +] + [[package]] name = "serial2" version = "0.2.37" @@ -5727,9 +7652,9 @@ dependencies = [ [[package]] name = "sha1" -version = "0.10.6" +version = "0.10.7" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e3bf829a2d51ab4a5ddf1352d8470c140cadc8301b2ae1789db023f01cedd6ba" +checksum = "a978451301f4db1d02937a4ab3ccce137717b81826e79b7d49ffe3244a13c3b8" dependencies = [ "cfg-if", "cpufeatures 0.2.17", @@ -5772,12 +7697,54 @@ version = "1.1.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "dc6fe69c597f9c37bfeeeeeb33da3530379845f10be461a66d16d03eca2ded77" +[[package]] +name = "shlex" +version = "1.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0fda2ff0d084019ba4d7c6f371c95d8fd75ce3524c3cb8fb653a3023f6323e64" + [[package]] name = "shlex" version = "2.0.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f8fadd59c855ef2080decdef8ff161eb6661b86933c9d82e5ba29dc602a55aba" +[[package]] +name = "shuma-agente" +version = "0.1.0" +dependencies = [ + "atipay", + "iniy-core", + "pluma-llm-core", + "serde", + "serde_json", + "sled", + "thiserror 2.0.19", + "uuid", + "wawa-config", +] + +[[package]] +name = "shuma-agente-host" +version = "0.1.0" +dependencies = [ + "pluma-llm", + "shuma-agente", + "tokio", + "wawa-config", +] + +[[package]] +name = "shuma-askpass" +version = "0.1.0" +dependencies = [ + "bitacora", + "llimphi-clipboard", + "llimphi-theme", + "llimphi-ui", + "llimphi-widget-text-input", +] + [[package]] name = "shuma-card" version = "0.1.0" @@ -5787,7 +7754,7 @@ dependencies = [ "sandokan-local", "serde", "serde_json", - "thiserror 2.0.18", + "thiserror 2.0.19", "tokio", "toml", "ulid", @@ -5798,9 +7765,9 @@ name = "shuma-cli" version = "0.1.0" dependencies = [ "anyhow", - "card-core", + "bitacora", "clap", - "serde_json", + "libc", "shuma-card", "shuma-protocol", "tokio", @@ -5813,10 +7780,38 @@ version = "0.1.0" dependencies = [ "directories", "serde", + "serde_json", "tempfile", "toml", ] +[[package]] +name = "shuma-consola-client" +version = "0.1.0" +dependencies = [ + "serde_json", + "shuma-consola-core", + "shuma-protocol", + "ureq", +] + +[[package]] +name = "shuma-consola-core" +version = "0.1.0" +dependencies = [ + "postcard", + "serde", + "serde_json", +] + +[[package]] +name = "shuma-consola-host" +version = "0.1.0" +dependencies = [ + "shuma-consola-core", + "tokio", +] + [[package]] name = "shuma-core" version = "0.1.0" @@ -5831,7 +7826,7 @@ dependencies = [ "shuma-card", "shuma-discern", "tempfile", - "thiserror 2.0.18", + "thiserror 2.0.19", "tokio", "tracing", "ulid", @@ -5843,27 +7838,33 @@ version = "0.1.0" dependencies = [ "anyhow", "arje-incarnate", + "bitacora", "card-core", "card-sidecar", "libc", "nix 0.29.0", "shuma-card", + "shuma-config", + "shuma-consola-host", "shuma-core", "shuma-discern", "shuma-exec", "shuma-link", + "shuma-module", + "shuma-module-shell", "shuma-protocol", + "shuma-remote-exec", "tempfile", "tokio", "tracing", "tracing-subscriber", "ulid", + "vt100", ] [[package]] name = "shuma-discern" version = "0.1.0" -source = "git+https://gitea.tawasuyu.net/sergio/gioser.git?tag=v0.1.0#692b40336c1e9cb12ab561764d6943cb65905ab3" dependencies = [ "card-core", "serde_json", @@ -5883,11 +7884,16 @@ name = "shuma-gateway" version = "0.1.0" dependencies = [ "anyhow", + "axum", + "bitacora", + "reqwest", + "serde", "serde_json", "shuma-protocol", "tokio", "tracing", "tracing-subscriber", + "ulid", ] [[package]] @@ -5933,7 +7939,7 @@ dependencies = [ "serde", "snow", "tempfile", - "thiserror 2.0.18", + "thiserror 2.0.19", "tokio", ] @@ -5945,6 +7951,26 @@ dependencies = [ "toml", ] +[[package]] +name = "shuma-module-agente" +version = "0.1.0" +dependencies = [ + "base64 0.22.1", + "llimphi-image", + "llimphi-theme", + "llimphi-ui", + "llimphi-widget-button", + "llimphi-widget-scroll", + "llimphi-widget-text-input", + "png 0.18.1", + "pollster", + "rimay-voz-core", + "shuma-agente", + "shuma-module", + "shuma-voz-ui", + "wawa-config", +] + [[package]] name = "shuma-module-canvas" version = "0.1.0" @@ -5962,13 +7988,33 @@ dependencies = [ "llimphi-theme", "llimphi-ui", "nucleo-matcher", + "png 0.18.1", + "pollster", "shuma-module", + "shuma-voz-ui", +] + +[[package]] +name = "shuma-module-consola" +version = "0.1.0" +dependencies = [ + "llimphi-icons", + "llimphi-theme", + "llimphi-ui", + "llimphi-widget-button", + "llimphi-widget-dock-rail", + "llimphi-widget-rag-sidebar", + "llimphi-widget-scroll", + "llimphi-widget-text-input", + "shuma-consola-core", + "shuma-consola-host", ] [[package]] name = "shuma-module-launcher" version = "0.1.0" dependencies = [ + "arje-applaunch", "llimphi-theme", "llimphi-ui", "serde", @@ -5989,8 +8035,9 @@ dependencies = [ "matilda-ghost", "matilda-linker", "matilda-plan", + "png 0.18.1", + "pollster", "shuma-module", - "ssh", "tokio", ] @@ -6012,12 +8059,27 @@ name = "shuma-module-shell" version = "0.1.0" dependencies = [ "arboard", + "atipay", + "base64 0.22.1", "llimphi-icons", + "llimphi-image", + "llimphi-svg", + "llimphi-term-graphics", "llimphi-theme", "llimphi-ui", + "llimphi-widget-context-menu", + "llimphi-widget-scroll", + "llimphi-widget-terminal", + "llimphi-widget-text-editor", "llimphi-widget-text-input", + "pluma-core", + "pluma-cotejo", + "pluma-cuerpo", "png 0.18.1", "pollster", + "serde", + "serde_json", + "shuma-config", "shuma-exec", "shuma-history", "shuma-infer", @@ -6027,19 +8089,27 @@ dependencies = [ "shuma-module", "shuma-protocol", "shuma-remote-exec", + "shuma-voz-ui", + "similar", + "tempfile", + "toml", + "typed-arena", + "ulid", + "uuid", "vt100", + "wawa-config", ] [[package]] name = "shuma-protocol" version = "0.1.0" dependencies = [ - "card-core", "nix 0.29.0", "postcard", "serde", "shuma-card", - "thiserror 2.0.18", + "shuma-consola-core", + "thiserror 2.0.19", "tokio", "ulid", ] @@ -6051,8 +8121,10 @@ dependencies = [ "shuma-exec", "shuma-link", "shuma-protocol", - "thiserror 2.0.18", + "ssh", + "thiserror 2.0.19", "tokio", + "ulid", ] [[package]] @@ -6067,23 +8139,53 @@ name = "shuma-shell-llimphi" version = "0.1.0" dependencies = [ "app-bus", + "bitacora", + "blake3", + "chrono", "directories", + "llimphi-clipboard", + "llimphi-icons", + "llimphi-image", + "llimphi-layer", "llimphi-motion", "llimphi-theme", "llimphi-ui", "llimphi-widget-context-menu", + "llimphi-widget-dock-rail", + "llimphi-widget-empty", "llimphi-widget-menubar", + "llimphi-widget-modal", + "llimphi-widget-panes", + "llimphi-widget-rag-sidebar", + "llimphi-widget-scroll", + "llimphi-widget-select", + "llimphi-widget-skeleton", "llimphi-widget-splitter", "llimphi-widget-stat-card", - "llimphi-widget-tabs", + "llimphi-widget-text-input", "matilda-core", "minga-core", + "mirada-procedural", "pata-host", + "pluma-llm", + "png 0.18.1", + "pollster", + "postcard", "rimay-localize", + "rimay-verbo", + "rimay-verbo-index", + "rimay-voz", + "rimay-voz-host", + "ron", "serde", "serde_json", + "shuma-agente", + "shuma-agente-host", + "shuma-config", + "shuma-discern", "shuma-intent", "shuma-module", + "shuma-module-agente", "shuma-module-canvas", "shuma-module-commandbar", "shuma-module-launcher", @@ -6092,9 +8194,13 @@ dependencies = [ "shuma-module-shell", "shuma-sysmon", "tempfile", + "tokio", "toml", "wawa-config", "wawa-config-llimphi", + "willay-checkpoint", + "willay-core", + "willay-emit", ] [[package]] @@ -6112,6 +8218,14 @@ dependencies = [ "serde", ] +[[package]] +name = "shuma-voz-ui" +version = "0.1.0" +dependencies = [ + "llimphi-theme", + "llimphi-ui", +] + [[package]] name = "signal-hook-registry" version = "1.4.8" @@ -6134,15 +8248,15 @@ dependencies = [ [[package]] name = "simd-adler32" -version = "0.3.9" +version = "0.3.10" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "703d5c7ef118737c72f1af64ad2f6f8c5e1921f818cdcb97b8fe6fc69bf66214" +checksum = "3a219298ac11a56ea9a6d2120044824d6f01aeb034955e7af7bc16858527deea" [[package]] name = "simd_cesu8" -version = "1.1.1" +version = "1.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "94f90157bb87cddf702797c5dadfa0be7d266cdf49e22da2fcaa32eff75b2c33" +checksum = "11031e251abf8611c80f460e19dbdeb54a66db918e49c65a7065b46ac7aec520" dependencies = [ "rustc_version", "simdutf8", @@ -6155,23 +8269,34 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e3a9fe34e3e7a50316060351f37187a3f546bce95496156754b601a5fa71b76e" [[package]] -name = "skrifa" -version = "0.31.3" +name = "similar" +version = "2.7.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "dbeb4ca4399663735553a09dd17ce7e49a0a0203f03b706b39628c4d913a8607" +checksum = "bbbb5d9659141646ae647b42fe094daf6c6192d1620870b449d9557f748b2daa" + +[[package]] +name = "simplecss" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7a9c6883ca9c3c7c90e888de77b7a5c849c779d25d74a1269b0218b14e8b136c" dependencies = [ - "bytemuck", - "read-fonts 0.29.3", + "log", ] [[package]] -name = "skrifa" -version = "0.35.0" +name = "siphasher" +version = "1.0.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "576e60c7de4bb6a803a0312f9bef17e78cf1e8d25a80e1ade76770d7a0237955" +checksum = "8ee5873ec9cce0195efcb7a4e9507a04cd49aec9c83d0389df45b1ef7ba2e649" + +[[package]] +name = "skrifa" +version = "0.37.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8c31071dedf532758ecf3fed987cdb4bd9509f900e026ab684b4ecb81ea49841" dependencies = [ "bytemuck", - "read-fonts 0.33.1", + "read-fonts 0.35.0", ] [[package]] @@ -6184,6 +8309,16 @@ dependencies = [ "read-fonts 0.37.0", ] +[[package]] +name = "skrifa" +version = "0.44.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "819ab7d62b1d3e72d9d9dea5650bac30424f9111364bb94928dbf5ecad1baa68" +dependencies = [ + "bytemuck", + "read-fonts 0.41.0", +] + [[package]] name = "slab" version = "0.4.12" @@ -6217,9 +8352,9 @@ dependencies = [ [[package]] name = "smallvec" -version = "1.15.1" +version = "1.15.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "67b1b7a3b5fe4f1376887184045fcf45c69e92af734b7aaddc05fb777b6fbd03" +checksum = "8ed6a63f02c8539c91a8685a86f4099661ba3da017932f6ebbea6de3f0fa7c90" [[package]] name = "smithay-client-toolkit" @@ -6227,13 +8362,15 @@ version = "0.19.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "3457dea1f0eb631b4034d61d4d8c32074caa6cd1ab2d59f2327bd8461e2c0016" dependencies = [ - "bitflags 2.12.1", + "bitflags 2.13.1", + "bytemuck", "calloop", "calloop-wayland-source", "cursor-icon", "libc", "log", - "memmap2", + "memmap2 0.9.11", + "pkg-config", "rustix 0.38.44", "thiserror 1.0.69", "wayland-backend", @@ -6243,6 +8380,7 @@ dependencies = [ "wayland-protocols", "wayland-protocols-wlr", "wayland-scanner", + "xkbcommon", "xkeysym", ] @@ -6284,9 +8422,9 @@ dependencies = [ [[package]] name = "socket2" -version = "0.6.4" +version = "0.6.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "52d1cfed4120b4d927bf7c0f86d2087a4a7d6027c906d9f9d525a80573b9be51" +checksum = "c3d1e2c7f27f8d4cb10542a02c49005dbd6e93095799d6f3be745fae9f8fedd4" dependencies = [ "libc", "windows-sys 0.61.2", @@ -6294,9 +8432,9 @@ dependencies = [ [[package]] name = "spin" -version = "0.9.8" +version = "0.9.9" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6980e8d7511241f8acf4aebddbb1ff938df5eebe98691418c4468d0b72a96a67" +checksum = "3763264f6b73151db08c50ff20d7d8a0b8796e021cdea7ceedad07b80155fa0e" dependencies = [ "lock_api", ] @@ -6307,7 +8445,7 @@ version = "0.3.0+sdk-1.3.268.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "eda41003dc44290527a59b13432d4a0379379fa074b70174882adfbdfd917844" dependencies = [ - "bitflags 2.12.1", + "bitflags 2.13.1", ] [[package]] @@ -6323,10 +8461,11 @@ dependencies = [ [[package]] name = "ssh" version = "0.1.0" -source = "git+https://gitea.tawasuyu.net/sergio/gioser.git?tag=v0.1.0#692b40336c1e9cb12ab561764d6943cb65905ab3" +source = "git+https://git.tawasuyu.net/tawasuyu/tawasuyu.git?tag=v0.2.0#a446bb3a73a0e022158cc0ec7deb65173859644b" dependencies = [ "russh", - "thiserror 2.0.18", + "thiserror 2.0.19", + "tokio", ] [[package]] @@ -6338,7 +8477,7 @@ dependencies = [ "aes", "aes-gcm", "cbc", - "chacha20", + "chacha20 0.9.1", "cipher", "ctr", "poly1305", @@ -6387,6 +8526,19 @@ name = "strict-num" version = "0.1.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "6637bab7722d379c8b41ba849228d680cc12d0a45ba1fa2b48f2a30577a06731" +dependencies = [ + "float-cmp", +] + +[[package]] +name = "string-interner" +version = "0.19.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "23de088478b31c349c9ba67816fa55d9355232d63c3afea8bf513e31f0f1d2c0" +dependencies = [ + "hashbrown 0.15.5", + "serde", +] [[package]] name = "strsim" @@ -6394,28 +8546,6 @@ version = "0.11.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7da8b5736845d9f2fcb837ea5d9e2628564b3b043a70948a3f0b778838c5fb4f" -[[package]] -name = "strum" -version = "0.26.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8fec0f0aef304996cf250b31b5a10dee7980c85da9d759361292b8bca5a18f06" -dependencies = [ - "strum_macros", -] - -[[package]] -name = "strum_macros" -version = "0.26.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4c6bee85a5a24955dc440386795aa378cd9cf82acd5f764469152d2270e581be" -dependencies = [ - "heck", - "proc-macro2", - "quote", - "rustversion", - "syn", -] - [[package]] name = "subtle" version = "2.6.1" @@ -6429,27 +8559,57 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0193cc4331cfd2f3d2011ef287590868599a2f33c3e69bc22c1a3d3acf9e02fb" [[package]] -name = "swash" -version = "0.2.7" +name = "svgtypes" +version = "0.16.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "842f3cd369c2ba38966204f983eaa5e54a8e84a7d7159ed36ade2b6c335aae64" +checksum = "695b5790b3131dafa99b3bbfd25a216edb3d216dad9ca208d4657bfb8f2abc3d" dependencies = [ - "skrifa 0.40.0", + "kurbo", + "siphasher", +] + +[[package]] +name = "swash" +version = "0.2.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6c2499c2d826531388872b2268718aed907a39bd785ab0dcfe57fab26283f92e" +dependencies = [ + "skrifa 0.44.0", "yazi", "zeno", ] [[package]] name = "syn" -version = "2.0.117" +version = "2.0.119" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e665b8803e7b1d2a727f4023456bbbbe74da67099c585258af0ad9c5013b9b99" +checksum = "872831b642d1a07999a962a351ed35b955ea2cfc8f3862091e2a240a84f17297" dependencies = [ "proc-macro2", "quote", "unicode-ident", ] +[[package]] +name = "syn" +version = "3.0.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "53e9bae58849f64dfa4f5d5ae372c8341f7305f82a3868709269343628b659a3" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + +[[package]] +name = "sync_wrapper" +version = "1.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0bf256ce5efdfa370213c1dabab5935a12e49f2c58d15e9eac2870d3b4f27263" +dependencies = [ + "futures-core", +] + [[package]] name = "synstructure" version = "0.13.2" @@ -6458,7 +8618,7 @@ checksum = "728a70f3dbaf5bab7f0c4b1ac8d7ae5ea60a4b5549c8a5914361c99147a709d2" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 2.0.119", ] [[package]] @@ -6476,8 +8636,8 @@ version = "0.7.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a13f3d0daba03132c0aa9767f98351b3488edc2c100cda2d2ec2b04f3d8d3c8b" dependencies = [ - "bitflags 2.12.1", - "core-foundation", + "bitflags 2.13.1", + "core-foundation 0.9.4", "system-configuration-sys", ] @@ -6516,7 +8676,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "32497e9a4c7b38532efcdebeef879707aa9f794296a4f0244f6f69e9bc8574bd" dependencies = [ "fastrand", - "getrandom 0.4.2", + "getrandom 0.4.3", "once_cell", "rustix 1.1.4", "windows-sys 0.61.2", @@ -6542,11 +8702,11 @@ dependencies = [ [[package]] name = "thiserror" -version = "2.0.18" +version = "2.0.19" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4288b5bcbc7920c07a1149a35cf9590a2aa808e0bc1eafaade0b80947865fbc4" +checksum = "09a43598840e33d5b0331f38c5e30d13bb11c11210a4b58f0d9b18a5a5eefcd9" dependencies = [ - "thiserror-impl 2.0.18", + "thiserror-impl 2.0.19", ] [[package]] @@ -6557,25 +8717,25 @@ checksum = "4fee6c4efc90059e10f81e6d42c60a18f76588c3d74cb83a0b242a2b6c7504c1" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 2.0.119", ] [[package]] name = "thiserror-impl" -version = "2.0.18" +version = "2.0.19" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ebc4ee7f67670e9b64d05fa4253e753e016c6c95ff35b89b7941d6b856dec1d5" +checksum = "43cbfe0cf76104d42a574802844187e84a305e531ed54455f11fbde0f10541cd" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 3.0.3", ] [[package]] name = "thread_local" -version = "1.1.9" +version = "1.1.10" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f60246a4944f24f6e018aa17cdeffb7818b76356965d03b07d6a9886e8962185" +checksum = "1ad99c4c6d32803332c548b1af0540b357b3f5fc0be8f6c6bfe8b2e6ae784070" dependencies = [ "cfg-if", ] @@ -6596,12 +8756,11 @@ dependencies = [ [[package]] name = "time" -version = "0.3.47" +version = "0.3.54" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "743bd48c283afc0388f9b8827b976905fb217ad9e647fae3a379a9283c4def2c" +checksum = "3e1d5e639ff6bab73cb6885cc7e7b1de96c3f32c68ec55f3952614bec1092244" dependencies = [ "deranged", - "itoa", "num-conv", "powerfmt", "serde_core", @@ -6611,15 +8770,15 @@ dependencies = [ [[package]] name = "time-core" -version = "0.1.8" +version = "0.1.9" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7694e1cfe791f8d31026952abf09c69ca6f6fa4e1a1229e18988f06a04a12dca" +checksum = "9e1c906769ad99c88eaa54e728060edef082f8e358ff32030cb7c7d315e81109" [[package]] name = "time-macros" -version = "0.2.27" +version = "0.2.32" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2e70e4c5a0e0a8a4823ad65dfe1a6930e4f4d756dcd9dd7939022b5e8c501215" +checksum = "7e689342a48d2ea927c87ea50cabf8594854bf940e9310208848d680d668ed85" dependencies = [ "num-conv", "time-core", @@ -6650,15 +8809,6 @@ dependencies = [ "strict-num", ] -[[package]] -name = "tinystr" -version = "0.7.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9117f5d4db391c1cf6927e7bea3db74b9a1c1add8f7eda9ffd5364f40f57b82f" -dependencies = [ - "displaydoc", -] - [[package]] name = "tinystr" version = "0.8.3" @@ -6672,9 +8822,9 @@ dependencies = [ [[package]] name = "tinyvec" -version = "1.11.0" +version = "1.12.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3e61e67053d25a4e82c844e8424039d9745781b3fc4f32b8d55ed50f5f667ef3" +checksum = "bb4ebadaa0af04fab11ae01eb5f9fdb5f9c5b875506e210e71c07873528baa7f" dependencies = [ "tinyvec_macros", ] @@ -6687,42 +8837,75 @@ checksum = "1f3ccbac311fea05f86f61904b462b55fb3df8837a366dfc601a0161d0532f20" [[package]] name = "tokio" -version = "1.52.3" +version = "1.53.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8fc7f01b389ac15039e4dc9531aa973a135d7a4135281b12d7c1bc79fd57fffe" +checksum = "202caea871b69668250d242070849eb495be178ed697a3e98aebce5bc81a0bed" dependencies = [ "bytes", "libc", - "mio 1.2.1", + "mio 1.2.2", "parking_lot 0.12.5", "pin-project-lite", "signal-hook-registry", - "socket2 0.6.4", + "socket2 0.6.5", "tokio-macros", "windows-sys 0.61.2", ] [[package]] name = "tokio-macros" -version = "2.7.0" +version = "2.7.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "385a6cb71ab9ab790c5fe8d67f1645e6c450a7ce006a33de03daa956cf70a496" +checksum = "6328af13490e73a9b4694030fafd93f8c8c6a9dede33e821c3fc63eddf8042ba" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 2.0.119", +] + +[[package]] +name = "tokio-native-tls" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bbae76ab933c85776efabc971569dd6119c580d8f5d448769dec1764bf796ef2" +dependencies = [ + "native-tls", + "tokio", +] + +[[package]] +name = "tokio-rustls" +version = "0.26.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1729aa945f29d91ba541258c8df89027d5792d85a8841fb65e8bf0f4ede4ef61" +dependencies = [ + "rustls", + "tokio", +] + +[[package]] +name = "tokio-tungstenite" +version = "0.24.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "edc5f74e248dc973e0dbb7b74c7e0d6fcc301c694ff50049504004ef4d0cdcd9" +dependencies = [ + "futures-util", + "log", + "tokio", + "tungstenite", ] [[package]] name = "tokio-util" -version = "0.7.18" +version = "0.7.19" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9ae9cec805b01e8fc3fd2fe289f89149a9b66dd16786abd8b19cfa7b48cb0098" +checksum = "494815d09bf52b5548659851081238f0ca39ff638363907596da739561c62c52" dependencies = [ "bytes", "futures-core", "futures-io", "futures-sink", + "libc", "pin-project-lite", "tokio", ] @@ -6773,14 +8956,14 @@ dependencies = [ [[package]] name = "toml_edit" -version = "0.25.12+spec-1.1.0" +version = "0.25.13+spec-1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d2153edc6955a6c354fad8f5efd38b6a8769bdccf9fe50f8e1329f81b0baa5d7" +checksum = "6975367e4d2ef766d86af01ffad14b622fecc8d4357a998fbc4deb6e9bacaf9b" dependencies = [ "indexmap", "toml_datetime 1.1.1+spec-1.1.0", "toml_parser", - "winnow 1.0.3", + "winnow 1.0.4", ] [[package]] @@ -6789,7 +8972,7 @@ version = "1.1.2+spec-1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a2abe9b86193656635d2411dc43050282ca48aa31c2451210f4202550afb7526" dependencies = [ - "winnow 1.0.3", + "winnow 1.0.4", ] [[package]] @@ -6798,6 +8981,46 @@ version = "0.1.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "5d99f8c9a7727884afe522e9bd5edbfc91a3312b36a77b5fb8926e4c31a41801" +[[package]] +name = "tower" +version = "0.5.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ebe5ef63511595f1344e2d5cfa636d973292adc0eec1f0ad45fae9f0851ab1d4" +dependencies = [ + "futures-core", + "futures-util", + "pin-project-lite", + "sync_wrapper", + "tokio", + "tower-layer", + "tower-service", + "tracing", +] + +[[package]] +name = "tower-http" +version = "0.6.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4cfcf7e2740e6fc6d4d688b4ef00650406bb94adf4731e43c096c3a19fe40840" +dependencies = [ + "bitflags 2.13.1", + "bytes", + "futures-util", + "http", + "http-body", + "pin-project-lite", + "tower", + "tower-layer", + "tower-service", + "url", +] + +[[package]] +name = "tower-layer" +version = "0.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "121c2a6cda46980bb0fcd1647ffaf6cd3fc79a013de288782836f6df9c48780e" + [[package]] name = "tower-service" version = "0.3.3" @@ -6810,6 +9033,7 @@ version = "0.1.44" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "63e71662fa4b2a2c3a26f570f037eb95bb1f85397f3cd8076caed2f026a6d100" dependencies = [ + "log", "pin-project-lite", "tracing-attributes", "tracing-core", @@ -6823,7 +9047,7 @@ checksum = "7490cfa5ec963746568740651ac6781f701c9c5ea257c58e057f3ba8cf69e8da" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 2.0.119", ] [[package]] @@ -6934,17 +9158,55 @@ dependencies = [ "tree-sitter-language", ] +[[package]] +name = "tree_magic_mini" +version = "3.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b8765b90061cba6c22b5831f675da109ae5561588290f9fa2317adab2714d5a6" +dependencies = [ + "memchr", + "nom 8.0.0", + "petgraph", +] + [[package]] name = "try-lock" version = "0.2.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e421abadd41a4225275504ea4d6566923418b7f05506fbc9c0fe86ba7396114b" +[[package]] +name = "ttf-parser" +version = "0.21.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2c591d83f69777866b9126b24c6dd9a18351f177e49d625920d19f989fd31cf8" + [[package]] name = "ttf-parser" version = "0.25.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d2df906b07856748fa3f6e0ad0cbaa047052d4a7dd609e231c4f72cee8c36f31" +dependencies = [ + "core_maths", +] + +[[package]] +name = "tungstenite" +version = "0.24.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "18e5b8366ee7a95b16d32197d0b2604b43a0be89dc5fac9f8e96ccafbaedda8a" +dependencies = [ + "byteorder", + "bytes", + "data-encoding", + "http", + "httparse", + "log", + "rand 0.8.7", + "sha1", + "thiserror 1.0.69", + "utf-8", +] [[package]] name = "type-map" @@ -6952,15 +9214,32 @@ version = "0.5.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "cb30dbbd9036155e74adad6812e9898d03ec374946234fbcebd5dfc7b9187b90" dependencies = [ - "rustc-hash 2.1.2", + "rustc-hash 2.1.3", ] +[[package]] +name = "typed-arena" +version = "2.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6af6ae20167a9ece4bcb41af5b80f8a1f1df981f6391189ce00fd257af04126a" + [[package]] name = "typenum" version = "1.20.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b6f5e870be6c3b371b77fe0ee0bafb859fa4964b4404c27de1d380043c4dda20" +[[package]] +name = "uds_windows" +version = "1.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f2f6fb2847f6742cd76af783a2a2c49e9375d0a111c7bef6f71cd9e738c72d6e" +dependencies = [ + "memoffset", + "tempfile", + "windows-sys 0.61.2", +] + [[package]] name = "uint" version = "0.10.0" @@ -6979,11 +9258,16 @@ version = "1.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "470dbf6591da1b39d43c14523b2b469c86879a53e8b758c8e090a470fe7b1fbe" dependencies = [ - "rand 0.9.4", + "rand 0.9.5", "serde", "web-time", ] +[[package]] +name = "umbral" +version = "0.1.0" +source = "git+https://git.tawasuyu.net/tawasuyu/tawasuyu.git?tag=v0.2.0#a446bb3a73a0e022158cc0ec7deb65173859644b" + [[package]] name = "unic-langid" version = "0.9.6" @@ -7000,7 +9284,7 @@ version = "0.9.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "dce1bf08044d4b7a94028c93786f8566047edc11110595914de93362559bc658" dependencies = [ - "tinystr 0.8.3", + "tinystr", ] [[package]] @@ -7010,7 +9294,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d5957eb82e346d7add14182a3315a7e298f04e1ba4baac36f7f0dbfedba5fc25" dependencies = [ "proc-macro-hack", - "tinystr 0.8.3", + "tinystr", "unic-langid-impl", "unic-langid-macros-impl", ] @@ -7023,16 +9307,52 @@ checksum = "a1249a628de3ad34b821ecb1001355bca3940bcb2f88558f1a8bd82e977f75b5" dependencies = [ "proc-macro-hack", "quote", - "syn", + "syn 2.0.119", "unic-langid-impl", ] +[[package]] +name = "unicase" +version = "2.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dbc4bc3a9f746d862c45cb89d705aa10f187bb96c76001afab07a0d35ce60142" + +[[package]] +name = "unicode-bidi" +version = "0.3.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5c1cb5db39152898a79168971543b1cb5020dff7fe43c8dc468b0885f5e29df5" + +[[package]] +name = "unicode-bidi-mirroring" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5dfa6e8c60bb66d49db113e0125ee8711b7647b5579dc7f5f19c42357ed039fe" + +[[package]] +name = "unicode-ccc" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ce61d488bcdc9bc8b5d1772c404828b17fc481c0a582b5581e95fb233aef503e" + [[package]] name = "unicode-ident" version = "1.0.24" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75" +[[package]] +name = "unicode-properties" +version = "0.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7df058c713841ad818f1dc5d3fd88063241cc61f49f5fbea4b951e8cf5a8d71d" + +[[package]] +name = "unicode-script" +version = "0.5.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "383ad40bb927465ec0ce7720e033cb4ca06912855fc35db31b5755d0de75b1ee" + [[package]] name = "unicode-segmentation" version = "1.13.3" @@ -7040,10 +9360,10 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c6f5d3c3b1bf09027a88a6bc961fc00497d651009560b5463668dc81b0fa87a8" [[package]] -name = "unicode-width" -version = "0.1.14" +name = "unicode-vo" +version = "0.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7dd6e30e90baa6f72411720665d41d89b9a3d039dc45b8faea1ddd07f617f6af" +checksum = "b1d386ff53b415b7fe27b50bb44679e2cc4660272694b7b6f3326d8480823a94" [[package]] name = "unicode-width" @@ -7051,12 +9371,6 @@ version = "0.2.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b4ac048d71ede7ee76d585517add45da530660ef4390e49b098733c6e897f254" -[[package]] -name = "unicode-xid" -version = "0.2.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ebc1c04c71510c7f702b52b7c350734c9ff1295c464a03335b00bb84fc54f853" - [[package]] name = "universal-hash" version = "0.5.1" @@ -7091,6 +9405,21 @@ version = "0.9.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8ecb6da28b8a351d773b68d5825ac39017e680750f980f3a1a85cd8dd28a47c1" +[[package]] +name = "ureq" +version = "2.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "02d1a66277ed75f640d608235660df48c8e3c19f3b4edb6a263315626cc3c01d" +dependencies = [ + "base64 0.22.1", + "log", + "once_cell", + "rustls", + "rustls-pki-types", + "url", + "webpki-roots 0.26.11", +] + [[package]] name = "url" version = "2.5.8" @@ -7103,6 +9432,39 @@ dependencies = [ "serde", ] +[[package]] +name = "usvg" +version = "0.46.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e419dff010bb12512b0ae9e3d2f318dfbdf0167fde7eb05465134d4e8756076f" +dependencies = [ + "base64 0.22.1", + "data-url", + "flate2", + "fontdb", + "imagesize", + "kurbo", + "log", + "pico-args", + "roxmltree 0.21.1", + "rustybuzz", + "simplecss", + "siphasher", + "strict-num", + "svgtypes", + "tiny-skia-path", + "unicode-bidi", + "unicode-script", + "unicode-vo", + "xmlwriter", +] + +[[package]] +name = "utf-8" +version = "0.7.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "09cc8ee72d2a9becf2f2febe0205bbed8fc6615b7cb429ad062dc7b7ddd036a9" + [[package]] name = "utf8_iter" version = "1.0.4" @@ -7117,15 +9479,26 @@ checksum = "06abde3611657adf66d383f00b093d7faecc7fa57071cce2578660c9f1010821" [[package]] name = "uuid" -version = "1.23.2" +version = "1.24.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d258b83ceec21034727ecee8c382cfa6c3e133699b0742c64571814fb420c9f7" +checksum = "bf3923a6f5c4c6382e0b653c4117f48d631ea17f38ed86e2a828e6f7412f5239" dependencies = [ - "getrandom 0.4.2", + "getrandom 0.4.3", "js-sys", + "serde_core", + "uuid-rng-internal", "wasm-bindgen", ] +[[package]] +name = "uuid-rng-internal" +version = "1.24.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9dff7387287837acf196f2596a6216f94c555d947d5331e1cc48393131020119" +dependencies = [ + "getrandom 0.4.3", +] + [[package]] name = "valuable" version = "0.1.1" @@ -7133,19 +9506,25 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ba73ea9cf16a25df0c8caa16c51acb937d5712a8429db78a3ee29d5dcacd3a65" [[package]] -name = "vello" -version = "0.5.1" +name = "vcpkg" +version = "0.2.15" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "fa3f8a53870a2ee699ce05b738a3f9974c92c35ed4874de86052ac68d214811c" +checksum = "accd4ea62f7bb7a82fe23066fb0957d48ef677f6eeb8215f372f52e48bb32426" + +[[package]] +name = "vello" +version = "0.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72fef40773530322d5c2ffe3c1107e9874bd8239ac137d1c2b6c1edad695146e" dependencies = [ "bytemuck", "futures-intrusive", "log", "peniko", "png 0.17.16", - "skrifa 0.35.0", + "skrifa 0.40.0", "static_assertions", - "thiserror 2.0.18", + "thiserror 2.0.19", "vello_encoding", "vello_shaders", "wgpu", @@ -7153,29 +9532,41 @@ dependencies = [ [[package]] name = "vello_encoding" -version = "0.5.1" +version = "0.7.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c69b0fe94b0ac7e47619c504ee2c377355174f5c46353c46d03fa5f7e435922b" +checksum = "24c91203ec4b483440614a9a5c7c2d991932af72c5349659a63ec49476f0b79c" dependencies = [ "bytemuck", "guillotiere", "peniko", - "skrifa 0.35.0", + "skrifa 0.40.0", "smallvec", ] [[package]] name = "vello_shaders" -version = "0.5.1" +version = "0.7.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b2ebea426bb2f95b7610bca09178b03d809ede1d3c500a9acf6eca43e8f200be" +checksum = "7a765d44d4bd354146e44f9a860f4e92effd91a97302549be9e47f0a18d8128c" dependencies = [ "bytemuck", + "log", "naga", - "thiserror 2.0.18", + "thiserror 2.0.19", "vello_encoding", ] +[[package]] +name = "vello_svg" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "79dfa03829a8fb45ad458a05bd533d399d2259d042cdd78f391b290dbbdf1bd3" +dependencies = [ + "thiserror 2.0.19", + "usvg", + "vello", +] + [[package]] name = "version_check" version = "0.9.5" @@ -7189,7 +9580,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "054ff75fb8fa83e609e685106df4faeffdf3a735d3c74ebce97ec557d5d36fd9" dependencies = [ "itoa", - "unicode-width 0.2.2", + "unicode-width", "vte", ] @@ -7230,27 +9621,18 @@ checksum = "ccf3ec651a847eb01de73ccad15eb7d99f80485de043efb2f370cd654f4ea44b" [[package]] name = "wasip2" -version = "1.0.3+wasi-0.2.9" +version = "1.0.4+wasi-0.2.12" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "20064672db26d7cdc89c7798c48a0fdfac8213434a1186e5ef29fd560ae223d6" +checksum = "b67efb37e106e55ce722a510d6b5f9c17f083e5fc79afc2badeb12cc313d9487" dependencies = [ - "wit-bindgen 0.57.1", -] - -[[package]] -name = "wasip3" -version = "0.4.0+wasi-0.3.0-rc-2026-01-06" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5428f8bf88ea5ddc08faddef2ac4a67e390b88186c703ce6dbd955e1c145aca5" -dependencies = [ - "wit-bindgen 0.51.0", + "wit-bindgen", ] [[package]] name = "wasm-bindgen" -version = "0.2.122" +version = "0.2.126" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3ed04576f974d2b2fba0f38c51dbc5518011e38c36bf1143164be765528fd409" +checksum = "4b067c0c11094aef6b7a801c1e34a26affafdf3d051dba08456b868789aaf9a4" dependencies = [ "cfg-if", "once_cell", @@ -7261,9 +9643,9 @@ dependencies = [ [[package]] name = "wasm-bindgen-futures" -version = "0.4.72" +version = "0.4.76" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9473dbd2991ae90b6291c3c32c30c6187ac49aa32f9905d1cce280ec1e110b0f" +checksum = "c62df1340f32221cb9c54d6a27b030e3dba64361d4a95bed55f9aacb44da291d" dependencies = [ "js-sys", "wasm-bindgen", @@ -7271,9 +9653,9 @@ dependencies = [ [[package]] name = "wasm-bindgen-macro" -version = "0.2.122" +version = "0.2.126" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "916151b09da36bd82f6615cbf3a419e2f0ba23a03c6160e8e92eb6bd4aa1dec6" +checksum = "167ce5e579f6bcf889c4f7175a8a5a585de84e8ff93976ce393efa5f2837aab1" dependencies = [ "quote", "wasm-bindgen-macro-support", @@ -7281,77 +9663,137 @@ dependencies = [ [[package]] name = "wasm-bindgen-macro-support" -version = "0.2.122" +version = "0.2.126" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "299047362ccbfce148b67ab7e73349f77748e00c8296f9542adfad2ad82c5c5e" +checksum = "f3997c7839262f4ef12cf90b818d6340c18e80f263f1a94bf157d0ec4420380e" dependencies = [ "bumpalo", "proc-macro2", "quote", - "syn", + "syn 2.0.119", "wasm-bindgen-shared", ] [[package]] name = "wasm-bindgen-shared" -version = "0.2.122" +version = "0.2.126" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9a929b2c61f11ba3e9bc35b50c1f25cb38e0e892c0c231ae2b8cf78d5dad4437" +checksum = "dc1b4cb0cc549fcf58d7dfc081778139b3d283a081644e833e84682ad71cea24" dependencies = [ "unicode-ident", ] [[package]] name = "wasm-encoder" -version = "0.244.0" +version = "0.254.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "990065f2fe63003fe337b932cfb5e3b80e0b4d0f5ff650e6985b1048f62c8319" +checksum = "09480d646178e5fdd12bb06e812d0af9a3a191dbc9cd697fdc86687beade7393" dependencies = [ "leb128fmt", - "wasmparser", + "wasmparser 0.254.0", ] [[package]] -name = "wasm-metadata" -version = "0.244.0" +name = "wasmi" +version = "1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bb0e353e6a2fbdc176932bbaab493762eb1255a7900fe0fea1a2f96c296cc909" +checksum = "2300d0f78cba12f14e29e8dd157ea64050c0a688179aefdb2050105805594a0c" dependencies = [ - "anyhow", - "indexmap", - "wasm-encoder", - "wasmparser", + "spin", + "wasmi_collections", + "wasmi_core", + "wasmi_ir", + "wasmparser 0.239.0", + "wat", +] + +[[package]] +name = "wasmi_collections" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8a8c42a2a76148d43097b1d7cc2a5bf33d5c23bd4dd69015fc887e311767884" +dependencies = [ + "string-interner", +] + +[[package]] +name = "wasmi_core" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9013136083d988725953390bf668b64b7a218fabf26f8b913bbc59546b97ee27" +dependencies = [ + "libm", +] + +[[package]] +name = "wasmi_ir" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ba1fa003f79156f406d62ef0e1464dc03e11ace37170e9fa7524299a75ad8f68" +dependencies = [ + "wasmi_core", ] [[package]] name = "wasmparser" -version = "0.244.0" +version = "0.239.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "47b807c72e1bac69382b3a6fb3dbe8ea4c0ed87ff5629b8685ae6b9a611028fe" +checksum = "8c9d90bb93e764f6beabf1d02028c70a2156a6583e63ac4218dd07ef733368b0" dependencies = [ - "bitflags 2.12.1", - "hashbrown 0.15.5", + "bitflags 2.13.1", + "indexmap", +] + +[[package]] +name = "wasmparser" +version = "0.254.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d5769a29f799fbab136aaf65b4fe5384cd7d93fe6fc9ba0dcb6c8382a1f16e27" +dependencies = [ + "bitflags 2.13.1", "indexmap", "semver", ] +[[package]] +name = "wast" +version = "254.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e7ed4dfc8f6b9fc38b231065e2cdfbf7359af5ab945990abf09658dcc63c3e32" +dependencies = [ + "bumpalo", + "leb128fmt", + "memchr", + "unicode-width", + "wasm-encoder", +] + +[[package]] +name = "wat" +version = "1.254.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7127f7f9b8f127c879991cecd35f494e4628bae1b0874c681414d8d8831e952c" +dependencies = [ + "wast", +] + [[package]] name = "wawa-config" version = "0.1.0" -source = "git+https://gitea.tawasuyu.net/sergio/gioser.git?tag=v0.1.0#692b40336c1e9cb12ab561764d6943cb65905ab3" +source = "git+https://git.tawasuyu.net/tawasuyu/tawasuyu.git?tag=v0.2.0#a446bb3a73a0e022158cc0ec7deb65173859644b" dependencies = [ "directories", "notify", "serde", "serde_json", - "thiserror 2.0.18", + "thiserror 2.0.19", "tracing", ] [[package]] name = "wawa-config-llimphi" version = "0.1.0" -source = "git+https://gitea.tawasuyu.net/sergio/gioser.git?tag=v0.1.0#692b40336c1e9cb12ab561764d6943cb65905ab3" +source = "git+https://git.tawasuyu.net/tawasuyu/tawasuyu.git?tag=v0.2.0#a446bb3a73a0e022158cc0ec7deb65173859644b" dependencies = [ "llimphi-theme", "wawa-config", @@ -7359,9 +9801,9 @@ dependencies = [ [[package]] name = "wayland-backend" -version = "0.3.15" +version = "0.3.16" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2857dd20b54e916ec7253b3d6b4d5c4d7d4ca2c33c2e11c6c76a99bd8744755d" +checksum = "016ccf01d1c58b6f8999612813e17c9b2390f7d70671428869913310f83f54b8" dependencies = [ "cc", "downcast-rs", @@ -7373,11 +9815,11 @@ dependencies = [ [[package]] name = "wayland-client" -version = "0.31.14" +version = "0.31.15" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "645c7c96bb74690c3189b5c9cb4ca1627062bb23693a4fad9d8c3de958260144" +checksum = "e3c36a0f861ad76d0901f2800b46321410d9f73f2ea88aac0650d86c32688073" dependencies = [ - "bitflags 2.12.1", + "bitflags 2.13.1", "rustix 1.1.4", "wayland-backend", "wayland-scanner", @@ -7389,7 +9831,7 @@ version = "0.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "625c5029dbd43d25e6aa9615e88b829a5cad13b2819c4ae129fdbb7c31ab4c7e" dependencies = [ - "bitflags 2.12.1", + "bitflags 2.13.1", "cursor-icon", "wayland-backend", ] @@ -7407,11 +9849,11 @@ dependencies = [ [[package]] name = "wayland-protocols" -version = "0.32.12" +version = "0.32.13" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "563a85523cade2429938e790815fd7319062103b9f4a2dc806e9b53b95982d8f" +checksum = "23d0c813de3daa2ed6520af85a3bd49b0e722a3078506899aa9686fea58dc4b6" dependencies = [ - "bitflags 2.12.1", + "bitflags 2.13.1", "wayland-backend", "wayland-client", "wayland-scanner", @@ -7423,7 +9865,7 @@ version = "0.3.12" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "2b6d8cf1eb2c1c31ed1f5643c88a6e53538129d4af80030c8cabd1f9fa884d91" dependencies = [ - "bitflags 2.12.1", + "bitflags 2.13.1", "wayland-backend", "wayland-client", "wayland-protocols", @@ -7436,7 +9878,7 @@ version = "0.3.12" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "eb04e52f7836d7c7976c78ca0250d61e33873c34156a2a1fc9474828ec268234" dependencies = [ - "bitflags 2.12.1", + "bitflags 2.13.1", "wayland-backend", "wayland-client", "wayland-protocols", @@ -7445,9 +9887,9 @@ dependencies = [ [[package]] name = "wayland-scanner" -version = "0.31.10" +version = "0.31.11" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9c324a910fd86ebdc364a3e61ec1f11737d3b1d6c273c0239ee8ff4bc0d24b4a" +checksum = "338e30461b3a2b67d70eb30a6d89f8e0c93a833e07d2ae89085cd070c4a00ac0" dependencies = [ "proc-macro2", "quick-xml", @@ -7468,9 +9910,9 @@ dependencies = [ [[package]] name = "web-sys" -version = "0.3.99" +version = "0.3.103" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6d621441cfc37b84979402712047321980c178f299193a3589d05b99e8763436" +checksum = "8622dcb61c0bcc9fffa6938bed81210af2da9a7e4a1a834b2e37a59b6dfb6141" dependencies = [ "js-sys", "wasm-bindgen", @@ -7486,6 +9928,24 @@ dependencies = [ "wasm-bindgen", ] +[[package]] +name = "webpki-roots" +version = "0.26.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "521bc38abb08001b01866da9f51eb7c5d647a19260e00054a8c7fd5f9e57f7a9" +dependencies = [ + "webpki-roots 1.0.9", +] + +[[package]] +name = "webpki-roots" +version = "1.0.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7dcd9d09a39985f5344844e66b0c530a33843579125f23e21e9f0f220850f22a" +dependencies = [ + "rustls-pki-types", +] + [[package]] name = "weezl" version = "0.1.12" @@ -7494,18 +9954,21 @@ checksum = "a28ac98ddc8b9274cb41bb4d9d4d5c425b6020c50c46f25559911905610b4a88" [[package]] name = "wgpu" -version = "24.0.5" +version = "27.0.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6b0b3436f0729f6cdf2e6e9201f3d39dc95813fad61d826c1ed07918b4539353" +checksum = "bfe68bac7cde125de7a731c3400723cadaaf1703795ad3f4805f187459cd7a77" dependencies = [ "arrayvec", - "bitflags 2.12.1", - "cfg_aliases 0.2.1", + "bitflags 2.13.1", + "cfg-if", + "cfg_aliases 0.2.2", "document-features", + "hashbrown 0.16.1", "js-sys", "log", "naga", "parking_lot 0.12.5", + "portable-atomic", "profiling", "raw-window-handle", "smallvec", @@ -7520,49 +9983,85 @@ dependencies = [ [[package]] name = "wgpu-core" -version = "24.0.5" +version = "27.0.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7f0aa306497a238d169b9dc70659105b4a096859a34894544ca81719242e1499" +checksum = "27a75de515543b1897b26119f93731b385a19aea165a1ec5f0e3acecc229cae7" dependencies = [ "arrayvec", + "bit-set", "bit-vec", - "bitflags 2.12.1", - "cfg_aliases 0.2.1", + "bitflags 2.13.1", + "bytemuck", + "cfg_aliases 0.2.2", "document-features", + "hashbrown 0.16.1", "indexmap", "log", "naga", "once_cell", "parking_lot 0.12.5", + "portable-atomic", "profiling", "raw-window-handle", "rustc-hash 1.1.0", "smallvec", - "thiserror 2.0.18", + "thiserror 2.0.19", + "wgpu-core-deps-apple", + "wgpu-core-deps-emscripten", + "wgpu-core-deps-windows-linux-android", "wgpu-hal", "wgpu-types", ] [[package]] -name = "wgpu-hal" -version = "24.0.4" +name = "wgpu-core-deps-apple" +version = "27.0.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f112f464674ca69f3533248508ee30cb84c67cf06c25ff6800685f5e0294e259" +checksum = "0772ae958e9be0c729561d5e3fd9a19679bcdfb945b8b1a1969d9bfe8056d233" +dependencies = [ + "wgpu-hal", +] + +[[package]] +name = "wgpu-core-deps-emscripten" +version = "27.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b06ac3444a95b0813ecfd81ddb2774b66220b264b3e2031152a4a29fda4da6b5" +dependencies = [ + "wgpu-hal", +] + +[[package]] +name = "wgpu-core-deps-windows-linux-android" +version = "27.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "71197027d61a71748e4120f05a9242b2ad142e3c01f8c1b47707945a879a03c3" +dependencies = [ + "wgpu-hal", +] + +[[package]] +name = "wgpu-hal" +version = "27.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5b21cb61c57ee198bc4aff71aeadff4cbb80b927beb912506af9c780d64313ce" dependencies = [ "android_system_properties", "arrayvec", "ash", "bit-set", - "bitflags 2.12.1", + "bitflags 2.13.1", "block", "bytemuck", - "cfg_aliases 0.2.1", - "core-graphics-types", + "cfg-if", + "cfg_aliases 0.2.2", + "core-graphics-types 0.2.0", "glow", "glutin_wgl_sys", "gpu-alloc", "gpu-allocator", "gpu-descriptor", + "hashbrown 0.16.1", "js-sys", "khronos-egl", "libc", @@ -7570,18 +10069,19 @@ dependencies = [ "log", "metal", "naga", - "ndk-sys 0.5.0+25.2.9519653", + "ndk-sys 0.6.0+11769913", "objc", "once_cell", "ordered-float", "parking_lot 0.12.5", + "portable-atomic", + "portable-atomic-util", "profiling", "range-alloc", "raw-window-handle", "renderdoc-sys", - "rustc-hash 1.1.0", "smallvec", - "thiserror 2.0.18", + "thiserror 2.0.19", "wasm-bindgen", "web-sys", "wgpu-types", @@ -7591,13 +10091,15 @@ dependencies = [ [[package]] name = "wgpu-types" -version = "24.0.0" +version = "27.0.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "50ac044c0e76c03a0378e7786ac505d010a873665e2d51383dcff8dd227dc69c" +checksum = "afdcf84c395990db737f2dd91628706cb31e86d72e53482320d368e52b5da5eb" dependencies = [ - "bitflags 2.12.1", + "bitflags 2.13.1", + "bytemuck", "js-sys", "log", + "thiserror 2.0.19", "web-sys", ] @@ -7607,6 +10109,37 @@ version = "1.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "72069c3113ab32ab29e5584db3c6ec55d416895e60715417b5b883a357c3e471" +[[package]] +name = "willay-checkpoint" +version = "0.1.0" +source = "git+https://git.tawasuyu.net/tawasuyu/tawasuyu.git?tag=v0.2.0#a446bb3a73a0e022158cc0ec7deb65173859644b" +dependencies = [ + "fork-proof", + "format", + "postcard", + "serde", + "willay-core", +] + +[[package]] +name = "willay-core" +version = "0.1.0" +source = "git+https://git.tawasuyu.net/tawasuyu/tawasuyu.git?tag=v0.2.0#a446bb3a73a0e022158cc0ec7deb65173859644b" +dependencies = [ + "blake3", + "serde", +] + +[[package]] +name = "willay-emit" +version = "0.1.0" +source = "git+https://git.tawasuyu.net/tawasuyu/tawasuyu.git?tag=v0.2.0#a446bb3a73a0e022158cc0ec7deb65173859644b" +dependencies = [ + "anyhow", + "postcard", + "willay-core", +] + [[package]] name = "winapi" version = "0.3.9" @@ -7638,6 +10171,16 @@ version = "0.4.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "712e227841d057c1ee1cd2fb22fa7e5a5461ae8e48fa2ca79ec42cfc1931183f" +[[package]] +name = "windows" +version = "0.54.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9252e5725dbed82865af151df558e754e4a3c2c30818359eb17465f1346a1b49" +dependencies = [ + "windows-core 0.54.0", + "windows-targets 0.52.6", +] + [[package]] name = "windows" version = "0.58.0" @@ -7679,6 +10222,16 @@ dependencies = [ "windows-core 0.62.2", ] +[[package]] +name = "windows-core" +version = "0.54.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "12661b9c89351d684a50a8a643ce5f608e20243b9fb84687800163429f161d65" +dependencies = [ + "windows-result 0.1.2", + "windows-targets 0.52.6", +] + [[package]] name = "windows-core" version = "0.58.0" @@ -7737,7 +10290,7 @@ checksum = "2bbd5b46c938e506ecbce286b6628a02171d56153ba733b6c741fc627ec9579b" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 2.0.119", ] [[package]] @@ -7748,7 +10301,7 @@ checksum = "83577b051e2f49a058c308f17f273b570a6a758386fc291b5f6a934dd84e48c1" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 2.0.119", ] [[package]] @@ -7759,7 +10312,7 @@ checksum = "053e2e040ab57b9dc951b72c264860db7eb3b0200ba345b4e4c3b14f67855ddf" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 2.0.119", ] [[package]] @@ -7770,7 +10323,7 @@ checksum = "053c4c462dc91d3b1504c6fe5a726dd15e216ba718e84a0e46a88fbe5ded3515" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 2.0.119", ] [[package]] @@ -7781,7 +10334,7 @@ checksum = "3f316c4a2570ba26bbec722032c4099d8c8bc095efccdc15688708623367e358" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 2.0.119", ] [[package]] @@ -7817,6 +10370,15 @@ dependencies = [ "windows-strings 0.5.1", ] +[[package]] +name = "windows-result" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5e383302e8ec8515204254685643de10811af0ed97ea37210dc26fb0032647f8" +dependencies = [ + "windows-targets 0.52.6", +] + [[package]] name = "windows-result" version = "0.2.0" @@ -7872,6 +10434,15 @@ dependencies = [ "windows-link 0.2.1", ] +[[package]] +name = "windows-sys" +version = "0.45.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "75283be5efb2831d37ea142365f009c02ec203cd29a3ebecbc093d52315b66d0" +dependencies = [ + "windows-targets 0.42.2", +] + [[package]] name = "windows-sys" version = "0.48.0" @@ -7917,6 +10488,21 @@ dependencies = [ "windows-link 0.2.1", ] +[[package]] +name = "windows-targets" +version = "0.42.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8e5180c00cd44c9b1c88adb3693291f1cd93605ded80c250a75d472756b4d071" +dependencies = [ + "windows_aarch64_gnullvm 0.42.2", + "windows_aarch64_msvc 0.42.2", + "windows_i686_gnu 0.42.2", + "windows_i686_msvc 0.42.2", + "windows_x86_64_gnu 0.42.2", + "windows_x86_64_gnullvm 0.42.2", + "windows_x86_64_msvc 0.42.2", +] + [[package]] name = "windows-targets" version = "0.48.5" @@ -7974,6 +10560,12 @@ dependencies = [ "windows-link 0.2.1", ] +[[package]] +name = "windows_aarch64_gnullvm" +version = "0.42.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "597a5118570b68bc08d8d59125332c54f1ba9d9adeedeef5b99b02ba2b0698f8" + [[package]] name = "windows_aarch64_gnullvm" version = "0.48.5" @@ -7992,6 +10584,12 @@ version = "0.53.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a9d8416fa8b42f5c947f8482c43e7d89e73a173cead56d044f6a56104a6d1b53" +[[package]] +name = "windows_aarch64_msvc" +version = "0.42.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e08e8864a60f06ef0d0ff4ba04124db8b0fb3be5776a5cd47641e942e58c4d43" + [[package]] name = "windows_aarch64_msvc" version = "0.48.5" @@ -8010,6 +10608,12 @@ version = "0.53.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b9d782e804c2f632e395708e99a94275910eb9100b2114651e04744e9b125006" +[[package]] +name = "windows_i686_gnu" +version = "0.42.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c61d927d8da41da96a81f029489353e68739737d3beca43145c8afec9a31a84f" + [[package]] name = "windows_i686_gnu" version = "0.48.5" @@ -8040,6 +10644,12 @@ version = "0.53.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "fa7359d10048f68ab8b09fa71c3daccfb0e9b559aed648a8f95469c27057180c" +[[package]] +name = "windows_i686_msvc" +version = "0.42.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "44d840b6ec649f480a41c8d80f9c65108b92d89345dd94027bfe06ac444d1060" + [[package]] name = "windows_i686_msvc" version = "0.48.5" @@ -8058,6 +10668,12 @@ version = "0.53.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1e7ac75179f18232fe9c285163565a57ef8d3c89254a30685b57d83a38d326c2" +[[package]] +name = "windows_x86_64_gnu" +version = "0.42.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8de912b8b8feb55c064867cf047dda097f92d51efad5b491dfb98f6bbb70cb36" + [[package]] name = "windows_x86_64_gnu" version = "0.48.5" @@ -8076,6 +10692,12 @@ version = "0.53.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9c3842cdd74a865a8066ab39c8a7a473c0778a3f29370b5fd6b4b9aa7df4a499" +[[package]] +name = "windows_x86_64_gnullvm" +version = "0.42.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "26d41b46a36d453748aedef1486d5c7a85db22e56aff34643984ea85514e94a3" + [[package]] name = "windows_x86_64_gnullvm" version = "0.48.5" @@ -8094,6 +10716,12 @@ version = "0.53.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0ffa179e2d07eee8ad8f57493436566c7cc30ac536a3379fdf008f47f6bb7ae1" +[[package]] +name = "windows_x86_64_msvc" +version = "0.42.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9aec5da331524158c6d1a4ac0ab1541149c0b9505fde06423b02f5ef0106b9f0" + [[package]] name = "windows_x86_64_msvc" version = "0.48.5" @@ -8121,20 +10749,20 @@ dependencies = [ "ahash", "android-activity", "atomic-waker", - "bitflags 2.12.1", + "bitflags 2.13.1", "block2", "bytemuck", "calloop", - "cfg_aliases 0.2.1", + "cfg_aliases 0.2.2", "concurrent-queue", - "core-foundation", + "core-foundation 0.9.4", "core-graphics", "cursor-icon", "dpi", "js-sys", "libc", - "memmap2", - "ndk", + "memmap2 0.9.11", + "ndk 0.9.0", "objc2 0.5.2", "objc2-app-kit 0.2.2", "objc2-foundation 0.2.2", @@ -8175,9 +10803,9 @@ dependencies = [ [[package]] name = "winnow" -version = "1.0.3" +version = "1.0.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0592e1c9d151f854e6fd382574c3a0855250e1d9b2f99d9281c6e6391af352f1" +checksum = "23b97319f7b8343df12cc98938e5c3eb436064524c8d2b4e30a1d3a36eecdf81" dependencies = [ "memchr", ] @@ -8191,15 +10819,6 @@ dependencies = [ "winapi", ] -[[package]] -name = "wit-bindgen" -version = "0.51.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d7249219f66ced02969388cf2bb044a09756a083d0fab1e566056b04d9fbcaa5" -dependencies = [ - "wit-bindgen-rust-macro", -] - [[package]] name = "wit-bindgen" version = "0.57.1" @@ -8207,90 +10826,23 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1ebf944e87a7c253233ad6766e082e3cd714b5d03812acc24c318f549614536e" [[package]] -name = "wit-bindgen-core" -version = "0.51.0" +name = "wl-clipboard-rs" +version = "0.9.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ea61de684c3ea68cb082b7a88508a8b27fcc8b797d738bfc99a82facf1d752dc" +checksum = "e9651471a32e87d96ef3a127715382b2d11cc7c8bb9822ded8a7cc94072eb0a3" dependencies = [ - "anyhow", - "heck", - "wit-parser", -] - -[[package]] -name = "wit-bindgen-rust" -version = "0.51.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b7c566e0f4b284dd6561c786d9cb0142da491f46a9fbed79ea69cdad5db17f21" -dependencies = [ - "anyhow", - "heck", - "indexmap", - "prettyplease", - "syn", - "wasm-metadata", - "wit-bindgen-core", - "wit-component", -] - -[[package]] -name = "wit-bindgen-rust-macro" -version = "0.51.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0c0f9bfd77e6a48eccf51359e3ae77140a7f50b1e2ebfe62422d8afdaffab17a" -dependencies = [ - "anyhow", - "prettyplease", - "proc-macro2", - "quote", - "syn", - "wit-bindgen-core", - "wit-bindgen-rust", -] - -[[package]] -name = "wit-component" -version = "0.244.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9d66ea20e9553b30172b5e831994e35fbde2d165325bec84fc43dbf6f4eb9cb2" -dependencies = [ - "anyhow", - "bitflags 2.12.1", - "indexmap", + "libc", "log", - "serde", - "serde_derive", - "serde_json", - "wasm-encoder", - "wasm-metadata", - "wasmparser", - "wit-parser", + "os_pipe", + "rustix 1.1.4", + "thiserror 2.0.19", + "tree_magic_mini", + "wayland-backend", + "wayland-client", + "wayland-protocols", + "wayland-protocols-wlr", ] -[[package]] -name = "wit-parser" -version = "0.244.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ecc8ac4bc1dc3381b7f59c34f00b67e18f910c2c0f50015669dde7def656a736" -dependencies = [ - "anyhow", - "id-arena", - "indexmap", - "log", - "semver", - "serde", - "serde_derive", - "serde_json", - "unicode-xid", - "wasmparser", -] - -[[package]] -name = "writeable" -version = "0.5.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1e9df38ee2d2c3c5948ea468a8406ff0db0b29ae1ffde1bcf20ef305bcc95c51" - [[package]] name = "writeable" version = "0.6.3" @@ -8351,10 +10903,10 @@ dependencies = [ "data-encoding", "der-parser", "lazy_static", - "nom", + "nom 7.1.3", "oid-registry", "rusticata-macros", - "thiserror 2.0.18", + "thiserror 2.0.19", "time", ] @@ -8364,13 +10916,24 @@ version = "0.3.10" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "bec9e4a500ca8864c5b47b8b482a73d62e4237670e5b5f1d6b9e3cae50f28f2b" +[[package]] +name = "xkbcommon" +version = "0.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "13867d259930edc7091a6c41b4ce6eee464328c6ff9659b7e4c668ca20d4c91e" +dependencies = [ + "libc", + "memmap2 0.8.0", + "xkeysym", +] + [[package]] name = "xkbcommon-dl" version = "0.4.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d039de8032a9a8856a6be89cea3e5d12fdd82306ab7c94d74e6deab2460651c5" dependencies = [ - "bitflags 2.12.1", + "bitflags 2.13.1", "dlib", "log", "once_cell", @@ -8382,6 +10945,9 @@ name = "xkeysym" version = "0.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b9cc00251562a284751c9973bace760d86c0276c471b4be569fe6b068ee97a56" +dependencies = [ + "bytemuck", +] [[package]] name = "xml-rs" @@ -8398,6 +10964,12 @@ dependencies = [ "xml-rs", ] +[[package]] +name = "xmlwriter" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ec7a2a501ed189703dba8b08142f057e887dfc4b2cc4db2d343ac6376ba3e0b9" + [[package]] name = "yamux" version = "0.12.1" @@ -8409,7 +10981,7 @@ dependencies = [ "nohash-hasher", "parking_lot 0.12.5", "pin-project", - "rand 0.8.6", + "rand 0.8.7", "static_assertions", ] @@ -8424,7 +10996,7 @@ dependencies = [ "nohash-hasher", "parking_lot 0.12.5", "pin-project", - "rand 0.9.4", + "rand 0.9.5", "static_assertions", "web-time", ] @@ -8444,6 +11016,17 @@ version = "0.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e01738255b5a16e78bbb83e7fbba0a1e7dd506905cfc53f4622d89015a03fbb5" +[[package]] +name = "yeslogic-fontconfig-sys" +version = "6.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1d8b8abf912b9a29ff112e1671c97c33636903d13a69712037190e6805af4f76" +dependencies = [ + "dlib", + "once_cell", + "pkg-config", +] + [[package]] name = "yoke" version = "0.8.3" @@ -8463,10 +11046,107 @@ checksum = "de844c262c8848816172cef550288e7dc6c7b7814b4ee56b3e1553f275f1858e" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 2.0.119", "synstructure", ] +[[package]] +name = "zbus" +version = "5.18.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fe18fb60dc696039e738717b76eaea21e7a4489bbb1885020b43c94236d7e98a" +dependencies = [ + "async-broadcast", + "async-executor", + "async-io", + "async-lock", + "async-process", + "async-recursion", + "async-task", + "async-trait", + "blocking", + "enumflags2", + "event-listener", + "futures-core", + "futures-lite", + "hex", + "libc", + "ordered-stream", + "rustix 1.1.4", + "serde", + "serde_repr", + "tracing", + "uds_windows", + "uuid", + "windows-sys 0.61.2", + "winnow 1.0.4", + "zbus_macros", + "zbus_names", + "zvariant", +] + +[[package]] +name = "zbus-lockstep" +version = "0.5.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6998de05217a084b7578728a9443d04ea4cd80f2a0839b8d78770b76ccd45863" +dependencies = [ + "zbus_xml", + "zvariant", +] + +[[package]] +name = "zbus-lockstep-macros" +version = "0.5.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "10da05367f3a7b7553c8cdf8fa91aee6b64afebe32b51c95177957efc47ca3a0" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", + "zbus-lockstep", + "zbus_xml", + "zvariant", +] + +[[package]] +name = "zbus_macros" +version = "5.18.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fe96480bed92df2b442a1a30df364e12d08eed03aeb061f2b8dc6afb2be91119" +dependencies = [ + "proc-macro-crate", + "proc-macro2", + "quote", + "syn 2.0.119", + "zbus_names", + "zvariant", + "zvariant_utils", +] + +[[package]] +name = "zbus_names" +version = "4.3.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d8bf88b4a3ff53e883001e0e0115b297a9d53c31b9c1edd2bfdd853e3428624e" +dependencies = [ + "serde", + "winnow 1.0.4", + "zvariant", +] + +[[package]] +name = "zbus_xml" +version = "5.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d1586c021a01ca0a9216dcd874e546382e156a5cbab5fab6cb5f10087e22682a" +dependencies = [ + "serde", + "winnow 1.0.4", + "zbus_names", + "zvariant", +] + [[package]] name = "zeno" version = "0.3.3" @@ -8475,22 +11155,22 @@ checksum = "6df3dc4292935e51816d896edcd52aa30bc297907c26167fec31e2b0c6a32524" [[package]] name = "zerocopy" -version = "0.8.50" +version = "0.8.55" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3b065d4f0e55f82fae73202e189638116a87c55ab6b8e6c2721e13dd9d854ad1" +checksum = "b5a105cd7b140f6eeec8acff2ea38135d3cab283ada58540f629fe51e46696eb" dependencies = [ "zerocopy-derive", ] [[package]] name = "zerocopy-derive" -version = "0.8.50" +version = "0.8.55" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0b631b19d36a892ab55420c92dbc83ccd79274f25be714855d3074aa71cab639" +checksum = "0fe976fb70c78cd64cccfe3a6fc142244e8a77b70959b30faf9d0ac37ee228eb" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 2.0.119", ] [[package]] @@ -8510,28 +11190,28 @@ checksum = "11532158c46691caf0f2593ea8358fed6bbf68a0315e80aae9bd41fbade684a1" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 2.0.119", "synstructure", ] [[package]] name = "zeroize" -version = "1.8.2" +version = "1.9.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b97154e67e32c85465826e8bcc1c59429aaaf107c1e4a9e53c8d8ccd5eff88d0" +checksum = "e13c156562582aa81c60cb29407084cdb54c4164760106ab78e6c5b0858cf64e" dependencies = [ "zeroize_derive", ] [[package]] name = "zeroize_derive" -version = "1.4.3" +version = "1.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "85a5b4158499876c763cb03bc4e49185d3cccbabb15b33c627f7884f43db852e" +checksum = "3c50655cbb0fe3fc43170059e702f1ce5e19b84cec58dc87b037a09935c2f328" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 2.0.119", ] [[package]] @@ -8565,14 +11245,14 @@ checksum = "625dc425cab0dca6dc3c3319506e6593dcb08a9f387ea3b284dbd52a92c40555" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 2.0.119", ] [[package]] name = "zmij" -version = "1.0.21" +version = "1.0.23" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b8848ee67ecc8aedbaf3e4122217aff892639231befc6a1b58d29fff4c2cabaa" +checksum = "29666d0abbfad1e3dc4dcf6144730dd3a3ab225bbbdac83319345b1b44ccfc1b" [[package]] name = "zune-core" @@ -8588,3 +11268,43 @@ checksum = "27bc9d5b815bc103f142aa054f561d9187d191692ec7c2d1e2b4737f8dbd7296" dependencies = [ "zune-core", ] + +[[package]] +name = "zvariant" +version = "5.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bee2a0bcd2a907786a456fff45aaaaf54c9ba5f50b71ae9ec1a4edd200c94911" +dependencies = [ + "endi", + "enumflags2", + "serde", + "winnow 1.0.4", + "zvariant_derive", + "zvariant_utils", +] + +[[package]] +name = "zvariant_derive" +version = "5.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "38a708216a18780796770bfe3f4739c7c83a3e8f789b755534bbbc06e4e23e12" +dependencies = [ + "proc-macro-crate", + "proc-macro2", + "quote", + "syn 2.0.119", + "zvariant_utils", +] + +[[package]] +name = "zvariant_utils" +version = "3.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "90cb9383f9b45290407a1258b202d3f8f01db719eb60b4e4055c6375af4fc7c7" +dependencies = [ + "proc-macro2", + "quote", + "serde", + "syn 2.0.119", + "winnow 1.0.4", +] diff --git a/Cargo.toml b/Cargo.toml index 9ce093c..afb7100 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,7 +1,13 @@ -# Cargo.toml raíz STANDALONE de shuma — front-door sobre Llimphi. -# Solo el código de shuma; Llimphi y lo fundacional por git-dep del monorepo gioser.git. +# Cargo.toml raíz STANDALONE de shuma — front-door público. +# GENERADO por scripts/actualizar-standalone.py desde el monorepo tawasuyu.git +# (v0.2.0). No editar a mano: se regenera. Lo fundacional se consume por +# git-dep al tag; sólo el código del dominio vive en este repo. [workspace] resolver = "2" + +# ============================================================ +# Cuadrantes — agregar a medida que se migran +# ============================================================ members = [ "02_ruway/shuma/baremetal/matilda-app", "02_ruway/shuma/baremetal/matilda-apply", @@ -11,9 +17,15 @@ members = [ "02_ruway/shuma/baremetal/matilda-ghost", "02_ruway/shuma/baremetal/matilda-linker", "02_ruway/shuma/baremetal/matilda-plan", + "02_ruway/shuma/sandbox/shuma-agente", + "02_ruway/shuma/sandbox/shuma-agente-host", "02_ruway/shuma/sandbox/shuma-card", "02_ruway/shuma/sandbox/shuma-config", + "02_ruway/shuma/sandbox/shuma-consola-client", + "02_ruway/shuma/sandbox/shuma-consola-core", + "02_ruway/shuma/sandbox/shuma-consola-host", "02_ruway/shuma/sandbox/shuma-core", + "02_ruway/shuma/sandbox/shuma-discern", "02_ruway/shuma/sandbox/shuma-exec", "02_ruway/shuma/sandbox/shuma-history", "02_ruway/shuma/sandbox/shuma-infer", @@ -21,8 +33,10 @@ members = [ "02_ruway/shuma/sandbox/shuma-line", "02_ruway/shuma/sandbox/shuma-link", "02_ruway/shuma/sandbox/shuma-module", + "02_ruway/shuma/sandbox/shuma-module-agente", "02_ruway/shuma/sandbox/shuma-module-canvas", "02_ruway/shuma/sandbox/shuma-module-commandbar", + "02_ruway/shuma/sandbox/shuma-module-consola", "02_ruway/shuma/sandbox/shuma-module-launcher", "02_ruway/shuma/sandbox/shuma-module-matilda", "02_ruway/shuma/sandbox/shuma-module-minga", @@ -32,12 +46,19 @@ members = [ "02_ruway/shuma/sandbox/shuma-session", "02_ruway/shuma/sandbox/shuma-shell-render", "02_ruway/shuma/sandbox/shuma-sysmon", + "02_ruway/shuma/sandbox/shuma-voz-ui", + "02_ruway/shuma/shuma-askpass", "02_ruway/shuma/shuma-cli", "02_ruway/shuma/shuma-daemon", "02_ruway/shuma/shuma-gateway", "02_ruway/shuma/shuma-shell-llimphi", ] +# `wawa/` se excluye del workspace global porque corre en target +# `x86_64-unknown-none` y `panic = "abort"`, incompatibles con los +# perfiles globales. Los crates compartidos se referencian por `path` +# cruzando la frontera. + [workspace.package] version = "0.1.0" edition = "2021" @@ -48,9 +69,32 @@ publish = false repository = "https://git.tawasuyu.net/tawasuyu/shuma" [workspace.dependencies] - +# === Configuración declarativa (vocabulario de esquemas) === +allichay = { git = "https://git.tawasuyu.net/tawasuyu/tawasuyu.git", tag = "v0.2.0" } +marca = { git = "https://git.tawasuyu.net/tawasuyu/tawasuyu.git", tag = "v0.2.0" } +# Telemetría local de desarrollo (stderr+panics+tracing → $XDG_STATE_HOME/tawasuyu//.log) +bitacora = { git = "https://git.tawasuyu.net/tawasuyu/tawasuyu.git", tag = "v0.2.0" } +# default = [] (núcleo liviano sin vello); `bake` es opt-in. Declaramos +# default-features = false aquí para que el `default-features = false` de los +# consumidores (splash/greeter/compositor) se honre y no quede el warning. +mirada-fondo = { git = "https://git.tawasuyu.net/tawasuyu/tawasuyu.git", tag = "v0.2.0" } +mirada-teclado-core = { git = "https://git.tawasuyu.net/tawasuyu/tawasuyu.git", tag = "v0.2.0" } +mirada-teclado-widget = { git = "https://git.tawasuyu.net/tawasuyu/tawasuyu.git", tag = "v0.2.0" } # === Registro de apps / menú global === -app-bus = { git = "https://git.tawasuyu.net/tawasuyu/tawasuyu.git", tag = "v0.1.0" } +app-bus = { git = "https://git.tawasuyu.net/tawasuyu/tawasuyu.git", tag = "v0.2.0" } +app-iconset = { git = "https://git.tawasuyu.net/tawasuyu/tawasuyu.git", tag = "v0.2.0" } +# === Centro de eventos (notificaciones + capturas + clipboard + …) === +willay-core = { git = "https://git.tawasuyu.net/tawasuyu/tawasuyu.git", tag = "v0.2.0" } +willay-checkpoint = { git = "https://git.tawasuyu.net/tawasuyu/tawasuyu.git", tag = "v0.2.0" } +willay-hilo = { git = "https://git.tawasuyu.net/tawasuyu/tawasuyu.git", tag = "v0.2.0" } +willay-store = { git = "https://git.tawasuyu.net/tawasuyu/tawasuyu.git", tag = "v0.2.0" } +willay-emit = { git = "https://git.tawasuyu.net/tawasuyu/tawasuyu.git", tag = "v0.2.0" } +rag-motor = { git = "https://git.tawasuyu.net/tawasuyu/tawasuyu.git", tag = "v0.2.0" } +churay-core = { git = "https://git.tawasuyu.net/tawasuyu/tawasuyu.git", tag = "v0.2.0" } +churay-welcome = { git = "https://git.tawasuyu.net/tawasuyu/tawasuyu.git", tag = "v0.2.0" } +churay-welcome-runner = { git = "https://git.tawasuyu.net/tawasuyu/tawasuyu.git", tag = "v0.2.0" } +# === Puente de drag-and-drop compositor → app (suple winit en Wayland) === +drop-bridge = { git = "https://git.tawasuyu.net/tawasuyu/tawasuyu.git", tag = "v0.2.0" } # === Serialización === serde = { version = "1", features = ["derive"] } serde_json = "1" @@ -77,8 +121,12 @@ tracing = "0.1" tracing-subscriber = { version = "0.3", features = ["env-filter", "fmt"] } # === Linux primitives (arje) === -nix = { version = "0.29", features = ["signal", "process", "sched", "mount", "fs", "socket", "net", "user"] } +nix = { version = "0.29", features = ["signal", "process", "sched", "mount", "fs", "socket", "net", "user", "reboot"] } libc = "0.2" +# DRM/KMS legacy (dumb buffer) para el splash nativo del arranque sin parpadeo. +# Misma versión que reexporta smithay (mirada-compositor) — el lock ya la fija. +drm = "0.14" +font8x8 = { version = "0.3", default-features = false } # === IDs / Hash / Crypto === ulid = { version = "1", features = ["serde"] } @@ -92,10 +140,15 @@ argon2 = "0.5" rand = "0.8" # === WASM (arje) === -# wasmi 1.0: unifica la versión con renaser (su kernel ya corre 1.0), para +# wasmi 1.0: unifica la versión con wawa (su kernel ya corre 1.0), para # que el ABI WASM del host sea idéntico en Linux y en bare-metal. wasmi = "1.0" wat = "1" +# wasmtime 46 (Fase 1 del pipeline JS): tier 0 de puriy-js pasa de wasmi +# (intérprete) a Wasmtime AOT (Cranelift → nativo) en Linux — quita la doble +# interpretación de QuickJS.wasm. En wawa bare-metal el runtime es Pulley (spike +# Q1 cerrado); mismo crate, distinto backend. +wasmtime = "46" # === Storage / DB === sled = "0.34" @@ -110,6 +163,10 @@ bzip2 = "0.4" # === Compresión (minga multi-bundle) === zstd = "0.13" +xz2 = "0.1" +# Contenedores adicionales del visor Archive de nahual (sólo listado). +sevenz-rust = "0.6" +unrar = "0.5" # === HTTP server (iniy-server) === axum = "0.7" @@ -119,7 +176,7 @@ tower = "0.5" instant-distance = "0.6" # === P2P (minga) === -libp2p = { version = "0.56", features = ["tokio", "tcp", "noise", "yamux", "macros", "kad", "identify", "relay", "dcutr", "autonat", "mdns"] } +libp2p = { version = "0.56", features = ["tokio", "tcp", "dns", "noise", "yamux", "macros", "kad", "identify", "relay", "dcutr", "autonat", "mdns"] } libp2p-stream = "=0.4.0-alpha" libp2p-allow-block-list = "0.6" @@ -134,7 +191,11 @@ libm = "0.2" midly = "0.5" # === Code parsing (minga) === -arboard = "3" +# `wayland-data-control` trae el backend wl-clipboard-rs: bajo mirada (Wayland +# propio, sin Xwayland para apps nativas) arboard-x11 fallaba a no-op silencioso +# y el clipboard de los text-editor quedaba muerto. Con la feature habla +# zwlr_data_control_manager_v1 (que mirada expone) y cae a X11 si no hay Wayland. +arboard = { version = "3", features = ["wayland-data-control"] } ropey = "1.6" tree-sitter = "0.24" tree-sitter-rust = "0.23" @@ -153,7 +214,19 @@ petgraph = "0.6" # default-features = false: nos quedamos con PNG + JPEG + WebP (lossless). # tullpu-render exporta a las tres; AVIF/TIFF/… los habilitamos si una app # los pide específicamente. -image = { version = "0.25", default-features = false, features = ["png", "jpeg", "webp"] } +# Decoders puro-Rust baratos; avif queda fuera (arrastra dav1d/rav1e). +image = { version = "0.25", default-features = false, features = ["png", "jpeg", "webp", "gif", "bmp", "ico", "tiff", "tga", "qoi"] } +# ONNX Runtime para los proveedores de píxel reales (pixel-verbo-onnx: segmentación +# u2net/isnet, inpaint, restyle…). `download-binaries` (default) baja el runtime nativo +# solo; ya lo arrastraba fastembed (rimay-verbo) transitivo — aquí lo declaramos directo. +ort = "2.0.0-rc.9" + +# Rasterizador vectorial CPU (relleno/trazo anti-aliased) para capas vectoriales de tullpu. +tiny-skia = "0.11" +# Booleanos de polígonos robustos (union/intersection/difference/xor) para el +# clipper vectorial de tullpu — restar/intersecar paths que se solapan +# parcialmente, que la regla de relleno no expresa. Ya estaba en el lock. +geo = "0.28" # === FUSE (minga-vfs) === # default-features = false: prescinde de pkg-config/libfuse-dev en build. @@ -175,91 +248,164 @@ tempfile = "3" # === Llimphi (motor gráfico soberano) === # wgpu sobre Vulkan/Metal/DX12, winit para ventana en dev Linux. -# raw-window-handle 0.6 alinea winit 0.30 con wgpu 24. -# vello 0.5 = rasterizador vectorial sobre wgpu 24. +# raw-window-handle 0.6 alinea winit 0.30 con wgpu 27. +# vello 0.7 = rasterizador vectorial sobre wgpu 27 (renderer GPU "wgpu" + opt-in "hybrid" CPU+GPU). # taffy 0.9 = motor Flexbox/Grid puro Rust (ya pulled por transitivos, lo alineamos). -# parley 0.2 = shaping/layout de texto compatible con peniko 0.4 (que vello 0.5 expone). -wgpu = "24" +# parley 0.6 = shaping/layout de texto compatible con peniko 0.6 (que vello 0.7 expone). +wgpu = "27" winit = "0.30" raw-window-handle = "0.6" pollster = "0.4" -vello = "0.5" +vello = "0.7" +# Renderer hybrid CPU+GPU sin compute shaders (mejor compat WebGL2/Adreno viejas). +# Opt-in; el renderer "wgpu" sigue siendo el default. +vello_hybrid = "0.0.9" +# foreign-lottie = fork vendorizado de velato 0.9 (Lottie → vello::Scene), +# completado para no paniquear ante features no soportadas. Lo consume +# llimphi-lottie. Ver shared/foreign-lottie. +foreign-lottie = { git = "https://git.tawasuyu.net/tawasuyu/tawasuyu.git", tag = "v0.2.0" } +# Árbol de accesibilidad para lectores de pantalla (NVDA/VoiceOver/Orca/TalkBack). +# Lo consume el runtime de Llimphi (iter 2/3 del plan AccessKit); el modelo +# `SemanticsSpec` del compositor es independiente de estas crates. +accesskit = "0.24" +accesskit_winit = "0.33" taffy = "0.9" # parley = shaping completo (bidi, ligatures, fallback CJK/emoji vía fontique, line break). -parley = "0.4" +parley = "0.6" # Bucle Elm (input→update→view→layout→raster→present). Lo consumen las apps. -llimphi-ui = { git = "https://git.tawasuyu.net/tawasuyu/tawasuyu.git", tag = "v0.1.0" } +llimphi-ui = { git = "https://git.tawasuyu.net/tawasuyu/tawasuyu.git", tag = "v0.2.0" } +# Tier 3 — apps WASM con UI Llimphi real. El guest pinta un WireNode (IR +# serializable), el host (runner) lo materializa en View y rebota los +# eventos al update del guest. SDK = lado guest, runner = lado host. +llimphi-wire-view = { git = "https://git.tawasuyu.net/tawasuyu/tawasuyu.git", tag = "v0.2.0" } +llimphi-wasm-app-sdk = { git = "https://git.tawasuyu.net/tawasuyu/tawasuyu.git", tag = "v0.2.0" } +llimphi-wasm-runner = { git = "https://git.tawasuyu.net/tawasuyu/tawasuyu.git", tag = "v0.2.0" } +# Núcleo puro de distribución (CAS + verificación + resolve), SIN runner/GPU. +llimphi-wasm-core = { git = "https://git.tawasuyu.net/tawasuyu/tawasuyu.git", tag = "v0.2.0" } +# Cara con baterías: re-exporta core + puente al runner para correr la app. +llimphi-wasm-dist = { git = "https://git.tawasuyu.net/tawasuyu/tawasuyu.git", tag = "v0.2.0" } +llimphi-wasm-open = { git = "https://git.tawasuyu.net/tawasuyu/tawasuyu.git", tag = "v0.2.0" } +llimphi-wasm-registry = { git = "https://git.tawasuyu.net/tawasuyu/tawasuyu.git", tag = "v0.2.0" } +llimphi-wasm-wasi = { git = "https://git.tawasuyu.net/tawasuyu/tawasuyu.git", tag = "v0.2.0" } +# Transporte P2P del bytecode por hash sobre BrahmanNet (card-net). +llimphi-wasm-net = { git = "https://git.tawasuyu.net/tawasuyu/tawasuyu.git", tag = "v0.2.0" } +llimphi-3d = { git = "https://git.tawasuyu.net/tawasuyu/tawasuyu.git", tag = "v0.2.0" } +# Runner wlr-layer-shell: corre un `App` de Llimphi como barra anclada a un +# borde (no como ventana), reusando la plumbing sctk+wgpu que pata probó. +llimphi-layer = { git = "https://git.tawasuyu.net/tawasuyu/tawasuyu.git", tag = "v0.2.0" } # Paleta semántica compartida por las apps y los widgets. -llimphi-theme = { git = "https://git.tawasuyu.net/tawasuyu/tawasuyu.git", tag = "v0.1.0" } +llimphi-theme = { git = "https://git.tawasuyu.net/tawasuyu/tawasuyu.git", tag = "v0.2.0" } # Tweens y helpers de animación sobre el bucle Elm. -llimphi-motion = { git = "https://git.tawasuyu.net/tawasuyu/tawasuyu.git", tag = "v0.1.0" } +llimphi-motion = { git = "https://git.tawasuyu.net/tawasuyu/tawasuyu.git", tag = "v0.2.0" } +# Máquina de estados de animación (estilo Rive) clip-agnóstica. Núcleo puro; +# el render lo cablea el consumidor (llimphi-lottie). Escalón sobre el playback. +llimphi-anim = { git = "https://git.tawasuyu.net/tawasuyu/tawasuyu.git", tag = "v0.2.0" } +# Studio de animación «rive»: además del editor (bin), expone como lib los +# documentos serializables (Doc + RigDoc + Project) que carga mirada-fondo. +llimphi-anim-studio = { git = "https://git.tawasuyu.net/tawasuyu/tawasuyu.git", tag = "v0.2.0" } +# Render de mallas deformables (skel::Mesh) a vello Scene: vectorial y texturizada. +llimphi-mesh = { git = "https://git.tawasuyu.net/tawasuyu/tawasuyu.git", tag = "v0.2.0" } # Iconos vectoriales (BezPath en grid 24×24) compartidos por todas las apps. -llimphi-icons = { git = "https://git.tawasuyu.net/tawasuyu/tawasuyu.git", tag = "v0.1.0" } +llimphi-icons = { git = "https://git.tawasuyu.net/tawasuyu/tawasuyu.git", tag = "v0.2.0" } +# Puente fino vello_svg → Llimphi para SVG arbitrario (íconos .desktop, logos). +llimphi-svg = { git = "https://git.tawasuyu.net/tawasuyu/tawasuyu.git", tag = "v0.2.0" } +# Puente fino velato (Lottie) → Llimphi para animación vectorial autorada (.json). +llimphi-lottie = { git = "https://git.tawasuyu.net/tawasuyu/tawasuyu.git", tag = "v0.2.0" } +llimphi-image = { git = "https://git.tawasuyu.net/tawasuyu/tawasuyu.git", tag = "v0.2.0" } +# Decodificador de protocolos de gráficos de terminal (kitty/sixel) → RGBA. +llimphi-term-graphics = { git = "https://git.tawasuyu.net/tawasuyu/tawasuyu.git", tag = "v0.2.0" } # Widgets reusables sobre llimphi-ui — uno por crate. -llimphi-widget-app-header = { git = "https://git.tawasuyu.net/tawasuyu/tawasuyu.git", tag = "v0.1.0" } -llimphi-widget-banner = { git = "https://git.tawasuyu.net/tawasuyu/tawasuyu.git", tag = "v0.1.0" } -llimphi-widget-button = { git = "https://git.tawasuyu.net/tawasuyu/tawasuyu.git", tag = "v0.1.0" } -llimphi-widget-card = { git = "https://git.tawasuyu.net/tawasuyu/tawasuyu.git", tag = "v0.1.0" } -llimphi-clipboard = { git = "https://git.tawasuyu.net/tawasuyu/tawasuyu.git", tag = "v0.1.0" } -llimphi-widget-context-menu = { git = "https://git.tawasuyu.net/tawasuyu/tawasuyu.git", tag = "v0.1.0" } -llimphi-widget-edit-menu = { git = "https://git.tawasuyu.net/tawasuyu/tawasuyu.git", tag = "v0.1.0" } -llimphi-widget-menubar = { git = "https://git.tawasuyu.net/tawasuyu/tawasuyu.git", tag = "v0.1.0" } -llimphi-widget-list = { git = "https://git.tawasuyu.net/tawasuyu/tawasuyu.git", tag = "v0.1.0" } -llimphi-widget-grid = { git = "https://git.tawasuyu.net/tawasuyu/tawasuyu.git", tag = "v0.1.0" } -llimphi-widget-slider = { git = "https://git.tawasuyu.net/tawasuyu/tawasuyu.git", tag = "v0.1.0" } -llimphi-widget-scroll = { git = "https://git.tawasuyu.net/tawasuyu/tawasuyu.git", tag = "v0.1.0" } -llimphi-widget-splitter = { git = "https://git.tawasuyu.net/tawasuyu/tawasuyu.git", tag = "v0.1.0" } -llimphi-widget-stat-card = { git = "https://git.tawasuyu.net/tawasuyu/tawasuyu.git", tag = "v0.1.0" } -llimphi-widget-tabs = { git = "https://git.tawasuyu.net/tawasuyu/tawasuyu.git", tag = "v0.1.0" } -llimphi-module-command-palette = { git = "https://git.tawasuyu.net/tawasuyu/tawasuyu.git", tag = "v0.1.0" } -llimphi-module-diff-viewer = { git = "https://git.tawasuyu.net/tawasuyu/tawasuyu.git", tag = "v0.1.0" } -llimphi-module-fif = { git = "https://git.tawasuyu.net/tawasuyu/tawasuyu.git", tag = "v0.1.0" } -llimphi-module-file-picker = { git = "https://git.tawasuyu.net/tawasuyu/tawasuyu.git", tag = "v0.1.0" } -llimphi-module-bookmarks = { git = "https://git.tawasuyu.net/tawasuyu/tawasuyu.git", tag = "v0.1.0" } -llimphi-module-mini-map = { git = "https://git.tawasuyu.net/tawasuyu/tawasuyu.git", tag = "v0.1.0" } -llimphi-module-shuma-term = { git = "https://git.tawasuyu.net/tawasuyu/tawasuyu.git", tag = "v0.1.0" } -llimphi-module-symbol-outline = { git = "https://git.tawasuyu.net/tawasuyu/tawasuyu.git", tag = "v0.1.0" } -llimphi-plugin-host = { git = "https://git.tawasuyu.net/tawasuyu/tawasuyu.git", tag = "v0.1.0" } -llimphi-widget-theme-switcher = { git = "https://git.tawasuyu.net/tawasuyu/tawasuyu.git", tag = "v0.1.0" } -llimphi-widget-text-area = { git = "https://git.tawasuyu.net/tawasuyu/tawasuyu.git", tag = "v0.1.0" } -llimphi-widget-text-editor-core = { git = "https://git.tawasuyu.net/tawasuyu/tawasuyu.git", tag = "v0.1.0" } -llimphi-widget-text-editor = { git = "https://git.tawasuyu.net/tawasuyu/tawasuyu.git", tag = "v0.1.0" } -llimphi-widget-text-editor-lsp = { git = "https://git.tawasuyu.net/tawasuyu/tawasuyu.git", tag = "v0.1.0" } -llimphi-widget-text-input = { git = "https://git.tawasuyu.net/tawasuyu/tawasuyu.git", tag = "v0.1.0" } -llimphi-widget-tiled = { git = "https://git.tawasuyu.net/tawasuyu/tawasuyu.git", tag = "v0.1.0" } -llimphi-widget-nodegraph = { git = "https://git.tawasuyu.net/tawasuyu/tawasuyu.git", tag = "v0.1.0" } -llimphi-widget-tree = { git = "https://git.tawasuyu.net/tawasuyu/tawasuyu.git", tag = "v0.1.0" } -llimphi-widget-navigator = { git = "https://git.tawasuyu.net/tawasuyu/tawasuyu.git", tag = "v0.1.0" } +llimphi-widget-app-header = { git = "https://git.tawasuyu.net/tawasuyu/tawasuyu.git", tag = "v0.2.0" } +llimphi-widget-banner = { git = "https://git.tawasuyu.net/tawasuyu/tawasuyu.git", tag = "v0.2.0" } +llimphi-widget-button = { git = "https://git.tawasuyu.net/tawasuyu/tawasuyu.git", tag = "v0.2.0" } +llimphi-widget-rive-button = { git = "https://git.tawasuyu.net/tawasuyu/tawasuyu.git", tag = "v0.2.0" } +llimphi-widget-card = { git = "https://git.tawasuyu.net/tawasuyu/tawasuyu.git", tag = "v0.2.0" } +llimphi-clipboard = { git = "https://git.tawasuyu.net/tawasuyu/tawasuyu.git", tag = "v0.2.0" } +llimphi-widget-context-menu = { git = "https://git.tawasuyu.net/tawasuyu/tawasuyu.git", tag = "v0.2.0" } +llimphi-widget-edit-menu = { git = "https://git.tawasuyu.net/tawasuyu/tawasuyu.git", tag = "v0.2.0" } +llimphi-widget-menubar = { git = "https://git.tawasuyu.net/tawasuyu/tawasuyu.git", tag = "v0.2.0" } +llimphi-widget-list = { git = "https://git.tawasuyu.net/tawasuyu/tawasuyu.git", tag = "v0.2.0" } +llimphi-widget-grid = { git = "https://git.tawasuyu.net/tawasuyu/tawasuyu.git", tag = "v0.2.0" } +llimphi-widget-table = { git = "https://git.tawasuyu.net/tawasuyu/tawasuyu.git", tag = "v0.2.0" } +llimphi-widget-color-picker = { git = "https://git.tawasuyu.net/tawasuyu/tawasuyu.git", tag = "v0.2.0" } +llimphi-widget-slider = { git = "https://git.tawasuyu.net/tawasuyu/tawasuyu.git", tag = "v0.2.0" } +llimphi-widget-scroll = { git = "https://git.tawasuyu.net/tawasuyu/tawasuyu.git", tag = "v0.2.0" } +llimphi-widget-lazy-list = { git = "https://git.tawasuyu.net/tawasuyu/tawasuyu.git", tag = "v0.2.0" } +llimphi-widget-splitter = { git = "https://git.tawasuyu.net/tawasuyu/tawasuyu.git", tag = "v0.2.0" } +llimphi-widget-stat-card = { git = "https://git.tawasuyu.net/tawasuyu/tawasuyu.git", tag = "v0.2.0" } +llimphi-widget-tabs = { git = "https://git.tawasuyu.net/tawasuyu/tawasuyu.git", tag = "v0.2.0" } +llimphi-module-allichay = { git = "https://git.tawasuyu.net/tawasuyu/tawasuyu.git", tag = "v0.2.0" } +llimphi-module-command-palette = { git = "https://git.tawasuyu.net/tawasuyu/tawasuyu.git", tag = "v0.2.0" } +llimphi-module-diff-viewer = { git = "https://git.tawasuyu.net/tawasuyu/tawasuyu.git", tag = "v0.2.0" } +llimphi-module-fif = { git = "https://git.tawasuyu.net/tawasuyu/tawasuyu.git", tag = "v0.2.0" } +llimphi-module-file-picker = { git = "https://git.tawasuyu.net/tawasuyu/tawasuyu.git", tag = "v0.2.0" } +llimphi-module-bookmarks = { git = "https://git.tawasuyu.net/tawasuyu/tawasuyu.git", tag = "v0.2.0" } +llimphi-module-mini-map = { git = "https://git.tawasuyu.net/tawasuyu/tawasuyu.git", tag = "v0.2.0" } +llimphi-module-shuma-term = { git = "https://git.tawasuyu.net/tawasuyu/tawasuyu.git", tag = "v0.2.0" } +llimphi-module-symbol-outline = { git = "https://git.tawasuyu.net/tawasuyu/tawasuyu.git", tag = "v0.2.0" } +llimphi-plugin-host = { git = "https://git.tawasuyu.net/tawasuyu/tawasuyu.git", tag = "v0.2.0" } +llimphi-widget-theme-switcher = { git = "https://git.tawasuyu.net/tawasuyu/tawasuyu.git", tag = "v0.2.0" } +llimphi-widget-text-area = { git = "https://git.tawasuyu.net/tawasuyu/tawasuyu.git", tag = "v0.2.0" } +llimphi-widget-text-editor-core = { git = "https://git.tawasuyu.net/tawasuyu/tawasuyu.git", tag = "v0.2.0" } +llimphi-widget-text-editor = { git = "https://git.tawasuyu.net/tawasuyu/tawasuyu.git", tag = "v0.2.0" } +llimphi-widget-text-editor-lsp = { git = "https://git.tawasuyu.net/tawasuyu/tawasuyu.git", tag = "v0.2.0" } +llimphi-widget-text-input = { git = "https://git.tawasuyu.net/tawasuyu/tawasuyu.git", tag = "v0.2.0" } +llimphi-widget-tiled = { git = "https://git.tawasuyu.net/tawasuyu/tawasuyu.git", tag = "v0.2.0" } +llimphi-widget-nodegraph = { git = "https://git.tawasuyu.net/tawasuyu/tawasuyu.git", tag = "v0.2.0" } +llimphi-widget-tree = { git = "https://git.tawasuyu.net/tawasuyu/tawasuyu.git", tag = "v0.2.0" } +grafo-nav = { git = "https://git.tawasuyu.net/tawasuyu/tawasuyu.git", tag = "v0.2.0" } +llimphi-widget-navigator = { git = "https://git.tawasuyu.net/tawasuyu/tawasuyu.git", tag = "v0.2.0" } +llimphi-widget-detail-table = { git = "https://git.tawasuyu.net/tawasuyu/tawasuyu.git", tag = "v0.2.0" } +llimphi-widget-select = { git = "https://git.tawasuyu.net/tawasuyu/tawasuyu.git", tag = "v0.2.0" } +llimphi-widget-terminal = { git = "https://git.tawasuyu.net/tawasuyu/tawasuyu.git", tag = "v0.2.0" } # Sello vectorial wawa (rombo + W implícita + Merkle Core). -llimphi-widget-wawa-mark = { git = "https://git.tawasuyu.net/tawasuyu/tawasuyu.git", tag = "v0.1.0" } +llimphi-widget-wawa-mark = { git = "https://git.tawasuyu.net/tawasuyu/tawasuyu.git", tag = "v0.2.0" } # Widgets de elegancia transversal (tooltip, spinner, progress, toast, # modal, empty, status-bar, shortcuts-help, splash). -llimphi-widget-tooltip = { git = "https://git.tawasuyu.net/tawasuyu/tawasuyu.git", tag = "v0.1.0" } -llimphi-widget-spinner = { git = "https://git.tawasuyu.net/tawasuyu/tawasuyu.git", tag = "v0.1.0" } -llimphi-widget-progress = { git = "https://git.tawasuyu.net/tawasuyu/tawasuyu.git", tag = "v0.1.0" } -llimphi-widget-toast = { git = "https://git.tawasuyu.net/tawasuyu/tawasuyu.git", tag = "v0.1.0" } -llimphi-widget-modal = { git = "https://git.tawasuyu.net/tawasuyu/tawasuyu.git", tag = "v0.1.0" } -llimphi-widget-empty = { git = "https://git.tawasuyu.net/tawasuyu/tawasuyu.git", tag = "v0.1.0" } -llimphi-widget-status-bar = { git = "https://git.tawasuyu.net/tawasuyu/tawasuyu.git", tag = "v0.1.0" } -llimphi-widget-shortcuts-help = { git = "https://git.tawasuyu.net/tawasuyu/tawasuyu.git", tag = "v0.1.0" } -llimphi-widget-timeline = { git = "https://git.tawasuyu.net/tawasuyu/tawasuyu.git", tag = "v0.1.0" } -llimphi-widget-splash = { git = "https://git.tawasuyu.net/tawasuyu/tawasuyu.git", tag = "v0.1.0" } +llimphi-widget-tooltip = { git = "https://git.tawasuyu.net/tawasuyu/tawasuyu.git", tag = "v0.2.0" } +llimphi-widget-spinner = { git = "https://git.tawasuyu.net/tawasuyu/tawasuyu.git", tag = "v0.2.0" } +llimphi-widget-progress = { git = "https://git.tawasuyu.net/tawasuyu/tawasuyu.git", tag = "v0.2.0" } +llimphi-widget-toast = { git = "https://git.tawasuyu.net/tawasuyu/tawasuyu.git", tag = "v0.2.0" } +llimphi-widget-modal = { git = "https://git.tawasuyu.net/tawasuyu/tawasuyu.git", tag = "v0.2.0" } +llimphi-widget-empty = { git = "https://git.tawasuyu.net/tawasuyu/tawasuyu.git", tag = "v0.2.0" } +llimphi-widget-status-bar = { git = "https://git.tawasuyu.net/tawasuyu/tawasuyu.git", tag = "v0.2.0" } +llimphi-widget-shortcuts-help = { git = "https://git.tawasuyu.net/tawasuyu/tawasuyu.git", tag = "v0.2.0" } +llimphi-widget-timeline = { git = "https://git.tawasuyu.net/tawasuyu/tawasuyu.git", tag = "v0.2.0" } +llimphi-widget-transport = { git = "https://git.tawasuyu.net/tawasuyu/tawasuyu.git", tag = "v0.2.0" } +llimphi-widget-waveform = { git = "https://git.tawasuyu.net/tawasuyu/tawasuyu.git", tag = "v0.2.0" } +llimphi-widget-splash = { git = "https://git.tawasuyu.net/tawasuyu/tawasuyu.git", tag = "v0.2.0" } # Controles de formulario y signaling (switch, segmented, breadcrumb, # badge, avatar, skeleton, field). -llimphi-widget-switch = { git = "https://git.tawasuyu.net/tawasuyu/tawasuyu.git", tag = "v0.1.0" } -llimphi-widget-segmented = { git = "https://git.tawasuyu.net/tawasuyu/tawasuyu.git", tag = "v0.1.0" } -llimphi-widget-dock-rail = { git = "https://git.tawasuyu.net/tawasuyu/tawasuyu.git", tag = "v0.1.0" } -llimphi-widget-breadcrumb = { git = "https://git.tawasuyu.net/tawasuyu/tawasuyu.git", tag = "v0.1.0" } -llimphi-widget-badge = { git = "https://git.tawasuyu.net/tawasuyu/tawasuyu.git", tag = "v0.1.0" } -llimphi-widget-avatar = { git = "https://git.tawasuyu.net/tawasuyu/tawasuyu.git", tag = "v0.1.0" } -llimphi-widget-skeleton = { git = "https://git.tawasuyu.net/tawasuyu/tawasuyu.git", tag = "v0.1.0" } -llimphi-widget-field = { git = "https://git.tawasuyu.net/tawasuyu/tawasuyu.git", tag = "v0.1.0" } +llimphi-widget-switch = { git = "https://git.tawasuyu.net/tawasuyu/tawasuyu.git", tag = "v0.2.0" } +llimphi-widget-segmented = { git = "https://git.tawasuyu.net/tawasuyu/tawasuyu.git", tag = "v0.2.0" } +llimphi-widget-rag-sidebar = { git = "https://git.tawasuyu.net/tawasuyu/tawasuyu.git", tag = "v0.2.0" } +llimphi-widget-dock-rail = { git = "https://git.tawasuyu.net/tawasuyu/tawasuyu.git", tag = "v0.2.0" } +llimphi-widget-toolbar = { git = "https://git.tawasuyu.net/tawasuyu/tawasuyu.git", tag = "v0.2.0" } +llimphi-widget-breadcrumb = { git = "https://git.tawasuyu.net/tawasuyu/tawasuyu.git", tag = "v0.2.0" } +llimphi-widget-badge = { git = "https://git.tawasuyu.net/tawasuyu/tawasuyu.git", tag = "v0.2.0" } +llimphi-widget-avatar = { git = "https://git.tawasuyu.net/tawasuyu/tawasuyu.git", tag = "v0.2.0" } +llimphi-widget-skeleton = { git = "https://git.tawasuyu.net/tawasuyu/tawasuyu.git", tag = "v0.2.0" } +llimphi-widget-field = { git = "https://git.tawasuyu.net/tawasuyu/tawasuyu.git", tag = "v0.2.0" } # Firma visual transversal (gradient sutil + hairline accent). -llimphi-widget-panel = { git = "https://git.tawasuyu.net/tawasuyu/tawasuyu.git", tag = "v0.1.0" } -llimphi-widget-panes = { git = "https://git.tawasuyu.net/tawasuyu/tawasuyu.git", tag = "v0.1.0" } -llimphi-workspace = { git = "https://git.tawasuyu.net/tawasuyu/tawasuyu.git", tag = "v0.1.0" } +llimphi-widget-panel = { git = "https://git.tawasuyu.net/tawasuyu/tawasuyu.git", tag = "v0.2.0" } +llimphi-widget-panes = { git = "https://git.tawasuyu.net/tawasuyu/tawasuyu.git", tag = "v0.2.0" } +# Widgets Flutter-like — composición sobre primitivas Tier 1/3 ya +# expuestas (sombra, gradient, animated, ripple). +llimphi-widget-chip = { git = "https://git.tawasuyu.net/tawasuyu/tawasuyu.git", tag = "v0.2.0" } +llimphi-widget-fab = { git = "https://git.tawasuyu.net/tawasuyu/tawasuyu.git", tag = "v0.2.0" } +llimphi-widget-wrap = { git = "https://git.tawasuyu.net/tawasuyu/tawasuyu.git", tag = "v0.2.0" } +llimphi-widget-range-slider = { git = "https://git.tawasuyu.net/tawasuyu/tawasuyu.git", tag = "v0.2.0" } +llimphi-widget-calendar = { git = "https://git.tawasuyu.net/tawasuyu/tawasuyu.git", tag = "v0.2.0" } +llimphi-widget-fitted-box = { git = "https://git.tawasuyu.net/tawasuyu/tawasuyu.git", tag = "v0.2.0" } +llimphi-widget-carousel = { git = "https://git.tawasuyu.net/tawasuyu/tawasuyu.git", tag = "v0.2.0" } +llimphi-widget-rating = { git = "https://git.tawasuyu.net/tawasuyu/tawasuyu.git", tag = "v0.2.0" } +llimphi-widget-gauge = { git = "https://git.tawasuyu.net/tawasuyu/tawasuyu.git", tag = "v0.2.0" } +llimphi-widget-scaffold = { git = "https://git.tawasuyu.net/tawasuyu/tawasuyu.git", tag = "v0.2.0" } +llimphi-widget-hero = { git = "https://git.tawasuyu.net/tawasuyu/tawasuyu.git", tag = "v0.2.0" } +llimphi-widget-router = { git = "https://git.tawasuyu.net/tawasuyu/tawasuyu.git", tag = "v0.2.0" } +llimphi-workspace = { git = "https://git.tawasuyu.net/tawasuyu/tawasuyu.git", tag = "v0.2.0" } # Abstracción Selector — host (paths) + wawa (khipus). -llimphi-module-selector = { git = "https://git.tawasuyu.net/tawasuyu/tawasuyu.git", tag = "v0.1.0" } +llimphi-module-selector = { git = "https://git.tawasuyu.net/tawasuyu/tawasuyu.git", tag = "v0.2.0" } # === Filesystem helpers === directories = "5" @@ -293,7 +439,7 @@ hex = "0.4" portable-pty = "0.9" vt100 = "0.16" -# === WASM web (gioser) === +# === WASM web (tawasuyu) === wasm-bindgen = "0.2" wasm-bindgen-futures = "0.4" js-sys = "0.3" @@ -318,61 +464,92 @@ ttf-parser = "0.25" # ============================================================ # Intra-workspace deps de nahual (referenciadas por workspace = true) # ============================================================ -nahual-text-viewer-llimphi = { git = "https://git.tawasuyu.net/tawasuyu/tawasuyu.git", tag = "v0.1.0" } -nahual-image-viewer-llimphi = { git = "https://git.tawasuyu.net/tawasuyu/tawasuyu.git", tag = "v0.1.0" } -nahual-thumb-core = { git = "https://git.tawasuyu.net/tawasuyu/tawasuyu.git", tag = "v0.1.0" } -nahual-gallery-llimphi = { git = "https://git.tawasuyu.net/tawasuyu/tawasuyu.git", tag = "v0.1.0" } -nahual-video-viewer-llimphi = { git = "https://git.tawasuyu.net/tawasuyu/tawasuyu.git", tag = "v0.1.0" } -nahual-card-viewer-llimphi = { git = "https://git.tawasuyu.net/tawasuyu/tawasuyu.git", tag = "v0.1.0" } -nahual-audio-viewer-llimphi = { git = "https://git.tawasuyu.net/tawasuyu/tawasuyu.git", tag = "v0.1.0" } -nahual-tree-viewer-llimphi = { git = "https://git.tawasuyu.net/tawasuyu/tawasuyu.git", tag = "v0.1.0" } -nahual-hex-viewer-llimphi = { git = "https://git.tawasuyu.net/tawasuyu/tawasuyu.git", tag = "v0.1.0" } -nahual-table-viewer-llimphi = { git = "https://git.tawasuyu.net/tawasuyu/tawasuyu.git", tag = "v0.1.0" } -nahual-markdown-viewer-llimphi = { git = "https://git.tawasuyu.net/tawasuyu/tawasuyu.git", tag = "v0.1.0" } -nahual-archive-viewer-llimphi = { git = "https://git.tawasuyu.net/tawasuyu/tawasuyu.git", tag = "v0.1.0" } -nahual-font-viewer-llimphi = { git = "https://git.tawasuyu.net/tawasuyu/tawasuyu.git", tag = "v0.1.0" } -nahual-map-viewer-llimphi = { git = "https://git.tawasuyu.net/tawasuyu/tawasuyu.git", tag = "v0.1.0" } -nahual-geo-core = { git = "https://git.tawasuyu.net/tawasuyu/tawasuyu.git", tag = "v0.1.0" } -nahual-viewer-core = { git = "https://git.tawasuyu.net/tawasuyu/tawasuyu.git", tag = "v0.1.0" } -nahual-file-explorer-llimphi = { git = "https://git.tawasuyu.net/tawasuyu/tawasuyu.git", tag = "v0.1.0" } +nahual-text-viewer-llimphi = { git = "https://git.tawasuyu.net/tawasuyu/tawasuyu.git", tag = "v0.2.0" } +nahual-image-viewer-llimphi = { git = "https://git.tawasuyu.net/tawasuyu/tawasuyu.git", tag = "v0.2.0" } +nahual-pdf-viewer-llimphi = { git = "https://git.tawasuyu.net/tawasuyu/tawasuyu.git", tag = "v0.2.0" } +nahual-cbz-viewer-llimphi = { git = "https://git.tawasuyu.net/tawasuyu/tawasuyu.git", tag = "v0.2.0" } +nahual-thumb-core = { git = "https://git.tawasuyu.net/tawasuyu/tawasuyu.git", tag = "v0.2.0" } +nahual-gallery-llimphi = { git = "https://git.tawasuyu.net/tawasuyu/tawasuyu.git", tag = "v0.2.0" } +nahual-video-viewer-llimphi = { git = "https://git.tawasuyu.net/tawasuyu/tawasuyu.git", tag = "v0.2.0" } +nahual-card-viewer-llimphi = { git = "https://git.tawasuyu.net/tawasuyu/tawasuyu.git", tag = "v0.2.0" } +nahual-audio-viewer-llimphi = { git = "https://git.tawasuyu.net/tawasuyu/tawasuyu.git", tag = "v0.2.0" } +media-module = { git = "https://git.tawasuyu.net/tawasuyu/tawasuyu.git", tag = "v0.2.0" } +tullpu-module = { git = "https://git.tawasuyu.net/tawasuyu/tawasuyu.git", tag = "v0.2.0" } +tullpu-core = { git = "https://git.tawasuyu.net/tawasuyu/tawasuyu.git", tag = "v0.2.0" } +tullpu-icon-core = { git = "https://git.tawasuyu.net/tawasuyu/tawasuyu.git", tag = "v0.2.0" } +tullpu-icon-cli = { git = "https://git.tawasuyu.net/tawasuyu/tawasuyu.git", tag = "v0.2.0" } +tullpu-icon-llimphi = { git = "https://git.tawasuyu.net/tawasuyu/tawasuyu.git", tag = "v0.2.0" } +tullpu-icon-llm = { git = "https://git.tawasuyu.net/tawasuyu/tawasuyu.git", tag = "v0.2.0" } +foreign-svg = { git = "https://git.tawasuyu.net/tawasuyu/tawasuyu.git", tag = "v0.2.0" } +foreign-pdf = { git = "https://git.tawasuyu.net/tawasuyu/tawasuyu.git", tag = "v0.2.0" } +tullpu-render = { git = "https://git.tawasuyu.net/tawasuyu/tawasuyu.git", tag = "v0.2.0" } +tullpu-render-gpu = { git = "https://git.tawasuyu.net/tawasuyu/tawasuyu.git", tag = "v0.2.0" } +tullpu-ops = { git = "https://git.tawasuyu.net/tawasuyu/tawasuyu.git", tag = "v0.2.0" } +tullpu-paint = { git = "https://git.tawasuyu.net/tawasuyu/tawasuyu.git", tag = "v0.2.0" } +nahual-tree-viewer-llimphi = { git = "https://git.tawasuyu.net/tawasuyu/tawasuyu.git", tag = "v0.2.0" } +nahual-hex-viewer-llimphi = { git = "https://git.tawasuyu.net/tawasuyu/tawasuyu.git", tag = "v0.2.0" } +nahual-svg-viewer-llimphi = { git = "https://git.tawasuyu.net/tawasuyu/tawasuyu.git", tag = "v0.2.0" } +nahual-sheet-viewer-llimphi = { git = "https://git.tawasuyu.net/tawasuyu/tawasuyu.git", tag = "v0.2.0" } +nahual-dbf-viewer-llimphi = { git = "https://git.tawasuyu.net/tawasuyu/tawasuyu.git", tag = "v0.2.0" } +nahual-table-viewer-llimphi = { git = "https://git.tawasuyu.net/tawasuyu/tawasuyu.git", tag = "v0.2.0" } +nahual-markdown-viewer-llimphi = { git = "https://git.tawasuyu.net/tawasuyu/tawasuyu.git", tag = "v0.2.0" } +nahual-pluma-viewer-llimphi = { git = "https://git.tawasuyu.net/tawasuyu/tawasuyu.git", tag = "v0.2.0" } +nahual-cotejo-llimphi = { git = "https://git.tawasuyu.net/tawasuyu/tawasuyu.git", tag = "v0.2.0" } +nahual-deck-viewer-llimphi = { git = "https://git.tawasuyu.net/tawasuyu/tawasuyu.git", tag = "v0.2.0" } +nahual-archive-viewer-llimphi = { git = "https://git.tawasuyu.net/tawasuyu/tawasuyu.git", tag = "v0.2.0" } +nahual-font-viewer-llimphi = { git = "https://git.tawasuyu.net/tawasuyu/tawasuyu.git", tag = "v0.2.0" } +nahual-map-viewer-llimphi = { git = "https://git.tawasuyu.net/tawasuyu/tawasuyu.git", tag = "v0.2.0" } +nahual-geo-core = { git = "https://git.tawasuyu.net/tawasuyu/tawasuyu.git", tag = "v0.2.0" } +nahual-geo-voxel = { git = "https://git.tawasuyu.net/tawasuyu/tawasuyu.git", tag = "v0.2.0" } +nahual-iconos = { git = "https://git.tawasuyu.net/tawasuyu/tawasuyu.git", tag = "v0.2.0" } +nahual-viewer-core = { git = "https://git.tawasuyu.net/tawasuyu/tawasuyu.git", tag = "v0.2.0" } +nahual-file-explorer-llimphi = { git = "https://git.tawasuyu.net/tawasuyu/tawasuyu.git", tag = "v0.2.0" } # ============================================================ # Intra-workspace deps de pineal (módulo de gráficos) # ============================================================ -pineal-core = { git = "https://git.tawasuyu.net/tawasuyu/tawasuyu.git", tag = "v0.1.0" } -pineal-render = { git = "https://git.tawasuyu.net/tawasuyu/tawasuyu.git", tag = "v0.1.0" } -pineal-cartesian = { git = "https://git.tawasuyu.net/tawasuyu/tawasuyu.git", tag = "v0.1.0" } -pineal-stream = { git = "https://git.tawasuyu.net/tawasuyu/tawasuyu.git", tag = "v0.1.0" } -pineal-mesh = { git = "https://git.tawasuyu.net/tawasuyu/tawasuyu.git", tag = "v0.1.0" } -pineal-financial = { git = "https://git.tawasuyu.net/tawasuyu/tawasuyu.git", tag = "v0.1.0" } -pineal-polar = { git = "https://git.tawasuyu.net/tawasuyu/tawasuyu.git", tag = "v0.1.0" } -pineal-heatmap = { git = "https://git.tawasuyu.net/tawasuyu/tawasuyu.git", tag = "v0.1.0" } -pineal-treemap = { git = "https://git.tawasuyu.net/tawasuyu/tawasuyu.git", tag = "v0.1.0" } -pineal-flow = { git = "https://git.tawasuyu.net/tawasuyu/tawasuyu.git", tag = "v0.1.0" } -pineal-phosphor = { git = "https://git.tawasuyu.net/tawasuyu/tawasuyu.git", tag = "v0.1.0" } -pineal-export = { git = "https://git.tawasuyu.net/tawasuyu/tawasuyu.git", tag = "v0.1.0" } -pineal-hexbin = { git = "https://git.tawasuyu.net/tawasuyu/tawasuyu.git", tag = "v0.1.0" } -pineal-contour = { git = "https://git.tawasuyu.net/tawasuyu/tawasuyu.git", tag = "v0.1.0" } -pineal-bars = { git = "https://git.tawasuyu.net/tawasuyu/tawasuyu.git", tag = "v0.1.0" } -pineal = { git = "https://git.tawasuyu.net/tawasuyu/tawasuyu.git", tag = "v0.1.0" } +pineal-core = { git = "https://git.tawasuyu.net/tawasuyu/tawasuyu.git", tag = "v0.2.0" } +pineal-render = { git = "https://git.tawasuyu.net/tawasuyu/tawasuyu.git", tag = "v0.2.0" } +pineal-cartesian = { git = "https://git.tawasuyu.net/tawasuyu/tawasuyu.git", tag = "v0.2.0" } +pineal-stream = { git = "https://git.tawasuyu.net/tawasuyu/tawasuyu.git", tag = "v0.2.0" } +pineal-mesh = { git = "https://git.tawasuyu.net/tawasuyu/tawasuyu.git", tag = "v0.2.0" } +pineal-financial = { git = "https://git.tawasuyu.net/tawasuyu/tawasuyu.git", tag = "v0.2.0" } +pineal-polar = { git = "https://git.tawasuyu.net/tawasuyu/tawasuyu.git", tag = "v0.2.0" } +pineal-heatmap = { git = "https://git.tawasuyu.net/tawasuyu/tawasuyu.git", tag = "v0.2.0" } +pineal-treemap = { git = "https://git.tawasuyu.net/tawasuyu/tawasuyu.git", tag = "v0.2.0" } +pineal-flow = { git = "https://git.tawasuyu.net/tawasuyu/tawasuyu.git", tag = "v0.2.0" } +pineal-phosphor = { git = "https://git.tawasuyu.net/tawasuyu/tawasuyu.git", tag = "v0.2.0" } +pineal-export = { git = "https://git.tawasuyu.net/tawasuyu/tawasuyu.git", tag = "v0.2.0" } +pineal-hexbin = { git = "https://git.tawasuyu.net/tawasuyu/tawasuyu.git", tag = "v0.2.0" } +pineal-contour = { git = "https://git.tawasuyu.net/tawasuyu/tawasuyu.git", tag = "v0.2.0" } +pineal-bars = { git = "https://git.tawasuyu.net/tawasuyu/tawasuyu.git", tag = "v0.2.0" } +pineal = { git = "https://git.tawasuyu.net/tawasuyu/tawasuyu.git", tag = "v0.2.0" } # ============================================================ # Intra-workspace deps de iniy (laboratorio semántico de creencias) # ============================================================ -iniy-core = { git = "https://git.tawasuyu.net/tawasuyu/tawasuyu.git", tag = "v0.1.0" } -iniy-ingest = { git = "https://git.tawasuyu.net/tawasuyu/tawasuyu.git", tag = "v0.1.0" } -iniy-extract = { git = "https://git.tawasuyu.net/tawasuyu/tawasuyu.git", tag = "v0.1.0" } -iniy-nli = { git = "https://git.tawasuyu.net/tawasuyu/tawasuyu.git", tag = "v0.1.0" } -iniy-nli-llm = { git = "https://git.tawasuyu.net/tawasuyu/tawasuyu.git", tag = "v0.1.0" } -iniy-graph = { git = "https://git.tawasuyu.net/tawasuyu/tawasuyu.git", tag = "v0.1.0" } -iniy-store = { git = "https://git.tawasuyu.net/tawasuyu/tawasuyu.git", tag = "v0.1.0" } +iniy-core = { git = "https://git.tawasuyu.net/tawasuyu/tawasuyu.git", tag = "v0.2.0" } +iniy-evidencia = { git = "https://git.tawasuyu.net/tawasuyu/tawasuyu.git", tag = "v0.2.0" } +iniy-derive = { git = "https://git.tawasuyu.net/tawasuyu/tawasuyu.git", tag = "v0.2.0" } +iniy-emisores = { git = "https://git.tawasuyu.net/tawasuyu/tawasuyu.git", tag = "v0.2.0" } +iniy-cache = { git = "https://git.tawasuyu.net/tawasuyu/tawasuyu.git", tag = "v0.2.0" } +iniy-daemon = { git = "https://git.tawasuyu.net/tawasuyu/tawasuyu.git", tag = "v0.2.0" } +iniy-ingest = { git = "https://git.tawasuyu.net/tawasuyu/tawasuyu.git", tag = "v0.2.0" } +iniy-extract = { git = "https://git.tawasuyu.net/tawasuyu/tawasuyu.git", tag = "v0.2.0" } +iniy-nli = { git = "https://git.tawasuyu.net/tawasuyu/tawasuyu.git", tag = "v0.2.0" } +iniy-nli-llm = { git = "https://git.tawasuyu.net/tawasuyu/tawasuyu.git", tag = "v0.2.0" } +iniy-graph = { git = "https://git.tawasuyu.net/tawasuyu/tawasuyu.git", tag = "v0.2.0" } +iniy-store = { git = "https://git.tawasuyu.net/tawasuyu/tawasuyu.git", tag = "v0.2.0" } +# la física de decaimiento de khipu la importa iniy-derive (SDD iniy §3) +khipu-gravity = { git = "https://git.tawasuyu.net/tawasuyu/tawasuyu.git", tag = "v0.2.0" } # === auto: declarados por crates internos faltantes === -cosmos-coords = { git = "https://git.tawasuyu.net/tawasuyu/tawasuyu.git", tag = "v0.1.0" } -cosmos-core = { git = "https://git.tawasuyu.net/tawasuyu/tawasuyu.git", tag = "v0.1.0" } -cosmos-ephemeris = { git = "https://git.tawasuyu.net/tawasuyu/tawasuyu.git", tag = "v0.1.0" } -cosmos-time = { git = "https://git.tawasuyu.net/tawasuyu/tawasuyu.git", tag = "v0.1.0" } -cosmos-wcs = { git = "https://git.tawasuyu.net/tawasuyu/tawasuyu.git", tag = "v0.1.0" } +cosmos-coords = { git = "https://git.tawasuyu.net/tawasuyu/tawasuyu.git", tag = "v0.2.0" } +cosmos-cities = { git = "https://git.tawasuyu.net/tawasuyu/tawasuyu.git", tag = "v0.2.0" } +cosmos-core = { git = "https://git.tawasuyu.net/tawasuyu/tawasuyu.git", tag = "v0.2.0" } +cosmos-ephemeris = { git = "https://git.tawasuyu.net/tawasuyu/tawasuyu.git", tag = "v0.2.0" } +cosmos-time = { git = "https://git.tawasuyu.net/tawasuyu/tawasuyu.git", tag = "v0.2.0" } +cosmos-wcs = { git = "https://git.tawasuyu.net/tawasuyu/tawasuyu.git", tag = "v0.2.0" } # === auto: externas de eternal === celestial-eop-data = { version = "0.1"} @@ -380,6 +557,7 @@ approx = "0.5" byteorder = "1.5" cc = "1.0" chrono = "0.4" +chrono-tz = "0.10" crc32fast = "1.4" criterion = "0.5" csv = "1.4" @@ -388,6 +566,11 @@ glob = "0.3" indicatif = "0.18" lz4_flex = "0.11" memmap2 = "0.9" +# Cliente Wayland (lado cliente) — lo usa hapiy para hablar zwlr_screencopy +# contra mirada (u otro compositor wlroots) y capturar la pantalla. +wayland-client = "0.31" +wayland-protocols-wlr = { version = "0.3", features = ["client"] } +wayland-protocols-misc = { version = "0.3", features = ["client"] } mockito = "1.0" ndarray = "0.15" num-traits = "0.2" @@ -421,6 +604,9 @@ markup5ever_rcdom = "0.39" cssparser = "0.35" url = "2" ureq = { version = "2", default-features = false, features = ["tls"] } +# Charset de la web real: no todo es UTF-8 (google.com sirve ISO-8859-1). +# Ya venía transitivo por el stack de Servo — aquí se hace explícito. +encoding_rs = "0.8" # === takiy-synth (SoundFont MIDI) === # rustysynth = sintetizador SF2 puro Rust, MIT. Reemplaza el oscilador @@ -449,7 +635,7 @@ hound = "3.5" symphonia = { version = "0.5", default-features = false, features = ["mp3", "flac", "vorbis", "ogg"] } # === media-source-opus (decoder Opus NATIVO puro-Rust) === -# Opus es el formato de audio nativo de gioser (par del video AV1). ogg +# Opus es el formato de audio nativo de tawasuyu (par del video AV1). ogg # demuxea las páginas Ogg; opus-wave es un port puro-Rust de libopus # (SILK+CELT, sin C ni FFI) — par del rav1d del lado video. ogg = "0.9" @@ -460,18 +646,49 @@ opus-wave = "3" # paquetes de los tracks V_AV1 y A_OPUS para alimentar a media-source-av1 # y media-source-opus — un .webm AV1+Opus se reproduce 100% nativo. matroska-demuxer = "0.7" -# === git-deps al monorepo (agregados por la extracción) === -arje-incarnate = { git = "https://git.tawasuyu.net/tawasuyu/tawasuyu.git", tag = "v0.1.0" } -card-core = { git = "https://git.tawasuyu.net/tawasuyu/tawasuyu.git", tag = "v0.1.0" } -card-sidecar = { git = "https://git.tawasuyu.net/tawasuyu/tawasuyu.git", tag = "v0.1.0" } -minga-core = { git = "https://git.tawasuyu.net/tawasuyu/tawasuyu.git", tag = "v0.1.0" } -minga-store = { git = "https://git.tawasuyu.net/tawasuyu/tawasuyu.git", tag = "v0.1.0" } -minga-vfs = { git = "https://git.tawasuyu.net/tawasuyu/tawasuyu.git", tag = "v0.1.0" } -pata-host = { git = "https://git.tawasuyu.net/tawasuyu/tawasuyu.git", tag = "v0.1.0" } -rimay-localize = { git = "https://git.tawasuyu.net/tawasuyu/tawasuyu.git", tag = "v0.1.0" } -sandokan-core = { git = "https://git.tawasuyu.net/tawasuyu/tawasuyu.git", tag = "v0.1.0" } -sandokan-local = { git = "https://git.tawasuyu.net/tawasuyu/tawasuyu.git", tag = "v0.1.0" } -shuma-discern = { git = "https://git.tawasuyu.net/tawasuyu/tawasuyu.git", tag = "v0.1.0" } -ssh = { git = "https://git.tawasuyu.net/tawasuyu/tawasuyu.git", tag = "v0.1.0" } -wawa-config = { git = "https://git.tawasuyu.net/tawasuyu/tawasuyu.git", tag = "v0.1.0" } -wawa-config-llimphi = { git = "https://git.tawasuyu.net/tawasuyu/tawasuyu.git", tag = "v0.1.0" } + +# === Perfil `dist`: binarios de reparto livianos (equipos chicos / poca RAM) === +# Hereda de `release` pero prioriza HUELLA sobre tiempo de compilación: LTO thin +# (poda código muerto entre crates), un solo codegen-unit (mejor inlining/DCE) y +# `strip` de símbolos. Reduce el tamaño en disco Y el segmento de código mapeado +# en RAM de cada binario (mirada, pata, apps Llimphi). No toca `cargo build +# --release` de iteración diaria; se usa sólo al empaquetar: +# cargo build --profile dist -p mirada-compositor -p pata-host … +# `panic = "unwind"` se mantiene a propósito: mirada-plugin-host aísla plugins +# con `catch_unwind`, así que `abort` rompería el sandbox. +# === Crates del monorepo consumidos por git-dep (pin del tag) === +arje-applaunch = { git = "https://git.tawasuyu.net/tawasuyu/tawasuyu.git", tag = "v0.2.0" } +arje-incarnate = { git = "https://git.tawasuyu.net/tawasuyu/tawasuyu.git", tag = "v0.2.0" } +atipay = { git = "https://git.tawasuyu.net/tawasuyu/tawasuyu.git", tag = "v0.2.0" } +card-core = { git = "https://git.tawasuyu.net/tawasuyu/tawasuyu.git", tag = "v0.2.0" } +card-sidecar = { git = "https://git.tawasuyu.net/tawasuyu/tawasuyu.git", tag = "v0.2.0" } +minga-core = { git = "https://git.tawasuyu.net/tawasuyu/tawasuyu.git", tag = "v0.2.0" } +minga-store = { git = "https://git.tawasuyu.net/tawasuyu/tawasuyu.git", tag = "v0.2.0" } +minga-vfs = { git = "https://git.tawasuyu.net/tawasuyu/tawasuyu.git", tag = "v0.2.0" } +mirada-procedural = { git = "https://git.tawasuyu.net/tawasuyu/tawasuyu.git", tag = "v0.2.0" } +pata-host = { git = "https://git.tawasuyu.net/tawasuyu/tawasuyu.git", tag = "v0.2.0" } +pluma-core = { git = "https://git.tawasuyu.net/tawasuyu/tawasuyu.git", tag = "v0.2.0" } +pluma-cotejo = { git = "https://git.tawasuyu.net/tawasuyu/tawasuyu.git", tag = "v0.2.0" } +pluma-cuerpo = { git = "https://git.tawasuyu.net/tawasuyu/tawasuyu.git", tag = "v0.2.0" } +pluma-llm = { git = "https://git.tawasuyu.net/tawasuyu/tawasuyu.git", tag = "v0.2.0" } +pluma-llm-core = { git = "https://git.tawasuyu.net/tawasuyu/tawasuyu.git", tag = "v0.2.0" } +rimay-localize = { git = "https://git.tawasuyu.net/tawasuyu/tawasuyu.git", tag = "v0.2.0" } +rimay-verbo = { git = "https://git.tawasuyu.net/tawasuyu/tawasuyu.git", tag = "v0.2.0" } +rimay-verbo-index = { git = "https://git.tawasuyu.net/tawasuyu/tawasuyu.git", tag = "v0.2.0" } +rimay-voz = { git = "https://git.tawasuyu.net/tawasuyu/tawasuyu.git", tag = "v0.2.0" } +rimay-voz-core = { git = "https://git.tawasuyu.net/tawasuyu/tawasuyu.git", tag = "v0.2.0" } +rimay-voz-host = { git = "https://git.tawasuyu.net/tawasuyu/tawasuyu.git", tag = "v0.2.0" } +sandokan-core = { git = "https://git.tawasuyu.net/tawasuyu/tawasuyu.git", tag = "v0.2.0" } +sandokan-local = { git = "https://git.tawasuyu.net/tawasuyu/tawasuyu.git", tag = "v0.2.0" } +ssh = { git = "https://git.tawasuyu.net/tawasuyu/tawasuyu.git", tag = "v0.2.0" } +wawa-config = { git = "https://git.tawasuyu.net/tawasuyu/tawasuyu.git", tag = "v0.2.0" } +wawa-config-llimphi = { git = "https://git.tawasuyu.net/tawasuyu/tawasuyu.git", tag = "v0.2.0" } + +[profile.dist] +inherits = "release" +lto = "thin" +codegen-units = 1 +strip = "symbols" +panic = "unwind" + +