chore: refresco desde el monorepo (pin tawasuyu.git v0.2.0)

Regenerado con scripts/actualizar-standalone.py: código del dominio al día,
git-deps repineadas a v0.2.0 y doble-fuente resuelta con [patch] al source git.
cargo check --workspace verde.
This commit is contained in:
Sergio
2026-07-25 21:31:22 +00:00
parent ec7b06f704
commit 2d1dbb0615
270 changed files with 70743 additions and 8452 deletions
+98
View File
@@ -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<Turno>`, título auto-derivado del primer
mensaje, timestamps. Ordenadas por `actualizada` (recientes primero).
- **`Turno`** — `rol` (Usuario/Asistente), `bloques`, `uso: Option<Uso>` (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 `<perfil>/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.
+603
View File
@@ -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 AK: 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 L1L13 + 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 · <título de la pestaña>» 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<Mutex<f32>>`,
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/<pid>/wchan` (suele decir `wait_woken`/`pipe_read`) o, más directo,
`/proc/<pid>/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 AK 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 25 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 AK (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 → **J2J5**. · 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.
+345
View File
@@ -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] <archivo>` (`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 <cmd>` (corre en el daemon, **sobrevive a cerrar shuma**, se adjunta
y rinde como TUI), `:sessions` (lista), `:attach <id>` (re-adjunta), y
`:kill-session <id>`. 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):
- `:? <pregunta>` — 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` `<instrucción> [%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,
NeedlemanWunsch) 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).
+60 -35
View File
@@ -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 05: 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 <patrón>` 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.
+168
View File
@@ -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<Action>` (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<AtomicBool>, 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<nombre, FleetEntry{Pending|Ready(RuntimeState)|Failed}>` +
`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
M1M6 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).
**M1M6 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.
+17 -5
View File
@@ -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 05: 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 <pat>` 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.
+3 -3
View File
@@ -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.*
+156
View File
@@ -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<Mutex<CorpusCache>>`). 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<String>` 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`/F1F8, 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.
+359
View File
@@ -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 04 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<u8>`/rope; un índice `Vec<u32>` (o `Vec<u64>` 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 12 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 14 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 04 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<u64>``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.
+192
View File
@@ -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.
@@ -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"
@@ -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).
@@ -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).
@@ -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();
}
@@ -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
@@ -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::<crate::MatildaMovil>(app);
}
@@ -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<Inventory>,
#[serde(default)]
inventory_path: Option<PathBuf>,
}
/// 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))
}
@@ -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<Msg>) -> 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<Msg>) -> Modelo {
match msg {
Msg::Accion(a) => accion(m, a, handle),
Msg::M(mm) => interceptar(m, mm, handle),
}
}
fn view(m: &Modelo) -> View<Msg> {
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<Msg> {
let mut botones: Vec<View<Msg>> = 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<Msg>) -> 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<matilda_core::Host> = 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<Msg>) -> 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::<MatildaMovil>();
}
#[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<Msg> = 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<Msg>) -> usize {
1 + v.children.iter().map(contar_nodos).sum::<usize>()
}
/// 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<Msg> = 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");
}
}
@@ -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" }
@@ -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
@@ -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
@@ -228,6 +228,7 @@ fn run() -> Result<(), String> {
}
fn main() -> ExitCode {
bitacora::abrir("shuma");
match run() {
Ok(()) => ExitCode::SUCCESS,
Err(e) => {
@@ -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<ApplyStep> {
],
}),
// --- 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<ApplyStep> {
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<String> {
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(&current, &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();
@@ -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());
}
}
@@ -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 a.
`HostConfig { packages, files, services, dotfiles, ... }` serializable a TOML. La verdad de "cómo debería estar el host" se escribe aquí.
## Deps
@@ -11,11 +11,24 @@ pub struct Host {
pub address: String,
/// Etiquetas libres — `"prod"`, `"db"`, `"edge"`.
pub tags: Vec<String>,
/// Usuario SSH para administrar el host (default `root`). Opcional para
/// que los inventarios viejos sigan parseando.
#[serde(default)]
pub user: Option<String>,
/// Puerto SSH (default 22).
#[serde(default)]
pub port: Option<u16>,
}
impl Host {
pub fn new(name: impl Into<String>, address: impl Into<String>) -> 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<String>) -> 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)]
@@ -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<String, Host>,
containers: BTreeMap<String, Container>,
vhosts: BTreeMap<String, VHost>,
#[serde(default)]
services: BTreeMap<String, Service>,
}
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<Item = &Service> {
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
@@ -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};
@@ -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<String>) -> 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);
}
}
@@ -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<String>,
/// Dominios de los vhosts presentes.
pub vhosts: Vec<String>,
/// Estado declarativo de los servicios administrados (sólo los
/// declarados; vacío en el discover remoto v1).
#[serde(default)]
pub services: Vec<ObservedService>,
}
/// 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<ContainerStatus>,
pub services: Vec<ServiceStatus>,
pub vhosts: Vec<String>,
}
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<ServiceStatus> {
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::<Vec<_>>().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<ContainerStatus> {
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::<f32>().unwrap_or(0.0)
}
/// Parsea la salida de `docker stats --no-stream --format DOCKER_STATS_FORMAT`:
/// una fila `nombre<TAB>cpu%<TAB>mem%` por contenedor. Devuelve un mapa
/// `nombre → ContainerStats`. Puro y testeable.
pub fn parse_docker_stats(text: &str) -> std::collections::BTreeMap<String, ContainerStats> {
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<String, ContainerStats> {
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<String> {
.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
/// `unit<TAB>enabled-state<TAB>active-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::<Vec<_>>()
.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
/// `unit<TAB>enabled-state<TAB>active-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<ObservedService> {
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<String> {
@@ -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<stdout>` (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<String>) -> 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<ServiceStatus> {
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, &current);
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, &current);
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(&current, &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(&current, &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);
@@ -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<F, S>(
&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 {
@@ -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(&current, &desired);
assert_eq!(p.actions, vec![Action::new(Op::Update, Resource::Service, "nginx.service")]);
// Remove: current lo tiene, desired no.
let p = plan(&current, &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());
}
}
@@ -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"
@@ -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).
@@ -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).
@@ -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();
}
@@ -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
@@ -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::<crate::ConsolaMovil>(app);
}
@@ -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<String>,
/// 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<Config> {
let texto = std::fs::read_to_string(path).ok()?;
let campo = |k: &str| -> Option<String> {
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)
}
@@ -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<Tab>, sesion: Option<Sesion> },
/// 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<Msg>) -> 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<Msg>) -> 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<Msg> {
consola::view(&m.st, &m.theme, Msg::M)
}
fn on_key(_m: &Modelo, ev: &KeyEvent) -> Option<Msg> {
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<Msg> {
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<Msg>) {
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::<ConsolaMovil>();
}
#[cfg(test)]
mod tests {
use super::*;
fn contar(v: &View<Msg>) -> usize {
1 + v.children.iter().map(contar).sum::<usize>()
}
/// 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<Msg> = 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<Msg> = Handle::for_test();
let m = ConsolaMovil::init(&handle);
assert!(matches!(
ConsolaMovil::on_key(&m, &ev),
Some(Msg::M(consola::Msg::Enviar))
));
}
}
Binary file not shown.

After

Width:  |  Height:  |  Size: 122 KiB

@@ -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 }
@@ -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).
@@ -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).
@@ -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<BloqueSalida>,
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<Respuesta, String> {
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<Respuesta, String> {
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<dyn ChatClient> =
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<std::sync::Arc<dyn pluma_llm::pluma_llm_core::ChatClient>, 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");
}
}
@@ -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 }
@@ -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).
@@ -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).
@@ -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.01.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<String>,
}
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<String>) -> 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<String>) -> 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<String>) -> 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<String>,
}
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)
}
}
@@ -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<Path>) -> Result<Self, AlmacenError> {
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<Option<Agente>, 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<Vec<Agente>, AlmacenError> {
let mut out = Vec::new();
for kv in self.agentes.iter() {
let (_, v) = kv?;
out.push(serde_json::from_slice::<Agente>(&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<Vec<Agente>, 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<Option<Conversacion>, 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<Vec<Conversacion>, AlmacenError> {
let mut out = Vec::new();
for kv in self.conversaciones.iter() {
let (_, v) = kv?;
out.push(serde_json::from_slice::<Conversacion>(&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);
}
}
@@ -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<atipay::Peligro> 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<String>,
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<BloqueSalida>,
/// 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<Uso>,
}
impl Turno {
/// Turno de usuario con texto plano.
pub fn usuario(texto: impl Into<String>, 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<BloqueSalida>, 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::<Vec<_>>()
.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<Item = (usize, &AccionPropuesta)> {
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<Turno>,
/// 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<String>, 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<String>, 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<String>,
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<BloqueSalida> = 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<BloqueSalida>, ts: u64, uso: Option<Uso>) -> 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::<Vec<_>>().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");
}
}
@@ -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,
};
@@ -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<BloqueSalida>`
//! (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<pluma_llm_core::ChatImage> = 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::<Vec<_>>();
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\":\"<id de la acción>\",\"args\":{{\"<param>\":\"<valor>\"}}}} — 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<BloqueSalida> {
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<BloqueSalida>| {
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(_)));
}
}
@@ -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<Autonomia> = escala.iter().map(termostato).collect();
assert_eq!(
niveles,
vec![Autonomia::Suelta, Autonomia::Propone, Autonomia::Cautelosa]
);
}
}
+8 -1
View File
@@ -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
}
@@ -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 }
@@ -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.).
@@ -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/<cmd>.toml` con la forma:
#
# flags = ["--mi-flag", "--otro=", "-x"]
+557 -1
View File
@@ -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-<pid>.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<String>,
/// 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<String, String>,
/// 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<String, HashMap<String, String>>,
/// 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<String, (usize, String)> = 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<String>) -> 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<PathBuf> {
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<PathBuf> {
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<EnvGroup> {
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<String> = 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<bool> {
let Ok(text) = std::fs::read_to_string(path) else {
return Ok(false);
};
let header = format!("[{section}]");
let mut lines: Vec<String> = 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<DedupPolicy> 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\""));
}
}
@@ -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<String>,
/// 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<String>,
}
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<PathBuf> {
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<PathBuf> {
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<usize> {
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<Vec<(String, String)>, 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<PathBuf> {
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<String, String> = 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"));
}
}
@@ -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 }
@@ -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 <token>`.
---
Parte de **shuma** — ver [shuma](../../LEEME.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).
@@ -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");
}
@@ -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 <token>`.
#![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<String>,
agent: ureq::Agent,
}
impl GatewayClient {
/// Cliente contra `base` (sin barra final), con token opcional.
pub fn new(base: impl Into<String>, token: Option<String>) -> 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<Response, String> {
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::<Response>(&text)
.map_err(|e| format!("respuesta no-JSON del gateway: {e}"))
}
/// Desenvuelve un `Response::Error` como `Err`.
fn no_error(resp: Response) -> Result<Response, String> {
match resp {
Response::Error { message } => Err(message),
otra => Ok(otra),
}
}
/// Lista de sesiones (para la tira de tabs).
pub fn list(&self) -> Result<Vec<ConsolaResumen>, 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<Option<Sesion>, 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<String>) -> Result<String, String> {
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<bool, String> {
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<bool, String> {
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<bool, String> {
self.ack(Request::ConsolaKill { id: id.to_string() })
}
fn ack(&self, req: Request) -> Result<bool, String> {
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::<Request>`). 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"));
}
}
@@ -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 }
@@ -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 <session_id>`. 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).
@@ -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 <session_id>`. 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).
@@ -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 <session_id>`. 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<String>,
/// 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<Etapa>,
pub uso: Option<Uso>,
/// 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<Uso> },
}
// ──────────────────────────── 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<String>,
/// 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<String>,
pub estado: EstadoSesion,
pub turnos: Vec<Turno>,
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<String>, cwd: impl Into<String>, 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<String>, 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<Cambio> {
match parse_evento(linea) {
Some(ev) => self.aplicar(ev, ts),
None => Vec::new(),
}
}
fn aplicar(&mut self, ev: Evento, ts: u64) -> Vec<Cambio> {
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<Cambio> {
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<Cambio> {
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<String>,
cwd: Option<String>,
},
Asistente(Vec<Bloque>),
Usuario(Vec<ResultadoHerramienta>),
Fin {
is_error: bool,
texto: Option<String>,
uso: Option<Uso>,
session_id: Option<String>,
},
}
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<Evento> {
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<Bloque> {
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<ResultadoHerramienta> {
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::<Vec<_>>()
.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::<Vec<_>>().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());
}
}
@@ -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"}
@@ -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);
}
@@ -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 }
@@ -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 <prompt> --output-format stream-json
--verbose [--resume <session_id>]`; 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).
@@ -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).
@@ -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(&reg, &id);
let sid = reg.snapshot(&id).unwrap().claude_session_id.clone();
let r1 = texto_final(&reg, &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(&reg, &id);
let r2 = texto_final(&reg, &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());
}
@@ -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 <prompt> --output-format stream-json
//! --verbose [--resume <session_id>]`; 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<String>,
/// `session_id` de Claude Code a reanudar (`--resume`). `None` = turno
/// inicial de la sesión.
pub resume: Option<String>,
/// 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<dyn Iterator<Item = String> + Send>,
pub matar: Box<dyn Fn() + Send + Sync>,
}
/// 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<LineasTurno>;
}
/// 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<LineasTurno> {
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<dyn Fn() + Send + Sync>
};
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<BufReader<std::process::ChildStdout>>,
child: Arc<Mutex<Child>>,
terminado: bool,
}
impl Iterator for LineasProc {
type Item = String;
fn next(&mut self) -> Option<String> {
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<Mutex<Interior>>,
tx: broadcast::Sender<Cambio>,
killer: Mutex<Option<Box<dyn Fn() + Send + Sync>>>,
}
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<Cambio>) {
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<dyn Fn() -> 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<HashMap<String, Arc<SesionViva>>>,
runner: Arc<dyn TurnoRunner>,
reloj: Reloj,
contador: AtomicU64,
}
impl ConsolaRegistro {
/// Registro sobre un runner dado y el reloj del sistema.
pub fn new(runner: Arc<dyn TurnoRunner>) -> 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<dyn TurnoRunner>, 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<String>,
prompt: impl Into<String>,
modelo: Option<String>,
) -> io::Result<String> {
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<String>) -> io::Result<bool> {
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<SesionViva>,
cwd: String,
prompt: String,
modelo: Option<String>,
resume: Option<String>,
) -> 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<Cambio>)> {
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<Sesion> {
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<ResumenSesion> {
let map = self.sesiones.lock().expect("registro lock");
let mut out: Vec<ResumenSesion> = 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)
}
@@ -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<bool>, 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<VecDeque<Vec<String>>>,
llamadas: Arc<Mutex<Vec<TurnoParams>>>,
gate: Mutex<Option<Gate>>,
}
impl TurnoRunner for ReplayRunner {
fn correr(&self, params: TurnoParams) -> std::io::Result<LineasTurno> {
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<String>,
gate: Option<Gate>,
}
impl Iterator for GatedIter {
type Item = String;
fn next(&mut self) -> Option<String> {
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<String> {
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<dyn Fn() -> 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");
}
+1 -1
View File
@@ -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 }
@@ -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 a 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(
@@ -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 }
@@ -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)
@@ -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)
File diff suppressed because it is too large Load Diff
+136 -15
View File
@@ -76,6 +76,13 @@ pub struct CommandSpec {
pub spill_path: Option<PathBuf>,
/// Texto a alimentar por stdin — para reprocesar una salida previa.
pub stdin_data: Option<String>,
/// 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<RunEvent> {
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<RunEvent> {
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<R: Read + AsFd + Send + 'static>(
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<R: Read + AsFd + Send + 'static>(
})
}
/// 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<R: BufRead>(reader: &mut R, buf: &mut String) -> std::io::Result<usize> {
let mut bytes: Vec<u8> = 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<Child>,
@@ -526,20 +584,35 @@ struct StageTee {
sink: std::fs::File,
}
/// Lanza un único proceso shell (`program -c "<line>"`).
fn spawn_shell(line: &str, program: &str, cwd: &str, want_stdin: bool) -> std::io::Result<Spawned> {
let mut child = Command::new(program)
.arg("-c")
/// Lanza un único proceso shell (`program -c "<line>"`). `_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<Spawned> {
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<Spawned> {
@@ -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<RunEvent>,
@@ -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);
@@ -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
//! `#<unixts>` opcionales si `HISTTIMEFORMAT` está puesto). zsh tiene dos
//! formatos: plano (igual que bash) y *extended* (`: <ts>:<elapsed>;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, `#<ts>` 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<PathBuf>) -> Self {
Self { kind: ShellKind::Bash, path: path.into() }
}
pub fn zsh(path: impl Into<PathBuf>) -> 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<PathBuf>,
}
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<ForeignSource> {
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 `#<digits>` son
/// timestamps (de `HISTTIMEFORMAT`) y se adjuntan al comando siguiente.
pub fn parse_bash(text: &str) -> Vec<Entry> {
let mut out = Vec::new();
let mut pending_ts: Option<u64> = 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::<u64>() {
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*
/// (`: <ts>:<elapsed>;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<Entry> {
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<Entry>, 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: `: <ts>:<elapsed>;<cmd>`.
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 `: <ts>:<elapsed>;<cmd>` 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::<u64>().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<u8> {
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<String, SourceState>,
}
/// `$XDG_DATA_HOME/shuma/shell_import.json` — el estado de importación.
fn import_state_path() -> Option<PathBuf> {
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<Entry> = 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<Entry> = 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<u8> = 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");
}
}
@@ -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<Item = Entry>,
) -> io::Result<usize> {
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<()> {
@@ -6,7 +6,7 @@
//! historial como un grafo de contexto navegable: cada comando es un
//! nodo, cada salida un buffer intermedio referenciable.
//!
//! Todo a 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`.
@@ -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<Decoration> {
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<Decoration> = 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<Decoration>) {
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<Decoration>) {
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<Decoration>) {
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<Decoration>) {
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<Decoration>) {
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<Decoration>) {
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://"];
+196 -3
View File
@@ -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<usize>,
}
impl LineState {
@@ -56,28 +61,147 @@ impl LineState {
pub fn set_text(&mut self, text: impl Into<String>) {
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<usize> {
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<String> {
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();
@@ -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/...). A 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:
+1 -1
View File
@@ -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;
@@ -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 }
@@ -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).
@@ -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).
@@ -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_<estado>.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();
}
File diff suppressed because it is too large Load Diff
@@ -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 `<N`. Status de cada nodo se colorea (running ámbar, ok
verde, failed rojo). Render directo con `paint_with` + vello — sin
depender de `pineal-render` para no arrastrar el backend de
dominium al chasis.
El módulo es independiente del shell: el host puede sincronizar el
grafo enviando `Msg::Record` / `Msg::Complete` después de cada
ejecución, pero también funciona standalone con un grafo de demo.
---
Parte de **shuma** — ver [shuma](../../LEEME.md).
@@ -0,0 +1,11 @@
# shuma-module-canvas
The shell's **Context Canvas**.
A tab/panel drawing `shuma-intent`'s `SessionGraph` as a visual graph: each `%cN`
command is a box, and the `%pN` dependencies are arrows towards the command that
produced them. The user sees the session's whole flow and can jump back.
---
Part of **shuma** — see [shuma](../../README.md).
@@ -162,7 +162,7 @@ pub fn update(state: State, msg: Msg) -> State {
}
},
Msg::InsertRef(_) => {
// No-op a — 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.
@@ -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 }

Some files were not shown because too many files have changed in this diff Show More