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:
@@ -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.
|
||||
@@ -0,0 +1,603 @@
|
||||
# Cola — shuma (input, pestañas, fugaces, consola, multiproceso, historial)
|
||||
|
||||
**La cola ÚNICA de shuma.** Nació el 22-jul-2026 recogiendo pedidos **en vivo** sobre las
|
||||
pestañas, y el 22-jul absorbió los pendientes de shuma que otro agente había dejado en la
|
||||
raíz (`COLA-VERIFICACION.md` §11/§14) — sección **L** al final. Está pensada para retomarse
|
||||
**en frío**, sin la conversación que la originó. Si aparece un pendiente de shuma en otra
|
||||
lista, su lugar es acá.
|
||||
|
||||
Convención: `[ ]` pendiente · `[~]` codeado, falta aprobarlo **en metal** · `[x]` aprobado
|
||||
en metal.
|
||||
|
||||
## Cómo probar cualquier cosa de acá
|
||||
|
||||
```sh
|
||||
cargo build -p pata-llimphi --release
|
||||
sudo install -m755 target/release/pata-llimphi /usr/local/bin/pata-llimphi
|
||||
pkill -x pata-llimphi # mirada la respawnea desde /usr/local/bin
|
||||
```
|
||||
|
||||
Diagnóstico sin poder inyectar env (a pata la respawnea el compositor):
|
||||
|
||||
```sh
|
||||
touch /tmp/pata-diag && pkill -x pata-llimphi # el centinela se lee AL ARRANCAR
|
||||
cat /tmp/pata-diag-fugaces.txt # fade de los iconos, una línea por cambio
|
||||
cat /tmp/shuma-consola-dump.txt # buffer de la consola + colores + secciones (marca 'cod' = línea H4)
|
||||
touch /tmp/shuma-diag # registro de pestañas (no requiere reiniciar)
|
||||
cat /tmp/shuma-tab-diag.txt # TabNew ↔ muerte de run (código de salida) — bug L14
|
||||
```
|
||||
|
||||
Certificación sin mirar imágenes (Regla 8 del repo):
|
||||
|
||||
```sh
|
||||
cargo run -p shuma-shell-llimphi --example pestanas_shot --release # diffea píxeles
|
||||
cargo test -p shuma-module-shell -p shuma-shell-llimphi -p pata-llimphi -p llimphi-widget-text-input
|
||||
```
|
||||
|
||||
## Dónde vive cada cosa
|
||||
|
||||
| pieza | archivo |
|
||||
|---|---|
|
||||
| Pestañas (modelo, avisos) | `shuma-shell-llimphi/src/workspace.rs` |
|
||||
| Pestañas (vista, chip, cava) | `shuma-shell-llimphi/src/view/session.rs` |
|
||||
| Campanas + título OSC | `sandbox/shuma-module-shell/src/campana.rs` |
|
||||
| Caudal del cava | `sandbox/shuma-module-shell/src/pulso.rs` |
|
||||
| Gate del modo consola | `sandbox/shuma-module-shell/src/update/mod.rs` |
|
||||
| Secciones/tablas del output | `sandbox/shuma-module-shell/src/sections.rs` |
|
||||
| Iconos fugaces + fade | `pata-llimphi/src/shuma.rs` |
|
||||
| Input compartido | `llimphi/widgets/text-input/{lib,area,rico}.rs` |
|
||||
|
||||
## Estado
|
||||
|
||||
Hechos (falta metal): A1, B1, B2, C1/H1, C2, E1, G1, H2, H3, H4, J1, K1.
|
||||
Pendientes A–K: D1, D2, F1, G2, G3, H5, H6, H7, H8, I1, J2, J3, J4, J5.
|
||||
Sección L (traída de COLA-VERIFICACION, otro agente): pendientes L1–L13 + limpieza. **L1
|
||||
(Enter con el cajón plegado pierde lo escrito) DESTRUYE trabajo del usuario — prioridad.**
|
||||
|
||||
## A. Notificaciones de pestaña → willay
|
||||
|
||||
- [x] **A1 — Dirección invertida (corrección del usuario). HECHO (falta metal).**
|
||||
El parpadeo de la pestaña se queda; **además** willay ahora despliega la notificación
|
||||
de un tab cuando ese tab NO está visible. Puente en `shuma-shell-llimphi/src/update.rs`
|
||||
(`drain_shell_instances`, en el mismo loop de `refrescar_aviso`): cada panel de cada
|
||||
pestaña drena `sh.tomar_notificaciones()` (OSC 9/777/99); si la pestaña no es la visible
|
||||
(`!(idx==activa && t==active_tab)`), cada aviso se publica como
|
||||
`willay_core::Evento{Clase::Notificacion}` vía `willay_emit::emitir_silencioso` (no-opea
|
||||
sin daemon). El `origen` lleva «shuma · <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 A–K no se
|
||||
reduplican: se anotan como YA cubiertos. Orden por gravedad (el otro agente lo dejó así).
|
||||
|
||||
- [~] **L1 — Enter con el cajón plegado ya no pierde el texto. HECHO (falta metal).**
|
||||
Decisión de sergio (22-jul): **auto-expandir + mandar a claude** cuando hay un run de
|
||||
consola inline vivo. Plegado, la tecla Enter llega como `Msg::Submit` (la arma `press_key`
|
||||
de pata) y caía en `run_submitted` → encolaba la línea como comando (se perdía para
|
||||
claude). Fix en el handler `Msg::Submit` de `update/mod.rs`: si
|
||||
`tui_skin_vivo.is_some() && !tui_altscreen_vivo`, enruta a claude vía el helper compartido
|
||||
`enviar_linea_a_consola` (extraído del Enter del gate). pata **ya** auto-expande el drawer
|
||||
al ver ese mismo `Msg::Submit` (`msg_is_submit`), así que la mitad de "expandir" ya estaba
|
||||
— sólo faltaba no encolar. De paso, el botón «enviar» con claude visible también enruta a
|
||||
claude (antes encolaba, latente). **En metal: plegado + claude vivo + escribir + Enter →
|
||||
se abre el cajón y la línea va a claude; plegado + sin run → comando normal.**
|
||||
- [~] **L2 — Salida de emergencia del modo consola. HECHO (falta metal).** `Ctrl+Shift+Esc`
|
||||
corta el run (SIGKILL vía `cancel_running`) por encima del programa, interceptado ARRIBA
|
||||
del gate (`update/mod.rs`, primer chequeo del bloque `tui_skin_vivo && canvas_visible`),
|
||||
antes de reenviar nada al PTY — funciona incluso en alt-screen (vim/htop). Es el único
|
||||
atajo que el gate no reenvía. (Superset de H8, que era sólo el `Ctrl+R`.) **En metal:
|
||||
probar que desencierra; CAVEAT L9 — si Ctrl+Shift no llega junto en kitty el atajo no
|
||||
dispararía ahí, pero bajo pata/mirada (el target real) sí debería.**
|
||||
- [~] **L3 — El `Exclusive` del drawer sólo se soltaba por el camino feliz. RELEASE POR
|
||||
WATCHDOG HECHO (falta metal).** Había DOS redes ya: el cierre por Esc/✕/scrim/Ctrl+Shift+W,
|
||||
y el **watchdog** (`SHUMA_WATCHDOG` 45s) que cierra el drawer inactivo — pero el watchdog
|
||||
**se inhibe con un PTY interactivo vivo** (claude/vim: mirar output largo sin tipear es uso
|
||||
normal). Ese era el hueco: un `Exclusive` colgado con claude a la vista no lo soltaba nadie.
|
||||
**Fix (`app_impl.rs`, commit f5c74145a):** bandera `shuma_grab_released` + `SHUMA_GRAB_RELEASE`
|
||||
(180s). Tras idle genuino **con PTY vivo**, el latido baja el teclado a `OnDemand` (suelta el
|
||||
`Exclusive`) **sin cerrar** el drawer — otras ventanas vuelven a recibir teclado; el próximo
|
||||
input real re-reclama el `Exclusive` (`toca_shuma_watchdog`). Umbral largo a propósito: nunca
|
||||
muerde en uso activo. Ver [[pata-drawer-shuma-wedgea-input]]. **Falta**: soltar TAMBIÉN al
|
||||
perder foco del compositor (KB `leave` sobre drawer Firme) — descartado por ahora: `leave` es
|
||||
ruidoso (muchas guardas anti-churn) y arriesga «pierde foco al leer»; el watchdog de grab
|
||||
cubre el wedge sin ese riesgo. **En metal**: confirmar que tras ~3 min idle con claude, otra
|
||||
ventana agarra teclado, y que al volver a tipear el drawer lo recupera.
|
||||
- [~] **L4 — Ventana de 2.000 del corpus de sugerencias. HECHO 2026-07-25 (falta tu ojo).**
|
||||
Era la **Fase 1** de `SDD-HISTORIAL.md`. `GHOST_CORPUS_WINDOW`/`LINE_SUGGEST_WINDOW` (las dos
|
||||
constantes, retiradas) recortaban por antigüedad **cruda**: un comando que usás seguido
|
||||
desaparecía del autocompletado por haber tecleado mucho después. Ahora hay un corpus
|
||||
**deduplicado y cacheado** (`update/corpus.rs` + `State::corpus`): las líneas distintas, la
|
||||
más reciente primero, con el cwd de ese uso (para el ranking local-antes-que-global de A3).
|
||||
Cubre **todo** el historial, sin ventana.
|
||||
- **Cuándo se pone al día:** al construir el `State` (así el ghost sirve desde el primer
|
||||
comando, no después del primero), en `refresh_patterns` (al cerrar cada comando) y
|
||||
**perezosamente al leerlo** — esto último es lo que cubre las vías que no pasan por
|
||||
ninguno de los dos (la importación de zsh, o un test que escribe el historial a mano).
|
||||
Nunca por pulsación: el chequeo es comparar una marca de agua.
|
||||
- **Costo por frame BAJA:** antes se clonaban hasta 2.000 líneas por render; ahora el corpus
|
||||
filtra por prefijo y devuelve el puñado que de verdad extiende lo tipeado.
|
||||
- **Agujero encontrado y tapado en el camino:** una marca de agua numérica sola es insegura
|
||||
— si el objeto historial se REEMPLAZA por otro más largo, «extender desde `seen`» saltea
|
||||
en silencio el prefijo del nuevo. El caché guarda además un **ancla** (la línea que estaba
|
||||
en `seen-1`) y si no coincide rehace entero. Lo cazó un test, no la lectura.
|
||||
- **Certificado:** `cargo test -p shuma-module-shell --lib` → **338/338**, con 5 tests nuevos
|
||||
del corpus (el comando viejo sobrevive a 3.000 líneas después; dedup dejando el cwd del uso
|
||||
más reciente; incremental ≡ rehacer de cero; líneas vacías; el tope recorta lo viejo) y el
|
||||
test `ghost_corpus_is_bounded_to_recent_window` **dado vuelta** — ahora se llama
|
||||
`el_corpus_ya_no_se_acota_por_antiguedad` y asierta lo contrario de lo que asertaba.
|
||||
- **En metal (tu ojo):** tipear el prefijo de un comando que usás mucho y hace rato no usás
|
||||
(p. ej. `claude --dan…`) y ver que el fantasma lo ofrece; y que el popup de líneas (`↪`)
|
||||
trae completas viejas sin haberlas tecleado hoy.
|
||||
- [~] **L5 — `Ctrl+Shift+Flecha` (salto por palabra CON selección) dentro del modo consola.
|
||||
CÓDIGO YA HECHO (falta metal).** `es_edicion_de_linea` (`update/mod.rs:72`) **no filtra por
|
||||
Shift**: la guarda es `ctrl && !alt` y matchea `ArrowLeft/Right`, así que `Ctrl+Shift+←/→`
|
||||
la pasa → `de_control=false` → NO va al PTY → cae al motor compartido, que hace el
|
||||
word-select con selección. El doc comment ya lo menciona explícitamente. Verificar en
|
||||
metal dentro de claude.
|
||||
- [~] **L6 — El fantasma vuelve a ofrecer el comando del bypass. MEDIDO SOBRE TU HISTORIAL
|
||||
REAL 2026-07-25 (queda tu ojo).** Lo cerró L4 de rebote. Números de
|
||||
`~/.local/share/shuma/history.jsonl` tal como está hoy: **15.624 entradas → 2.902 líneas
|
||||
distintas** (5,4× menos; el corpus entero entra holgado bajo el tope de 20.000), **109 usos**
|
||||
de `claude --dangerously-skip-permissions`, y para el prefijo `claude --dan` hay 11 candidatos
|
||||
de los que el corpus ofrece primero justamente ése. **Matiz honesto:** hoy ese comando es la
|
||||
entrada más reciente del historial, así que la ventana vieja *también* lo habría alcanzado —
|
||||
lo que cambió es que ya no depende de dónde caiga. El caso que dolía (quedar sepultado por
|
||||
miles de líneas después) está cubierto por test.
|
||||
- [ ] **L7 — En modo shell el autocomplete salta por cada palabra** («lo molesto»,
|
||||
diferido explícitamente por sergio).
|
||||
- [ ] **L8 — El «Terminal» del menú es una cadena de respaldo silenciosa:**
|
||||
`sh -c shuma || kitty || alacritty || foot || xterm`. Abre una terminal distinta según qué
|
||||
falle, con atajos distintos y sin avisar cuál te tocó.
|
||||
- [ ] **L9 — Ctrl+Shift no funciona en kitty — SIN DIAGNOSTICAR.** Ctrl+C anda, fallan
|
||||
todos los Ctrl+Shift. Descartado (con evidencia): grab colgado, atajo global, modificador
|
||||
pegado, config de kitty. Hipótesis viva: el bit de Shift no llega junto con Ctrl. Cierre:
|
||||
arrancar el compositor con `MIRADA_DEBUG_KEYS=1` (imprime bits crudos, `drm_backend/input.rs`)
|
||||
o `xkbcli interactive-wayland` (sale con Ctrl+D).
|
||||
- [ ] **L10 — Font zoom como feature de usuario.** La plomería ya es zoom-aware (`pty_dims`,
|
||||
`Metricas::con_zoom`); falta la UI para cambiarlo.
|
||||
- [ ] **L11 — Fases 2–5 de `SDD-HISTORIAL.md`:** registrar `exit`/`dur_ms`/`sesion` (hoy
|
||||
`exit` es siempre `None`), grafo con frecuencias/lugares/bigramas por dir/args por verbo,
|
||||
completado por especificación, grupos por lugar.
|
||||
- [ ] **L12 — matilda contra una flota SSH real** (metal). §11 de la cola vieja.
|
||||
- [~] **L13 — `:predice`: legibilidad del listado en metal.** LÓGICA CERTIFICADA
|
||||
(`cargo test -p shuma-module-shell`, incl. `predice_lista_comandos_por_frecuencia_y_cwd`);
|
||||
queda el ojo sobre el listado en el widget de input.
|
||||
- [ ] **L14 — Abrir una pestaña mata la de al lado (EN DIAGNÓSTICO).** Reproducido 2×.
|
||||
Descartadas por lectura: modelo de tabs, `State::new` (no auto-adjunta), resize-a-0 (las
|
||||
filas de claude están clampeadas), robo de sesión (ULID fresco por run), reinicio del
|
||||
daemon (`ensure_daemon` reusa). Instrumentado: `touch /tmp/shuma-diag` → `cat
|
||||
/tmp/shuma-tab-diag.txt` correlaciona `TabNew` con el `run-end` del vecino y su código de
|
||||
salida (137=SIGKILL, 143=SIGTERM, 129+=128+señal). Al 22-jul-22:30 no reapareció.
|
||||
|
||||
### Ya cubierto por A–K (no reduplicar)
|
||||
- Respaldo de los fugaces → **C2**. · Caret con estela al mover flecha → **B2**.
|
||||
- `Ctrl+Shift+C/V/X` en consola → **G1**. · Fade de los fugaces «no calcula bien» → **C1/H1**
|
||||
(arreglado proporcional). · shuma multiproceso → **J2–J5**. · Tabs dinámicos con atajos →
|
||||
**K1**. · Detección de tablas en mensajes de claude → **H5**.
|
||||
|
||||
### Limpieza pendiente (diagnósticos temporales a quitar al cerrar sus casos)
|
||||
- [ ] `/tmp/shuma-consola-dump.txt` (volcado de `surface_view.rs`) y la marca `cod` — quitar
|
||||
cuando H4 esté aprobado.
|
||||
- [ ] `/tmp/shuma-tab-diag.txt` + `diag_tab` — quitar cuando L14 cierre.
|
||||
- [ ] `avisar_ancho_inestable` (`/tmp/llimphi-input-jitter.log`) — del hilo del input.
|
||||
- [ ] `/tmp/pata-diag-fugaces.txt` — quitar cuando C1/H1 esté aprobado.
|
||||
@@ -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,
|
||||
Needleman–Wunsch) para comparar la salida de dos bloques *al estilo pluma*:
|
||||
no un diff de líneas exacto, sino emparejar líneas parecidas aunque difieran y
|
||||
clasificarlas idéntica (≡) / similar (≈) / divergente (✗) / agregada (+) /
|
||||
eliminada (−). Pinta un side-by-side `izq │ der` con % de similitud,
|
||||
eliminadas en rojo, en bloque propio referenciable; el bloque se saltea el
|
||||
desplanizador. Núcleo puro `cotejo_rows` (testeable). Acepta refs con etapa
|
||||
(`%cN.K`). Verificado headless (`examples/pantallazo_compara.rs`).
|
||||
|
||||
- **`:predice` / `:sugiere` / `:next` — predicción consultable.**
|
||||
`rank_command_predictions` (puro) pondera cada línea del historial por
|
||||
**frecuencia** + **afinidad con el cwd** (×3 las corridas en el directorio
|
||||
actual o hijos) + **recencia**. Lista: la continuación inmediata (motor de
|
||||
patrones), los comandos probables aquí (marca ◆ de afinidad de cwd) y las
|
||||
secuencias/grupos aplicables al contexto (`applicable_sequences`, filtradas por
|
||||
marcadores de proyecto) + las F-keys guardadas. Hace consultable lo que ya
|
||||
alimentaba el ghost (A3) y las coreografías (A1).
|
||||
|
||||
Todo certificado por tests (223/223 verde): `:filtra`, redirección de IA,
|
||||
encadenado, etapas del tee, ranking por cwd.
|
||||
|
||||
**UI accionable HECHA y verificada en pantalla** (`examples/pantallazo_tee.rs`):
|
||||
chips del tee rotulados con su índice `K`; al desplegar una etapa, fila de
|
||||
acciones 🜲 filtrar / copiar / guardar / explicar que direcciona `%cN.K`
|
||||
(filtrar/guardar prellenan el input vía `Msg::PrefillInput`; copiar/explicar
|
||||
corren ya); chip «🜲 filtrar» en el header de cada bloque (prellena `:filtra
|
||||
%cN `). Las líneas `Ai` se pintan en acento y el bloque IA se saltea el
|
||||
desplanizador (`is_ai_block`). Las acciones viven en chips, no en menú
|
||||
contextual (más descubribles). **Pendiente de pantalla aún:** legibilidad del
|
||||
listado de `:predice` (sólo tests).
|
||||
+60
-35
@@ -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.
|
||||

|
||||
|
||||
`shuma` reemplaza zsh + tmux + mosh con una sola pieza: shell con history/completion/job-control, multiplexing nativo (no `tmux`), sesiones remotas (no `mosh`), todo dentro de un chasis Llimphi de 4 slots (TopBar, Main, BottomBar, DrawerTab + drawer Quake). `matilda` es la herramienta hermana para configuración declarativa multi-host.
|
||||
|
||||
## Instalación
|
||||
|
||||
@@ -20,53 +22,76 @@ cargo run --release -p shuma-daemon
|
||||
## Compatibilidad
|
||||
|
||||
- **Linux / macOS / Windows** — shell + UI Llimphi.
|
||||
- **Wawa** — corre adentro del kernel.
|
||||
- Protocolo `shuma-protocol` permite cliente local + server remoto sin SSH.
|
||||
- **Wawa** — planificado (todavía no hay port kernel-side).
|
||||
- `shuma-daemon` + `shuma-protocol` permiten cliente local + server remoto sin SSH.
|
||||
|
||||
## Crates: shuma
|
||||
|
||||
Los binarios viven en la raíz del dominio; las librerías, en `sandbox/`.
|
||||
|
||||
| Crate | Rol |
|
||||
|---|---|
|
||||
| [`shuma-core`](shuma-core/README.md) | Tipos: Session, Command, Output. |
|
||||
| [`shuma-core`](sandbox/shuma-core/README.md) | Tipos: Session, Command, Output. |
|
||||
| [`shuma-cli`](shuma-cli/README.md) | CLI (no Llimphi). |
|
||||
| [`shuma-daemon`](shuma-daemon/README.md) | Daemon de sesiones. |
|
||||
| [`shuma-daemon`](shuma-daemon/README.md) | Daemon de workspaces (Unix socket + TCP cifrado Noise XK). |
|
||||
| [`shuma-gateway`](shuma-gateway/README.md) | Gateway HTTP → daemon. |
|
||||
| [`shuma-askpass`](shuma-askpass/) | Popup de contraseña compatible `SUDO_ASKPASS`. |
|
||||
| [`shuma-shell-llimphi`](shuma-shell-llimphi/README.md) | Shell con UI Llimphi. |
|
||||
| [`shuma-shell-render`](shuma-shell-render/README.md) | Renderer de output (ANSI, imágenes, links). |
|
||||
| [`shuma-protocol`](shuma-protocol/README.md) | Protocolo wire (reemplazo de SSH/mosh). |
|
||||
| [`shuma-gateway`](shuma-gateway/README.md) | Gateway de sesiones remotas. |
|
||||
| [`shuma-remote-exec`](shuma-remote-exec/README.md) | Exec remoto vía gateway. |
|
||||
| [`shuma-session`](shuma-session/README.md) | Sesión persistente. |
|
||||
| [`shuma-history`](shuma-history/README.md) | History con búsqueda fuzzy. |
|
||||
| [`shuma-exec`](shuma-exec/README.md) | Ejecutor de comandos. |
|
||||
| [`shuma-line`](shuma-line/README.md) | Readline (edición · completion · highlight). |
|
||||
| [`shuma-config`](shuma-config/README.md) | Config del shell. |
|
||||
| [`shuma-intent`](shuma-intent/README.md) | Intent → comando (predictor). |
|
||||
| [`shuma-infer`](shuma-infer/README.md) | Inferencia para `intent`. |
|
||||
| [`shuma-discern`](shuma-discern/README.md) | Discriminador comando-vs-texto. |
|
||||
| [`shuma-link`](shuma-link/README.md) | Links clickables en output. |
|
||||
| [`shuma-sysmon`](shuma-sysmon/README.md) | Monitor de sistema embebido. |
|
||||
| [`shuma-card`](shuma-card/README.md) | Card escritorio. |
|
||||
| [`shuma-module`](shuma-module/README.md) | Trait módulo del chasis. |
|
||||
| [`shuma-module-shell`](shuma-module-shell/README.md) | Módulo shell (Main slot). |
|
||||
| [`shuma-module-commandbar`](shuma-module-commandbar/README.md) | Módulo command bar (TopBar). |
|
||||
| [`shuma-module-launcher`](shuma-module-launcher/README.md) | Módulo launcher (DrawerTab). |
|
||||
| [`shuma-module-matilda`](shuma-module-matilda/README.md) | Módulo matilda integrado. |
|
||||
| [`shuma-shell-render`](sandbox/shuma-shell-render/README.md) | Renderer de output (ANSI, imágenes, links). |
|
||||
| [`shuma-protocol`](sandbox/shuma-protocol/README.md) | Protocolo wire daemon ↔ cliente (length-prefix + postcard). |
|
||||
| [`shuma-remote-exec`](sandbox/shuma-remote-exec/README.md) | Exec remoto vía gateway. |
|
||||
| [`shuma-session`](sandbox/shuma-session/README.md) | Sesión persistente. |
|
||||
| [`shuma-history`](sandbox/shuma-history/README.md) | History con búsqueda fuzzy. |
|
||||
| [`shuma-exec`](sandbox/shuma-exec/README.md) | Ejecutor de comandos (PTY cross-platform). |
|
||||
| [`shuma-line`](sandbox/shuma-line/README.md) | Readline (edición · completion · highlight). |
|
||||
| [`shuma-config`](sandbox/shuma-config/README.md) | Config del shell. |
|
||||
| [`shuma-intent`](sandbox/shuma-intent/README.md) | Intent → comando (predictor). |
|
||||
| [`shuma-infer`](sandbox/shuma-infer/README.md) | Inferencia para `intent`. |
|
||||
| [`shuma-discern`](sandbox/shuma-discern/README.md) | Discriminador comando-vs-texto. |
|
||||
| [`shuma-link`](sandbox/shuma-link/README.md) | Transporte autenticado (handshake + canal cifrado Noise). |
|
||||
| [`shuma-sysmon`](sandbox/shuma-sysmon/README.md) | Monitor de sistema embebido. |
|
||||
| [`shuma-card`](sandbox/shuma-card/README.md) | Workspaces + `PipelineSpec` (DAG de comandos). |
|
||||
| [`shuma-module`](sandbox/shuma-module/README.md) | Trait módulo del chasis (+ `Source`: local / daemon Unix / daemon TCP / SSH / container). |
|
||||
| [`shuma-module-shell`](sandbox/shuma-module-shell/README.md) | Módulo shell (Main slot). |
|
||||
| [`shuma-module-commandbar`](sandbox/shuma-module-commandbar/README.md) | Módulo command bar (TopBar). |
|
||||
| [`shuma-module-launcher`](sandbox/shuma-module-launcher/README.md) | Módulo launcher (DrawerTab). |
|
||||
| [`shuma-module-canvas`](sandbox/shuma-module-canvas/) | Lienzo de Contexto: el `SessionGraph` como grafo visual. |
|
||||
| [`shuma-module-minga`](sandbox/shuma-module-minga/README.md) | Visualizador del repo Minga del cwd. |
|
||||
| [`shuma-module-matilda`](sandbox/shuma-module-matilda/README.md) | Módulo matilda integrado. |
|
||||
| [`shuma-agente`](sandbox/shuma-agente/) + [`-host`](sandbox/shuma-agente-host/) | El núcleo de la IA conversacional (sync, sin red) y el host que corre un turno. |
|
||||
| [`shuma-module-agente`](sandbox/shuma-module-agente/) | El panel de chat multi-agente. |
|
||||
| [`shuma-consola-core`](sandbox/shuma-consola-core/) + [`-host`](sandbox/shuma-consola-host/) + [`-client`](sandbox/shuma-consola-client/) | La consola de sesiones agénticas: núcleo puro, registro de sesiones vivas y cliente HTTP contra el gateway. |
|
||||
| [`shuma-module-consola`](sandbox/shuma-module-consola/) | La UI Llimphi de esa consola. |
|
||||
| [`shuma-voz-ui`](sandbox/shuma-voz-ui/) | El indicador de escucha por voz, compartido entre superficies. |
|
||||
|
||||
La superficie de terminal reusable vive en llimphi: `llimphi-widget-terminal` (`02_ruway/llimphi/widgets/terminal`) y `llimphi-module-shuma-term` (`02_ruway/llimphi/modules/shuma-term`, terminal embebible estilo Ctrl+` para cualquier app Llimphi).
|
||||
|
||||
## Crates: matilda (declarative host config)
|
||||
|
||||
| Crate | Rol |
|
||||
|---|---|
|
||||
| [`matilda-core`](matilda/matilda-core/README.md) | Modelo de config declarativa. |
|
||||
| [`matilda-config`](matilda/matilda-config/README.md) | Loader de archivos. |
|
||||
| [`matilda-plan`](matilda/matilda-plan/README.md) | Planificador de diff (estado actual → deseado). |
|
||||
| [`matilda-apply`](matilda/matilda-apply/README.md) | Ejecutor del plan. |
|
||||
| [`matilda-discover`](matilda/matilda-discover/README.md) | Descubrimiento de estado actual. |
|
||||
| [`matilda-linker`](matilda/matilda-linker/README.md) | Enlaza dotfiles. |
|
||||
| [`matilda-ghost`](matilda/matilda-ghost/README.md) | Modo dry-run. |
|
||||
| [`matilda-app`](matilda/matilda-app/README.md) | CLI/UI. |
|
||||
| [`matilda-core`](baremetal/matilda-core/README.md) | Modelo de config declarativa. |
|
||||
| [`matilda-config`](baremetal/matilda-config/README.md) | Loader de archivos. |
|
||||
| [`matilda-plan`](baremetal/matilda-plan/README.md) | Planificador de diff (estado actual → deseado). |
|
||||
| [`matilda-apply`](baremetal/matilda-apply/README.md) | Ejecutor del plan. |
|
||||
| [`matilda-discover`](baremetal/matilda-discover/README.md) | Descubrimiento de estado actual. |
|
||||
| [`matilda-linker`](baremetal/matilda-linker/README.md) | Enlaza dotfiles. |
|
||||
| [`matilda-ghost`](baremetal/matilda-ghost/README.md) | Modo dry-run. |
|
||||
| [`matilda-app`](baremetal/matilda-app/README.md) | CLI/UI. |
|
||||
| [`matilda-android`](baremetal/matilda-android/) | matilda desde el bolsillo: frontend móvil (Llimphi sobre Android NativeActivity) del admin de servidores. |
|
||||
|
||||
## Consideraciones
|
||||
|
||||
- **Reemplazo, no añadido.** Si usás shuma, podés desinstalar zsh/tmux/mosh; todo el comportamiento está cubierto.
|
||||
- **Reemplazo, no añadido.** Si usas shuma, puedes desinstalar zsh/tmux/mosh; todo el comportamiento está cubierto.
|
||||
- **`intent → comando`** es opcional; sin LLM corre el shell tradicional sin diferencia.
|
||||
- Sesiones remotas usan **`shuma-protocol`** sobre TCP/TLS — no requiere demonio SSH.
|
||||
- Las sesiones remotas van por **`shuma-daemon` sobre TCP, cifrado y autenticado con Noise XK** (`shuma-link`, pinning de peers conocidos) — no requiere demonio SSH ni TLS/CA. `shuma-protocol` es el framing del wire (length-prefix + postcard).
|
||||
|
||||
## Estado (2026-06-09)
|
||||
|
||||
- **La superficie de terminal es el path de render por defecto** (SDD-TERMINAL fases 0–5: store de scrollback append-only, modo línea virtualizado, bloques de comando + chrome, selección/copy + find con Ctrl+F, grilla de celdas GPU detrás de `SHUMA_GPU_GRID=1`). El pane legacy queda accesible con `SHUMA_TERMINAL_LEGACY=1`. El scrollback persistente derrama a disco; `:scrollback` / `:scrollback grep <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.
|
||||
|
||||
@@ -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
|
||||
|
||||
M1–M6 entregados 2026-06-13 (varios con el alcance acotado anotado arriba). El
|
||||
tab pasó de "visor declarativo" a **consola de operación viva de una flota**:
|
||||
ves qué corre y qué se cayó en cada host, operas el host montado sin bajar a la
|
||||
terminal, y reconcilias contenedores/vhosts/servicios declarativamente. Operas
|
||||
también recursos de cualquier host de la flota sin montarlo (M5, 2026-06-21).
|
||||
**M1–M6 COMPLETOS** al 2026-06-21. La consola matilda no tiene pendientes de
|
||||
roadmap: polling (local/remoto/flota), acciones (local/Source-remoto/flota),
|
||||
series CPU/mem con sparkline, live-tail `docker logs -f` (local y remoto, vía
|
||||
streaming SSH) y discovery de drift de servicios remotos por SSH — todo
|
||||
cerrado. Lo que reste será pulido o features nuevas, no huecos del plan.
|
||||
@@ -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.
|
||||

|
||||
|
||||
`shuma` replaces zsh + tmux + mosh with a single piece: shell with history/completion/job-control, native multiplexing (no `tmux`), remote sessions (no `mosh`), all inside a Llimphi 4-slot chassis (TopBar, Main, BottomBar, DrawerTab + Quake drawer). The terminal surface is its own render path (append-only scrollback store, virtualized line mode, command blocks, selection/copy and find, GPU cell grid) — see [SDD-TERMINAL.md](SDD-TERMINAL.md). `matilda` is the sibling tool for declarative multi-host configuration.
|
||||
|
||||
## Install
|
||||
|
||||
@@ -15,13 +17,23 @@ cargo run --release -p shuma-daemon
|
||||
## Compatibility
|
||||
|
||||
- **Linux / macOS / Windows** — shell + Llimphi UI.
|
||||
- **Wawa** — runs inside the kernel.
|
||||
- `shuma-protocol` enables local-client + remote-server without SSH.
|
||||
- **Wawa** — planned (no kernel-side port yet).
|
||||
- `shuma-daemon` + `shuma-protocol` enable local-client + remote-server without SSH.
|
||||
|
||||
Crates listed in [README.md](README.md) (shuma + matilda).
|
||||
Crates listed in [LEEME.md](LEEME.md) (shuma + matilda).
|
||||
|
||||
## Considerations
|
||||
|
||||
- **Replacement, not addition.** If you use shuma, you can uninstall zsh/tmux/mosh; behavior fully covered.
|
||||
- **`intent → command`** is optional; without LLM the traditional shell runs unchanged.
|
||||
- Remote sessions use **`shuma-protocol`** over TCP/TLS — no SSH daemon required.
|
||||
- Remote sessions go through **`shuma-daemon` over TCP, encrypted and authenticated with Noise XK** (`shuma-link`, known-peers pinning) — no SSH daemon and no TLS/CA required. `shuma-protocol` is the wire framing (length-prefix + postcard).
|
||||
|
||||
## Status (2026-06-09)
|
||||
|
||||
- **Terminal surface is the default render path** (SDD-TERMINAL phases 0–5: append-only scrollback store, virtualized line mode, command blocks + chrome, selection/copy + Ctrl+F find, GPU cell grid behind `SHUMA_GPU_GRID=1`). Legacy pane stays reachable with `SHUMA_TERMINAL_LEGACY=1`. Persistent scrollback spills to disk; `:scrollback` / `:scrollback grep <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.
|
||||
|
||||
@@ -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.*
|
||||
|
||||
@@ -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`/F1–F8, que
|
||||
ya existen — cablear, no inventar.
|
||||
|
||||
## Riesgos anotados
|
||||
|
||||
- **Privacidad.** El historial lleva rutas, hosts y a veces secretos tipeados.
|
||||
Nada de esto sale de la máquina. `histignorespace` de zsh (comando con espacio
|
||||
al frente = no se guarda) tiene que respetarse también acá.
|
||||
- **Costo por pulsación.** Es lo que motivó las ventanas de 2.000. La regla:
|
||||
el camino del tecleo **lee índices**, nunca recorre el crudo.
|
||||
- **Que el índice mienta.** Reconstruible desde el `.jsonl`, siempre. Y un
|
||||
`:historial verificar` que lo rehaga y compare.
|
||||
- **Aprender basura.** Lo aprendido no puede venir de fixtures ni de
|
||||
importaciones duplicadas — precisamente lo que rompió esto. La Fase 0 es el
|
||||
requisito de todas las demás.
|
||||
@@ -0,0 +1,359 @@
|
||||
# SDD — superficie de terminal infinita y supereficiente
|
||||
|
||||
> Estado: **implementado — Fases 0-5 ✅** (ver §Estado al final) · diseño 2026-06-05, forjado al 2026-06-07.
|
||||
> Idioma del repo: español. Reemplaza, por fases, el `output_pane` actual del shell Llimphi.
|
||||
|
||||
## Tesis
|
||||
|
||||
El shell de tawasuyu existe para **desplanar la terminal**: el output no es un volcado
|
||||
plano, es contenido vivo que se despliega en la ventana a medida que se genera. Hoy
|
||||
eso se logra con cards IDE (numeración, color, selección, badges) — pero el control
|
||||
**no escala**: capa a ~500 líneas y pinta *todo lo que hay*, no *lo que se ve*.
|
||||
|
||||
La apuesta: una **superficie de terminal** virtualizada que (a) sostiene scrollback
|
||||
**ilimitado** a costo de render **constante**, (b) sirve tres modos sobre la misma
|
||||
tela — **línea** (IDE: numerada, selectable), **grilla** (alt-screen TUI), **híbrido**
|
||||
(PTY en modo líneas) — y (c) usa **GPU directo** exactamente donde paga (la grilla y
|
||||
los floods), vello donde alcanza (chrome + línea virtualizada). Sin render plano,
|
||||
jamás.
|
||||
|
||||
## Principios irrenunciables (el norte, no se negocia)
|
||||
|
||||
Lo que define este control y lo separa de una terminal cualquiera (pedido explícito
|
||||
del usuario, 2026-06-05):
|
||||
|
||||
1. **Nunca plano.** El output JAMÁS es un volcado de texto crudo. Siempre es contenido
|
||||
estructurado y vivo (bloques, numeración, color, chrome). Si algo cae a render
|
||||
plano, es un bug, no un fallback aceptable.
|
||||
2. **Interactivo y dinámico.** Se despliega a medida que se genera (streaming), se
|
||||
colapsa/expande, se scrollea fluido, responde al mouse y al teclado. No es estático.
|
||||
3. **Menú contextual + clipboard de primera clase.** Selección moderna (arrastre,
|
||||
doble/triple-click), copiar/pegar **nuestro** (no el del terminal crudo), menú de
|
||||
botón-derecho con acciones — en TODOS los modos (línea, grilla, híbrido), no sólo en
|
||||
líneas.
|
||||
4. **Emula TUIs.** Las apps de pantalla completa (vim/htop/less/…) corren de verdad,
|
||||
con su grilla de celdas, dentro de la misma superficie — no como un terminal opaco
|
||||
"por un vidrio", sino integradas y (donde aplique) con nuestra selección/copia.
|
||||
|
||||
Todo lo de abajo está al servicio de estos cuatro puntos.
|
||||
|
||||
## La limitación actual (el porqué de este SDD)
|
||||
|
||||
- `MAX_OUTPUT_LINES = 500` (`shuma-module-shell/src/lib.rs`): el buffer se capa. Si no,
|
||||
el render explota.
|
||||
- `output_pane` (`view.rs`) arma **un text-editor por comando**, cada uno pintando
|
||||
**todas** sus líneas (subimos el cap embebido a `EMBEDDED_LINE_CAP = 512`), y el
|
||||
panel **traslada** todo con un `transform` de scroll.
|
||||
- Resultado: ~500 Views pintadas por frame es el techo (pared de `wgpu`
|
||||
`max_*_buffer_binding_size` + costo de layout). Y el modelo "editor por comando +
|
||||
panel que traslada" fue la fuente de bugs reales (negro al anclar al fondo,
|
||||
desalineación gutter/contenido por el transform multicolor del compositor —
|
||||
arreglado en commit `caf37079`).
|
||||
|
||||
**Conclusión:** el techo no es un número a subir, es la arquitectura. Para infinito hay
|
||||
que **virtualizar** (pintar sólo la ventana visible) y dejar el scroll a UN control,
|
||||
no a editores anidados que el panel traslada.
|
||||
|
||||
## Arquitectura — capas estrictas
|
||||
|
||||
```
|
||||
┌─ Capa 4 · Interacción ────────────────────────────────────────────┐
|
||||
│ selección sobre el stream · numeración · find · menú · copy/paste │
|
||||
├─ Capa 3 · Render ─────────────────────────────────────────────────┤
|
||||
│ GPU-directo (atlas glifos + celdas instanciadas) → grilla/flood │
|
||||
│ vello → chrome (cards/badges/colapsables) + modo línea virtualiz. │
|
||||
├─ Capa 2 · Virtualización ─────────────────────────────────────────┤
|
||||
│ ventana visible (fila inicial..fila final) sobre el viewport; │
|
||||
│ sólo esas filas/bloques se materializan en Views/draws │
|
||||
├─ Capa 1 · Modelo de bloques ──────────────────────────────────────┤
|
||||
│ stream de bloques (comando = header+cuerpo+badge+stages+colapso) │
|
||||
│ cada bloque indexa su rango de filas en el store │
|
||||
├─ Capa 0 · Store de scrollback ────────────────────────────────────┤
|
||||
│ append-only, compacto: bytes + índice de offsets de línea; │
|
||||
│ cap por MEMORIA (MB), no por líneas; spill a disco opcional │
|
||||
└────────────────────────────────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
Regla dura del repo: **núcleo agnóstico, frontend lo pinta** (Regla 2). Por eso:
|
||||
|
||||
- **`llimphi-widget-terminal`** (crate nuevo, reusable — logs, consolas, no sólo shuma):
|
||||
Capas 0–4 agnósticas de shuma. No sabe de comandos; sabe de *bloques de filas* con
|
||||
un `BlockKind` (líneas numeradas / grilla / chrome opaco que el caller pinta).
|
||||
- **shuma** maneja el modelo de comando (header/badge/stages/reprocess) como
|
||||
*decoración de bloque* que inyecta al widget; el widget virtualiza y pinta.
|
||||
|
||||
## Principio rector: **un control, los paneles son datos** (no al revés)
|
||||
|
||||
La inversión que hace funcionar todo lo de abajo. En el diseño viejo cada panel
|
||||
(card de comando) **era un control** con su propio scroll/estado (`text_editor` por
|
||||
comando) y un contenedor los **trasladaba a todos** con un `transform`. Aquí es al
|
||||
revés: hay **un solo control** (la superficie) y los paneles son **items de datos**
|
||||
(`Item::Chrome` / `Item::Lines`) que el control coloca y virtualiza. Las ventajas
|
||||
—por las que se eligió esta forma, no por estética—:
|
||||
|
||||
1. **Costo de render desacoplado del contenido.** Un único scroller virtualiza: el
|
||||
costo es ∝ la ventana visible, **no** ∝ la cantidad de paneles ni de líneas. El
|
||||
modelo "un control por panel" pagaba por *cada* panel siempre — la pared de ~500.
|
||||
2. **Un scroll, un sistema de coordenadas.** Sin transforms anidados → mata de raíz
|
||||
la clase de bug clip+transform (negro al anclar, desalineación gutter). Y habilita
|
||||
la **selección/find sobre todo el stream** (Capa 4) en un único espacio
|
||||
`(fila global, columna)`, no card-por-card.
|
||||
3. **Estado mínimo.** Los paneles son datos planos rearmados desde el modelo cada
|
||||
frame; no hay estado de widget por-panel que sincronizar o que se filtre.
|
||||
Colapsar/reordenar/insertar = cambiar la lista de items.
|
||||
4. **La composición GPU encaja (Capa 3).** Como la superficie es dueña de todo el
|
||||
paint, compone **una** pasada GPU-directo (celdas de grilla) + **una** pasada vello
|
||||
(líneas/chrome) en una sola escena. Con controles independientes por panel, esa
|
||||
pasada única sería imposible.
|
||||
|
||||
Precio aceptado (no es gratis): el caller arma el chrome de **todos** los bloques por
|
||||
frame (O(n_bloques); el control descarta los no visibles) y los paneles pierden estado
|
||||
local salvo que se modele como dato. Para este dominio —líneas ilimitadas, bloques
|
||||
acotados a lo que un humano tipea— es claramente conveniente: lo verdaderamente
|
||||
ilimitado (las líneas) se virtualiza de raíz; los bloques están acotados por
|
||||
naturaleza. Si algún día importara, el paso es **chrome lazy** (`Fn() -> View` por
|
||||
item en vez del `View` ya construido).
|
||||
|
||||
## Capa 0 — Store de scrollback
|
||||
|
||||
- **Append-only.** Cada línea (o chunk de bytes del PTY) se appendea. Nunca se
|
||||
reescribe lo viejo.
|
||||
- **Compacto.** Texto en un `Vec<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 1–2 en
|
||||
`llimphi-widget-terminal::view`: `line_surface` materializa **sólo** la ventana
|
||||
visible (`visible_window`, pura y testeada) bajo un `scroll_y` **propio del
|
||||
widget** (no transform de contenido alto — la anti-feature del SDD), con
|
||||
numeración global 1-based del store, color base + runs + tinte de fondo por
|
||||
renglón (inyectados por el caller vía `LineStyle`, Regla 2), scrollbar via
|
||||
`thumb_geometry` dimensionada al alto TOTAL virtual, scroll sub-renglón
|
||||
(`partial_px`) y painter de medición del viewport. 19 tests (store + ventana).
|
||||
**Verificado headless** (`examples/dump_terminal.rs`): 1 M de líneas, anclado al
|
||||
fondo → **38 filas materializadas** (999963..1000000), sin negro, alineado,
|
||||
costo constante (independiente del scrollback). Falta: enganchar al shell
|
||||
(Fase 2 trae bloques/chrome y el flag `SHUMA_TERMINAL_SURFACE`).
|
||||
- **Fase 2 — Bloques + chrome. ✅ (2026-06-05)** Capa 1 en
|
||||
`llimphi-widget-terminal::blocks`: el stream es una secuencia de `Item`s —
|
||||
`Chrome{height, view}` (header/badge/etapa de alto fijo que el caller pinta) o
|
||||
`Lines{start, end}` (rango del store en modo línea). `block_surface` virtualiza
|
||||
sobre **alturas mixtas**: `item_tops` + `visible_items` (búsqueda binaria,
|
||||
O(log n) en bloques) localizan los items que tocan el viewport, y dentro de un
|
||||
`Lines` enorme `visible_rows_in_item` materializa sólo las sub-filas visibles —
|
||||
costo constante aunque un body tenga 500 k líneas. **Colapsar** = no emitir el
|
||||
`Lines`. El modo línea de la Fase 1 quedó **unificado** como el caso de un solo
|
||||
`Item::Lines(0, len)` (delega en `block_surface`, sin duplicar render). 26 tests.
|
||||
**Verificado headless** (`examples/dump_blocks.rs`): 6 comandos, un flood de
|
||||
500 k líneas, un bloque colapsado, stderr tintado, anclado al fondo → ~40 filas
|
||||
materializadas.
|
||||
- **Integración al shell ✅ (2026-06-05).** `output_pane_surface` en
|
||||
`shuma-module-shell/src/view.rs` mapea el modelo del shell
|
||||
(`OutputLine`/bloques/`collapsed`/`block_command`) a `Item`s: cada comando =
|
||||
un header chrome (`surface_header`: chevron + `$ cmd` + badge, click→colapso)
|
||||
+ su cuerpo (rango en un `Scrollback`), reusando
|
||||
`body_lines_for_block`/`body_color_runs`/`CmdStatus`. Conversión de scroll
|
||||
`scroll_px` (desde el fondo) ↔ `scroll_y` (desde arriba); rueda/arrastre del
|
||||
widget → `Msg::Scroll(-delta)`. Detrás del flag **`SHUMA_TERMINAL_SURFACE`**
|
||||
(env, leído una vez); el `output_pane` viejo queda intacto para A/B y
|
||||
rollback. **Verificado** (`examples/dump_surface.rs`, viewport sembrado +
|
||||
scroll al fondo): flood de 3 000 líneas virtualizado, bloque colapsado,
|
||||
stderr tintado, anclado al fondo, sin negro, en la composición real del
|
||||
`view()`. 94 tests del shell pasan; `output_pane` sin cambios.
|
||||
- Deuda de paridad (no crítica): filas de etapa (tee) y chip de reprocess del
|
||||
header todavía no están en el chrome de la superficie; numeración global
|
||||
continua (no por-bloque). Se cierran antes de la migración (Fase 5).
|
||||
- **Fase 3 — Selección + find sobre el stream.** Extraer el núcleo de selección del
|
||||
`text-editor` a compartido; selección global; copy; Ctrl+F.
|
||||
- **Fase 4 — GPU directo grilla.** Atlas de glifos + celdas instanciadas para el modo
|
||||
grilla (TUI). Bench vs el grid vt100 actual. Híbrido.
|
||||
- **Fase 5 — Pulido + migración. ✅** Anclaje estable bajo append, scroll inertial,
|
||||
spill a disco, y **borrado del `output_pane`/per-command-editor viejo**
|
||||
(2026-06-14): la superficie es la **única** vía de output (salvo PTY/TUI
|
||||
fullscreen). Se eliminaron `view/output_pane.rs`, la fn `command_card` +
|
||||
`pipe_stages_row`, `render_output_line` + sus helpers exclusivos
|
||||
(`build_span_children`/`kind_icon`/`partition_line`/`LinePiece`), el menú
|
||||
legacy (`view/chrome.rs::body_context_menu`), la maquinaria del editor IDE
|
||||
per-comando (`body_sel`/`body_menu`/`body_drag_accum`, `apply_body_pointer`,
|
||||
`apply_body_double_click`, `body_editor_state`, los `Msg`
|
||||
`BodyPointer`/`BodyDoubleClick`/`CopyBody`/`OpenBodyMenu`/`BodyMenu{Pick,Dismiss}`)
|
||||
y el flag `terminal_surface_enabled`/`SHUMA_TERMINAL_LEGACY`. Quedan, ahora
|
||||
como helpers compartidos por la superficie, `stage_capture_rows`,
|
||||
`copy_command_block`+`Msg::CopyCommandBlock`, `word_range_at`, `mix_color`,
|
||||
`ROW_H`/`STAGES_H`/`COLLAPSE_ANIM`, `pty_lines_panel` y `body_editor_metrics`/
|
||||
`body_editor_palette`. `cargo check --workspace` verde; 181 tests pasan
|
||||
(los 2 que fallan ya fallaban en `main`, sin relación con esto).
|
||||
|
||||
Cada fase es un commit (o pocos) verificado con render headless + viewport medido, y
|
||||
deja el shell funcionando (flag de migración hasta la Fase 5).
|
||||
|
||||
## Cómo reemplazó al `output_pane` (sin romper)
|
||||
|
||||
- Fases 1–4 convivieron con el `output_pane` viejo detrás de un flag
|
||||
(`SHUMA_TERMINAL_SURFACE` / opt-out `SHUMA_TERMINAL_LEGACY`), para A/B y
|
||||
rollback inmediato.
|
||||
- El modelo de datos no cambió de raíz: las `OutputLine` + `block_command` +
|
||||
`expanded_stages` se mapean a bloques de la superficie. El emulador vt100 y la
|
||||
detección de alt-screen se reusan.
|
||||
- La **Fase 5 borró el camino viejo** (2026-06-14) una vez verificada la
|
||||
paridad: la superficie tiene su propia selección/copy/find/menú sobre el
|
||||
stream (`surf_*`), así que el editor IDE per-comando y su menú legacy ya no
|
||||
aportaban nada. Ya no hay flag: la superficie es el único path (salvo PTY/TUI
|
||||
fullscreen).
|
||||
|
||||
## Anti-features (rechazadas con motivo)
|
||||
|
||||
- **Subir `MAX_OUTPUT_LINES` y ya.** Mueve la pared, no la rompe; sigue siendo
|
||||
"render todo".
|
||||
- **Un text-editor gigante para todo el scrollback.** El widget de archivo virtualiza
|
||||
pero no modela bloques (header/badge/grilla); forzarlo es lo que ya rompió.
|
||||
- **GPU directo para TODO.** El modo línea no lo necesita; meterlo ahí es complejidad
|
||||
sin payoff y pelea con vello (texto rico).
|
||||
- **Scroll por `transform` del panel sobre contenido alto.** Es la fuente del bug
|
||||
clip+transform. El scroll vive en la superficie, que sólo materializa lo visible.
|
||||
|
||||
## Pila exacta (sin negociación)
|
||||
|
||||
- Crate `llimphi-widget-terminal` (Capas 0–4 agnósticas), consumido por
|
||||
`shuma-module-shell`.
|
||||
- Texto: `llimphi-text` (vello) para modo línea + chrome.
|
||||
- Grilla: `llimphi-raster` GPU directo (`GpuPipelines`, patrón persistente) +
|
||||
`fontdue`/atlas para el glyph cache (precedente: `atlas` de wawa, Fontdue).
|
||||
- vt100: el parser ya en uso (`vt100` crate) para grilla/híbrido.
|
||||
- Núcleo de selección/find: extraído de `text-editor` a compartido, NO duplicado.
|
||||
|
||||
## Referencias
|
||||
|
||||
- Código actual: `shuma-module-shell/src/view.rs` (`output_pane`, `command_card`,
|
||||
`body_editor_*`), `lib.rs` (`MAX_OUTPUT_LINES`, `block_command`).
|
||||
- Bugs que motivaron esto: negro al anclar al fondo + desalineación gutter/contenido →
|
||||
fix de raíz del transform multicolor (commit `caf37079`); cap embebido
|
||||
(`2038492c`); ruteo a card IDE (`01befe89`).
|
||||
- GPU directo validado: [[project_gpu_directo_bench_pending]] (141 fps @ 1M, Iris Xe).
|
||||
- Plan de UX del output: `PLAN-OUTPUT.md` (deudas D/find, anclaje estable).
|
||||
- Memoria viva: [[project_shuma_output_ux]], [[project_shuma_rescate]].
|
||||
|
||||
## Estado
|
||||
|
||||
**Implementado al 2026-06-07; migración cerrada el 2026-06-14.** Fases 0-5 ✅ (foundation, virtualización, bloques, selección + copy + find, GPU grid behind `SHUMA_GPU_GRID=1`, pulido y migración). La superficie es **el único path** de output (salvo PTY/TUI fullscreen): el `output_pane` viejo + las cards per-comando IDE + su menú legacy + el flag `SHUMA_TERMINAL_LEGACY` fueron **borrados** (no hay más opt-out).
|
||||
|
||||
**Cerrado en Fase 5**:
|
||||
- Anclaje estable bajo append (no más jiggle al recibir output mientras se lee historia).
|
||||
- Doble-click select-word + triple-click select-line.
|
||||
- Scroll inercial (touchpad/wheel decay).
|
||||
- Menú contextual right-click (Copiar / Copiar todo / Seleccionar todo) sobre el stream (`surf_*`).
|
||||
- Spill a disco: configurable vía `[scrollback]` en `shumarc.toml`, archive automático al recortar el frente, chip de status en UI, builtin `:scrollback open` para abrirlo con `$EDITOR`.
|
||||
- **Borrado del `output_pane`/per-command-editor viejo** (2026-06-14): ver el detalle en la lista de Fases (Fase 5). Migración de `view()`/`body_view()` a `output_pane_surface` incondicional; eliminados los módulos/funciones/`Msg`/campos de `State` legacy; helpers compartidos reubicados. `cargo check --workspace` verde, downstream (`shuma-shell-llimphi`, `pata-llimphi`, `shuma-cli`) compila, 181 tests pasan (2 fallos pre-existentes en `main`).
|
||||
|
||||
**Fase 5.12 — paginado del archive al scrollear (✅ 2026-06-21):** el view ya
|
||||
no se queda en las últimas `MAX_SPILLED_VISIBLE` (200) líneas spilled. El cache
|
||||
(`SurfSpilledCache`) gana `window_start: Option<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.
|
||||
@@ -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(¤t, &Inventory::new()), &Inventory::new());
|
||||
assert!(steps
|
||||
.iter()
|
||||
.flat_map(|s| s.commands.iter())
|
||||
.any(|c| c == "systemctl disable --now viejo.service"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn host_actions_produce_no_steps() {
|
||||
let mut desired = Inventory::new();
|
||||
|
||||
@@ -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 acá.
|
||||
`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, ¤t);
|
||||
assert_eq!(p.count(Op::Create), 0, "el servicio existe → no Create");
|
||||
assert_eq!(p.count(Op::Update), 0, "coincide → no Update");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn servicio_remoto_desviado_emite_update() {
|
||||
use matilda_core::{Inventory, Service};
|
||||
let mut desired = Inventory::new();
|
||||
desired.add_service(Service::new("nginx")); // quiere enabled+active
|
||||
// Observado enabled pero NO active → drift → Update (no Create).
|
||||
let services = parse_service_states("nginx.service\tenabled\tinactive\n");
|
||||
let state = ServerState { containers: vec![], vhosts: vec![], services };
|
||||
let current = observed_inventory(&state, &desired);
|
||||
let p = plan(&desired, ¤t);
|
||||
assert_eq!(p.count(Op::Create), 0);
|
||||
assert_eq!(p.count(Op::Update), 1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parse_docker_stats_porcentajes() {
|
||||
let text = "web\t12.34%\t5.67%\ndb\t0.00%\t40.10%\nbad\t--\t--\n";
|
||||
let m = parse_docker_stats(text);
|
||||
assert_eq!(m.len(), 3);
|
||||
assert!((m["web"].cpu_pct - 12.34).abs() < 0.01);
|
||||
assert!((m["web"].mem_pct - 5.67).abs() < 0.01);
|
||||
assert_eq!(m["db"].cpu_pct, 0.0);
|
||||
// `--` (sin dato) cae a 0.0 sin romper.
|
||||
assert_eq!(m["bad"].cpu_pct, 0.0);
|
||||
assert_eq!(m["bad"].mem_pct, 0.0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn runtime_state_cuenta_up_down() {
|
||||
let rs = RuntimeState {
|
||||
containers: vec![
|
||||
ContainerStatus {
|
||||
name: "a".into(),
|
||||
image: "x".into(),
|
||||
state: RunState::Running,
|
||||
status: "Up".into(),
|
||||
ports: String::new(),
|
||||
},
|
||||
ContainerStatus {
|
||||
name: "b".into(),
|
||||
image: "y".into(),
|
||||
state: RunState::Exited,
|
||||
status: "Exited".into(),
|
||||
ports: String::new(),
|
||||
},
|
||||
ContainerStatus {
|
||||
name: "c".into(),
|
||||
image: "z".into(),
|
||||
state: RunState::Restarting,
|
||||
status: "Restarting".into(),
|
||||
ports: String::new(),
|
||||
},
|
||||
],
|
||||
services: vec![],
|
||||
vhosts: vec![],
|
||||
};
|
||||
assert_eq!(rs.up_count(), 2); // running + restarting
|
||||
assert_eq!(rs.down_count(), 1);
|
||||
assert_eq!(rs.container("b").unwrap().state, RunState::Exited);
|
||||
assert!(rs.container("nope").is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parse_systemctl_units_y_conteos() {
|
||||
let text = "sshd.service loaded active running OpenSSH server daemon\n\
|
||||
nginx.service loaded active running A high performance web server\n\
|
||||
backup.service loaded failed failed Nightly backup\n";
|
||||
let svcs = parse_systemctl_units(text);
|
||||
assert_eq!(svcs.len(), 3);
|
||||
assert_eq!(svcs[0].name, "sshd.service");
|
||||
assert_eq!(svcs[0].state, ServiceState::Active);
|
||||
assert_eq!(svcs[0].description, "OpenSSH server daemon");
|
||||
assert_eq!(svcs[2].state, ServiceState::Failed);
|
||||
let rs = RuntimeState { containers: vec![], services: svcs, vhosts: vec![] };
|
||||
assert_eq!(rs.services_active(), 2);
|
||||
assert_eq!(rs.services_failed(), 1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn run_state_glyphs_y_mapeo() {
|
||||
assert_eq!(RunState::from_docker("RUNNING"), RunState::Running);
|
||||
assert_eq!(RunState::from_docker("up"), RunState::Running);
|
||||
assert_eq!(RunState::from_docker("dead"), RunState::Dead);
|
||||
assert_eq!(RunState::from_docker("???"), RunState::Unknown);
|
||||
assert_eq!(RunState::Running.glyph(), '●');
|
||||
assert_eq!(RunState::Exited.glyph(), '○');
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parses_docker_names() {
|
||||
let names = parse_docker_names("web\napi\n\n db \n");
|
||||
@@ -253,7 +788,7 @@ mod tests {
|
||||
// Un contenedor presente que también se desea → sin cambios.
|
||||
let mut desired = Inventory::new();
|
||||
desired.add_container(Container::new("web", "nginx:1.27"));
|
||||
let state = ServerState { containers: vec!["web".into()], vhosts: vec![] };
|
||||
let state = ServerState { containers: vec!["web".into()], vhosts: vec![], services: vec![] };
|
||||
let current = observed_inventory(&state, &desired);
|
||||
let p = plan(¤t, &desired);
|
||||
assert!(p.is_empty(), "presente y deseado → sin acciones");
|
||||
@@ -263,7 +798,7 @@ mod tests {
|
||||
fn observed_orphan_becomes_a_removal() {
|
||||
// Un contenedor presente que NO se desea → se elimina.
|
||||
let desired = Inventory::new();
|
||||
let state = ServerState { containers: vec!["viejo".into()], vhosts: vec![] };
|
||||
let state = ServerState { containers: vec!["viejo".into()], vhosts: vec![], services: vec![] };
|
||||
let current = observed_inventory(&state, &desired);
|
||||
let p = plan(¤t, &desired);
|
||||
assert_eq!(p.count(Op::Remove), 1);
|
||||
@@ -284,7 +819,7 @@ mod tests {
|
||||
fn create_and_remove_together() {
|
||||
let mut desired = Inventory::new();
|
||||
desired.add_container(Container::new("nuevo", "img:1"));
|
||||
let state = ServerState { containers: vec!["viejo".into()], vhosts: vec![] };
|
||||
let state = ServerState { containers: vec!["viejo".into()], vhosts: vec![], services: vec![] };
|
||||
let p = plan(&observed_inventory(&state, &desired), &desired);
|
||||
assert_eq!(p.count(Op::Create), 1);
|
||||
assert_eq!(p.count(Op::Remove), 1);
|
||||
|
||||
@@ -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(¤t, &desired);
|
||||
assert_eq!(p.actions, vec![Action::new(Op::Update, Resource::Service, "nginx.service")]);
|
||||
|
||||
// Remove: current lo tiene, desired no.
|
||||
let p = plan(¤t, &Inventory::new());
|
||||
assert_eq!(p.actions, vec![Action::new(Op::Remove, Resource::Service, "nginx.service")]);
|
||||
|
||||
// Igual → sin acciones.
|
||||
assert!(plan(&desired, &desired.clone()).is_empty());
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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.0–1.0 (bajo para tareas técnicas, alto para creativo).
|
||||
#[serde(default = "temperatura_default")]
|
||||
pub temperatura: f32,
|
||||
/// Tope de tokens de salida por turno.
|
||||
#[serde(default = "max_tokens_default")]
|
||||
pub max_tokens: u32,
|
||||
/// Qué acciones de control puede **proponer** el agente.
|
||||
#[serde(default)]
|
||||
pub capacidades: Capacidades,
|
||||
/// Color de acento (hex `#rrggbb`) para la UI; `None` = el del theme.
|
||||
#[serde(default)]
|
||||
pub color: Option<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]
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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"]
|
||||
|
||||
@@ -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(®, &id);
|
||||
let sid = reg.snapshot(&id).unwrap().claude_session_id.clone();
|
||||
let r1 = texto_final(®, &id);
|
||||
println!("── turno 1 ──");
|
||||
println!("session_id de claude: {sid:?}");
|
||||
println!("respuesta: {r1}");
|
||||
assert!(sid.is_some(), "debió aprender el session_id");
|
||||
|
||||
// Turno 2: reanuda (--resume) y prueba continuidad de contexto.
|
||||
reg.enviar(&id, "¿Qué palabra te pedí recién? Responde sólo esa palabra.")
|
||||
.expect("enviar");
|
||||
esperar_idle(®, &id);
|
||||
let r2 = texto_final(®, &id);
|
||||
println!("── turno 2 (--resume) ──");
|
||||
println!("respuesta: {r2}");
|
||||
|
||||
let ok = r2.to_lowercase().contains("banana");
|
||||
println!("\n{}", if ok { "✓ RESUME OK — recordó el contexto" } else { "✗ no recordó" });
|
||||
if !ok {
|
||||
eprintln!("── DEBUG turnos ──\n{:#?}", reg.snapshot(&id).unwrap().turnos);
|
||||
}
|
||||
assert!(ok, "el 2º turno no recordó la palabra → --resume no continuó la sesión");
|
||||
println!("✓ e2e real verde: {} turnos, sesión reanudada", reg.snapshot(&id).unwrap().turnos.len());
|
||||
}
|
||||
@@ -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");
|
||||
}
|
||||
@@ -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 acá porque
|
||||
/// (daemon) los relance — no podemos relanzarlos desde aquí porque
|
||||
/// `run_pipeline` necesita `Incarnator` + `DiscernPipeline`.
|
||||
/// Errores no-fatales (workspaces inválidos) se loguean y se saltan.
|
||||
pub async fn restore_snapshot(
|
||||
|
||||
@@ -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
@@ -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 acá es lógica pura y serializable — el front-end GPUI (las tres
|
||||
//! Todo aquí es lógica pura y serializable — el front-end GPUI (las tres
|
||||
//! zonas: RUN, SENS y el lienzo central) lo rehidrata; la ejecución real
|
||||
//! la hace `sandokan`.
|
||||
|
||||
|
||||
@@ -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://"];
|
||||
|
||||
@@ -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/...). Acá cubrimos el caso del shell, donde sólo tenemos el
|
||||
//! video/...). Aquí cubrimos el caso del shell, donde sólo tenemos el
|
||||
//! path en disco (sin samplear bytes), así que vamos por extensión.
|
||||
//!
|
||||
//! Dos salidas paralelas, ambas UI-agnósticas:
|
||||
|
||||
@@ -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 acá — el chasis intercepta esta variante antes de
|
||||
// No-op aquí — el chasis intercepta esta variante antes de
|
||||
// que entre al update del canvas. Si llega es porque el
|
||||
// canvas está corriendo standalone (sin chasis); no podemos
|
||||
// hacer nada útil sin acceso al shell.
|
||||
|
||||
@@ -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
Reference in New Issue
Block a user