3 Commits

Author SHA1 Message Date
egutierrez 729921e16e feat(cybersecurity): auto-commit con 48 cambios
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-06-04 23:44:39 +02:00
egutierrez efc9911925 feat(kotlin-compose): design system fn.compose:ui + toolbelt android Linux-first
Design system Compose (kotlin/functions/ui, modulo Gradle `fn.compose:ui`):
- FnTokens + FnTheme con la paleta heredada al hex de cpp/DESIGN_SYSTEM.md
  (Mantine v9 dark + indigo), identica a la web @fn_library y a las apps C++.
- 26 componentes Compose (Layout/Display/Inputs/Feedback/Data/Charts) +
  FnTheme + FnTokens registrados en el registry (28 entradas kind=component
  lang=kt domain=ui), descubribles via fn_search. Habilitan init_kotlin_app.

Recuperacion: el commit cb6d9e6 habia anadido `kotlin/functions/ui/` al
.gitignore, por eso el design system nunca se versiono y se perdio del working
tree. Des-ignorado; el .gitignore interno del modulo ya excluye
build/.gradle/local.properties. La gallery (apps/gallery_kt) se recupero del
sub-repo Gitea y sus 27 componentes se reconstruyeron con su MainActivity como
contrato exacto.

Toolbelt Android Linux-first (antes asumia WSL2 + Windows):
- adb_wsl 1.1.0, android_emulator_start 1.1.0, android_emulator_list 1.1.0:
  resuelven adb/emulator nativos del SDK ($ANDROID_HOME), .exe solo fallback WSL2.
- android_emulator_start: fix `timeout adb_run wait-for-device` (timeout no puede
  ejecutar una funcion del shell; ahora invoca el binario $ADB directamente).
- install_android_sdk 1.0.1: fix licencias bajo pipefail (SIGPIPE de `yes`) +
  trap EXIT con variable unbound.
- docs/capabilities/android.md regenerado Linux-first + seccion design system.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-04 23:43:59 +02:00
egutierrez c65f1698ae fix(infra): write_mcp_jupyter_config usa wrapper jupyter_mcp_serve con el venv del analysis
El .mcp.json generado ahora apunta al wrapper jupyter_mcp_serve.sh con env overrides
(JUPYTER_MCP_VENV/ROOT/PORT/TOKEN) en vez del console-script jupyter-mcp-server directo.

Antes: el .mcp.json solo CONECTABA a un Jupyter ya existente y, si se abria Claude
desde la raiz del repo, el MCP usaba el venv canonico python/.venv (sin las deps del
analisis). Ahora el wrapper arranca (o reusa) un Jupyter con el venv del propio
analisis, asi que abrir Claude desde el directorio del analisis basta y cada analisis
ejecuta con sus dependencias sin contaminar python/.venv.

Bump v1.2.0. Declara dependencia jupyter_mcp_serve_bash_infra.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-03 21:48:01 +02:00
105 changed files with 7010 additions and 149 deletions
+29 -7
View File
@@ -4,8 +4,14 @@
{
"matcher": "Bash",
"hooks": [
{ "type": "command", "command": "$CLAUDE_PROJECT_DIR/.claude/scripts/hook_registry_mcp.sh" },
{ "type": "command", "command": "$CLAUDE_PROJECT_DIR/.claude/scripts/hook_fn_match.sh" }
{
"type": "command",
"command": "$CLAUDE_PROJECT_DIR/.claude/scripts/hook_registry_mcp.sh"
},
{
"type": "command",
"command": "$CLAUDE_PROJECT_DIR/.claude/scripts/hook_fn_match.sh"
}
]
}
],
@@ -13,23 +19,39 @@
{
"matcher": "Bash|Edit|Write|MultiEdit|mcp__registry__.*",
"hooks": [
{ "type": "command", "command": "$CLAUDE_PROJECT_DIR/.claude/scripts/hook_call_monitor.sh" }
{
"type": "command",
"command": "$CLAUDE_PROJECT_DIR/.claude/scripts/hook_call_monitor.sh"
}
]
},
{
"matcher": "Edit|Write|MultiEdit|mcp__registry__fn_create_function",
"hooks": [
{ "type": "command", "command": "$CLAUDE_PROJECT_DIR/.claude/scripts/hook_capability_tag_gate.sh" }
{
"type": "command",
"command": "$CLAUDE_PROJECT_DIR/.claude/scripts/hook_capability_tag_gate.sh"
}
]
}
],
"UserPromptSubmit": [
{
"hooks": [
{ "type": "command", "command": "$CLAUDE_PROJECT_DIR/.claude/scripts/hook_capabilities_inject.sh" },
{ "type": "command", "command": "$CLAUDE_PROJECT_DIR/.claude/scripts/hook_registry_first_reminder.sh" }
{
"type": "command",
"command": "$CLAUDE_PROJECT_DIR/.claude/scripts/hook_capabilities_inject.sh"
},
{
"type": "command",
"command": "$CLAUDE_PROJECT_DIR/.claude/scripts/hook_registry_first_reminder.sh"
}
]
}
]
}
},
"enabledMcpjsonServers": [
"registry",
"jupyter"
]
}
-1
View File
@@ -81,7 +81,6 @@ Thumbs.db
broken_paths.txt
imgui.ini
prompts/
kotlin/functions/ui/
# Module versioning auto-generated headers (written by `fn index`, issue 0097)
**/version_generated.h
+7
View File
@@ -0,0 +1,7 @@
{
"0ea5e69b-9607-4f11-b740-005e835faef6": {
"version": "2.4.0",
"created_at": "2026-06-03T17:52:16.077873+00:00",
"document_version": "2.0.0"
}
}
Binary file not shown.
+42 -44
View File
@@ -3,17 +3,17 @@ name: adb_wsl
kind: function
lang: bash
domain: infra
version: "1.0.0"
version: "1.1.0"
purity: impure
signature: "source adb_wsl.sh [ADB=<path>] [ANDROID_SDK_WIN=<sdk_root>]"
description: "Wrapper sourceable para usar adb.exe Windows desde WSL2. Resuelve binario, convierte paths, espera boot del emulador."
tags: ["android", "adb", "wsl", "windows"]
signature: "source adb_wsl.sh [ADB=<path>] [ANDROID_HOME=<sdk_root>]"
description: "Wrapper sourceable para resolver e invocar adb. Linux-first: usa el adb nativo del Android SDK ($ANDROID_HOME) o del PATH; fallback a adb.exe solo si detecta WSL2. Expone adb_run, adb_devices, adb_pick_serial, adb_s, adb_wait_boot."
tags: ["android", "adb", "linux", "emulator", "wsl"]
params:
- name: ADB
desc: "Env var opcional. Path absoluto a adb.exe. Si no se fija, se construye desde ANDROID_SDK_WIN o el default /mnt/c/Users/lucas/AppData/Local/Android/Sdk."
- name: ANDROID_SDK_WIN
desc: "Env var opcional. Raiz del Android SDK montado en WSL. Default: /mnt/c/Users/lucas/AppData/Local/Android/Sdk."
output: "Source-able shell helpers: adb_run, adb_devices, adb_wsl_to_win, adb_wait_boot. Define ADB env var apuntando a Windows adb.exe via ANDROID_SDK_WIN."
desc: "Env var opcional. Path absoluto al binario adb (override explicito). Si no se fija, se resuelve Linux-first: $ANDROID_HOME/platform-tools/adb, luego adb del PATH, luego adb.exe si WSL2."
- name: ANDROID_HOME
desc: "Env var opcional. Raiz del Android SDK nativo. Si esta presente, se usa $ANDROID_HOME/platform-tools/adb. Tambien se acepta ANDROID_SDK_ROOT."
output: "Source-able shell helpers: adb_run, adb_devices, adb_pick_serial, adb_s, adb_wait_boot, adb_wsl_to_win. Resuelve y fija la env var ADB al binario adb disponible."
uses_functions: []
uses_types: []
returns: []
@@ -26,24 +26,33 @@ test_file_path: ""
file_path: "bash/functions/infra/adb_wsl.sh"
---
## Uso
## Cuando usarla
Sourcéala como capa base de cualquier script que hable con un device o emulador Android via adb. Es la dependencia comun de todo el toolbelt android del registry (`android_screenshot`, `android_input_*`, `android_logcat`, `android_app_*`, `android_push/pull`). En Linux nativo resuelve el adb del SDK automaticamente; no hace falta configurar nada si `ANDROID_HOME` esta exportado (o `adb` esta en el PATH).
## Ejemplo
```bash
# Sourcear (usa SDK default)
# Linux nativo: con el SDK instalado y ANDROID_HOME exportado, resuelve solo.
source ~/android-sdk/env.sh
source bash/functions/infra/adb_wsl.sh
adb_devices
# List of devices attached
# emulator-5554 device
# Sourcear con SDK custom
ANDROID_SDK_WIN=/mnt/d/Android/Sdk source bash/functions/infra/adb_wsl.sh
# Fijar binario adb explicito (override)
ADB=/opt/android/platform-tools/adb source bash/functions/infra/adb_wsl.sh
# Sourcear con binario fijo
ADB=/mnt/c/my/tools/adb.exe source bash/functions/infra/adb_wsl.sh
# Smoke test
bash bash/functions/infra/adb_wsl.sh --self-test
# Android Debug Bridge version 1.0.41
```
## Funciones expuestas
### `adb_run "<args...>"`
Ejecuta `$ADB` con los argumentos dados. Retorna el exit code de `adb.exe`.
Ejecuta `$ADB` con los argumentos dados. Retorna el exit code de adb.
```bash
adb_run shell ls /sdcard/
@@ -54,45 +63,34 @@ adb_run install app.apk
Alias de `adb_run devices`. Lista dispositivos/emuladores conectados.
```bash
adb_devices
# List of devices attached
# emulator-5554 device
```
### `adb_pick_serial [--serial <S>] [...]`
### `adb_wsl_to_win <path_wsl>`
Convierte un path WSL a formato Windows con `wslpath -w`. Si `wslpath` no está disponible retorna el path sin convertir.
Resuelve el serial a usar (multi-device). Lee `--serial X` de los args y setea los globals `ADB_PICK_SERIAL` y `ADB_PICK_REST`. Si no se pasa, autoselecciona el primer device/emulador conectado.
```bash
win_path=$(adb_wsl_to_win /home/lucas/proyecto/app.apk)
# C:\Users\lucas\AppData\Local\... (o la ruta Windows equivalente)
adb_run install "$win_path"
adb_pick_serial "$@" || { echo "no device" >&2; exit 3; }
serial="$ADB_PICK_SERIAL"; set -- "${ADB_PICK_REST[@]}"
```
### `adb_s <serial> <args...>`
Atajo de `adb_run -s <serial> <args...>` para multi-device.
### `adb_wait_boot [timeout_s]`
Espera a que el emulador/dispositivo complete el boot (`sys.boot_completed = 1`). Útil tras lanzar un AVD en CI.
Espera a que el emulador/dispositivo complete el boot (`sys.boot_completed = 1`). Polling cada 3s. Retorna `0` si bootó, `1` si timeout (default 120s).
```bash
adb_wait_boot # timeout 120s
adb_wait_boot 60 # timeout 60s
```
### `adb_wsl_to_win <path_wsl>`
Retorna `0` si el boot se completó, `1` si expiró el timeout.
Legacy WSL: convierte path WSL→Windows con `wslpath -w`. En Linux nativo (sin `wslpath`) devuelve el path tal cual.
## Smoke test
## Gotchas
```bash
bash bash/functions/infra/adb_wsl.sh --self-test
# OK
```
- **Linux-first.** El default ya NO es Windows. Resolucion: `$ADB``$ANDROID_HOME/platform-tools/adb``adb` del PATH → (solo si `/proc/version` indica WSL2) `adb.exe`. En un PC Linux con el SDK instalado funciona sin configurar nada.
- **Necesita el SDK o adb en PATH.** Si no encuentra adb aborta con mensaje a stderr. Instala con `fn run install_android_sdk_bash_infra` y exporta `ANDROID_HOME` (o `source ~/android-sdk/env.sh`).
- **`ADB` se resuelve una sola vez al sourcing.** Cambiar el SDK despues requiere re-sourcear.
- **Sourcéala con bash, no zsh.** Los consumidores usan `${BASH_SOURCE[0]}` para localizar este archivo; ejecutarlos con `bash <file>` (no `zsh`/`source` desde zsh) resuelve el path correctamente.
## Notas
## Capability growth log
- El script es **source-able**: define funciones en el shell actual, no crea subshell.
- `ADB` se resuelve una sola vez al sourcing. Si el binario no existe en disco, la carga falla con mensaje en stderr y `return 1` / `exit 1`.
- `adb_wait_boot` hace polling cada 3 segundos. Ajustar `interval` si el emulador es especialmente lento.
- En WSL2 `wslpath` siempre está disponible; el fallback existe para entornos Linux puros que accidentalmente sourceen el archivo.
- Si el emulador requiere `-s <serial>`, pasar el flag directamente a `adb_run`: `adb_run -s emulator-5554 shell ...`.
---
- v1.1.0 (2026-06-03) — Linux-first: la resolucion de adb ahora prioriza el adb nativo del SDK (`$ANDROID_HOME/platform-tools/adb`) y del PATH; el adb.exe de Windows queda como fallback legacy solo bajo WSL2. Se elimina el default hardcodeado `/mnt/c/Users/lucas/...`. Todo el toolbelt android (~20 funciones) pasa a funcionar en Linux nativo sin preexportar `ADB`.
+25 -10
View File
@@ -1,20 +1,35 @@
#!/usr/bin/env bash
# adb_wsl — Wrapper sourceable para usar adb.exe Windows desde WSL2.
# adb_wsl — Wrapper sourceable para resolver e invocar adb.
# Linux-first: usa el adb nativo del Android SDK o del PATH. Conserva un
# fallback a adb.exe SOLO cuando se detecta WSL2 (legacy). El nombre del
# archivo se mantiene por compatibilidad con sus consumidores del registry.
# Uso: source bash/functions/infra/adb_wsl.sh
# Smoke test: bash bash/functions/infra/adb_wsl.sh --self-test
# ---------------------------------------------------------------------------
# Resolver ADB
# Resolver ADB (Linux-first; fallback WSL legacy)
# ---------------------------------------------------------------------------
# El caller puede fijar ADB antes de sourcing para apuntar a otro binario.
# Prioridad de resolucion:
# 1. $ADB preexportada por el caller (override explicito).
# 2. adb nativo del Android SDK ($ANDROID_HOME / $ANDROID_SDK_ROOT).
# 3. adb del PATH.
# 4. (legacy) adb.exe de Windows, solo si corremos dentro de WSL2.
if [[ -z "${ADB:-}" ]]; then
_sdk_root="${ANDROID_SDK_WIN:-/mnt/c/Users/lucas/AppData/Local/Android/Sdk}"
ADB="${_sdk_root}/platform-tools/adb.exe"
unset _sdk_root
_sdk="${ANDROID_HOME:-${ANDROID_SDK_ROOT:-}}"
if [[ -n "$_sdk" && -x "$_sdk/platform-tools/adb" ]]; then
ADB="$_sdk/platform-tools/adb"
elif command -v adb &>/dev/null; then
ADB="$(command -v adb)"
elif grep -qiE "(microsoft|wsl)" /proc/version 2>/dev/null; then
_sdk_win="${ANDROID_SDK_WIN:-/mnt/c/Users/$USER/AppData/Local/Android/Sdk}"
ADB="${_sdk_win}/platform-tools/adb.exe"
unset _sdk_win
fi
unset _sdk
fi
if [[ ! -f "$ADB" ]]; then
echo "adb_wsl: ADB no encontrado en '$ADB'. Fija ADB= o ANDROID_SDK_WIN= antes de sourcear." >&2
if [[ -z "${ADB:-}" ]] || ! command -v "$ADB" &>/dev/null; then
echo "adb_wsl: adb no encontrado. Instala el SDK (fn run install_android_sdk_bash_infra), exporta ANDROID_HOME, o fija ADB= antes de sourcear." >&2
# Solo abortamos si el script se ejecuta directamente; si se sourcea,
# permitimos continuar para que el caller maneje el error.
return 1 2>/dev/null || exit 1
@@ -22,8 +37,8 @@ fi
# ---------------------------------------------------------------------------
# adb_run "<args...>"
# Ejecuta el ADB Windows con los argumentos dados.
# Retorna el exit code de adb.exe.
# Ejecuta adb (el binario resuelto en $ADB) con los argumentos dados.
# Retorna el exit code de adb.
# ---------------------------------------------------------------------------
adb_run() {
"$ADB" "$@"
+20 -14
View File
@@ -3,11 +3,11 @@ name: android_emulator_list
kind: function
lang: bash
domain: infra
version: "1.0.0"
version: "1.1.0"
purity: impure
signature: "android_emulator_list([--json])"
description: "Lista los AVDs disponibles invocando emulator.exe Windows desde WSL2."
tags: [android, emulator, wsl]
description: "Lista los AVDs disponibles. Linux-first: usa el emulator nativo del Android SDK ($ANDROID_HOME); fallback a emulator.exe solo bajo WSL2."
tags: [android, emulator, linux, avd, wsl]
uses_functions: []
uses_types: []
returns: []
@@ -17,35 +17,41 @@ imports: []
params:
- name: "--json"
desc: "Optional flag, outputs JSON array instead of newline-separated names"
output: "Lista de AVDs disponibles en el SDK Windows. Una por linea, o JSON array con --json."
output: "Lista de AVDs disponibles en el SDK. Una por linea, o JSON array con --json."
tested: false
tests: []
test_file_path: ""
file_path: "bash/functions/infra/android_emulator_list.sh"
notes: "Lee env var EMULATOR o ANDROID_SDK_WIN. Default Windows path: /mnt/c/Users/lucas/AppData/Local/Android/Sdk/emulator/emulator.exe. Exit 0 si lista (incluso vacia). Exit 1 solo si el binario no existe o no es ejecutable."
notes: "Resuelve el binario emulator Linux-first ($ANDROID_HOME/emulator/emulator -> emulator del PATH -> emulator.exe si WSL2). Override con EMULATOR=. Exit 0 si lista (incluso vacia). Exit 1 solo si el binario no existe."
---
## Ejemplo
```bash
source ~/android-sdk/env.sh # exporta ANDROID_HOME
# Listar AVDs (una por linea)
android_emulator_list
# Pixel_API34
# Listar AVDs en formato JSON
android_emulator_list --json
# ["Pixel_7_API_34","Pixel_4_API_30"]
# ["Pixel_API34"]
# Sobreescribir ruta del emulador
EMULATOR="/custom/path/emulator.exe" android_emulator_list
# Sobreescribir SDK base
ANDROID_SDK_WIN="/mnt/d/Android/Sdk" android_emulator_list
EMULATOR="/opt/android/emulator/emulator" android_emulator_list
```
## Notas
## Cuando usarla
El script es ejecutable directamente (`chmod +x`) o invocable con `bash android_emulator_list.sh`.
Antes de arrancar un emulador, para validar que el AVD existe (lo hace `deploy_capacitor_to_emulator` y `run_kotlin_app_tests` internamente). Útil también para listar qué AVDs hay creados en la máquina.
`emulator.exe -list-avds` imprime warnings a stderr que se descartan con `2>/dev/null`. La captura con `mapfile` filtra ademas lineas vacias para producir una lista limpia.
## Gotchas
La variable `EMULATOR` tiene prioridad sobre `ANDROID_SDK_WIN`. Si ninguna esta definida se usa el path Windows por defecto de Lucas.
- **Linux-first.** El default ya no es Windows. Resuelve `$ANDROID_HOME/emulator/emulator`, luego `emulator` del PATH, y solo bajo WSL2 cae a `emulator.exe`.
- `emulator -list-avds` imprime warnings a stderr que se descartan con `2>/dev/null`. La captura con `mapfile` filtra líneas vacías.
- Override del binario con `EMULATOR=`; override del SDK con `ANDROID_HOME=`.
## Capability growth log
- v1.1.0 (2026-06-03) — Linux-first: resuelve el emulator nativo del SDK (`$ANDROID_HOME`) y del PATH antes que `emulator.exe`; se elimina el default hardcodeado `/mnt/c/Users/lucas/...`.
+16 -5
View File
@@ -1,12 +1,23 @@
#!/usr/bin/env bash
# android_emulator_list — Lista los AVDs disponibles invocando emulator.exe Windows desde WSL2.
# android_emulator_list — Lista los AVDs disponibles. Linux-first: usa el
# emulator nativo del Android SDK; fallback a emulator.exe solo bajo WSL2.
set -euo pipefail
# Resolve emulator binary
EMULATOR="${EMULATOR:-${ANDROID_SDK_WIN:-/mnt/c/Users/lucas/AppData/Local/Android/Sdk}/emulator/emulator.exe}"
# Resolve emulator binary (Linux-first; WSL fallback)
if [[ -z "${EMULATOR:-}" ]]; then
_sdk="${ANDROID_HOME:-${ANDROID_SDK_ROOT:-}}"
if [[ -n "$_sdk" && -x "$_sdk/emulator/emulator" ]]; then
EMULATOR="$_sdk/emulator/emulator"
elif command -v emulator &>/dev/null; then
EMULATOR="$(command -v emulator)"
elif grep -qiE "(microsoft|wsl)" /proc/version 2>/dev/null; then
EMULATOR="${ANDROID_SDK_WIN:-/mnt/c/Users/$USER/AppData/Local/Android/Sdk}/emulator/emulator.exe"
fi
unset _sdk
fi
if [[ ! -x "$EMULATOR" ]]; then
echo "error: emulator binary not found or not executable: $EMULATOR" >&2
if [[ -z "${EMULATOR:-}" ]] || ! command -v "$EMULATOR" &>/dev/null; then
echo "error: emulator no encontrado. Instala el SDK (fn run install_android_sdk_bash_infra) + el paquete 'emulator', exporta ANDROID_HOME, o fija EMULATOR=." >&2
exit 1
fi
+23 -13
View File
@@ -3,14 +3,14 @@ name: android_emulator_start
kind: function
lang: bash
domain: infra
version: "1.0.0"
version: "1.1.0"
purity: impure
signature: "android_emulator_start(avd_name: string, timeout_s: int) -> string"
description: "Arranca un AVD en background y espera a que termine de bootear. Idempotente: si ya hay emulador corriendo no lanza otro."
tags: [android, emulator, wsl]
description: "Arranca un AVD Android en background y espera a que termine de bootear. Linux-first: resuelve el emulator/adb nativos del SDK; fallback a binarios .exe solo bajo WSL2. Idempotente: si ya hay un emulador corriendo, imprime 'already running' y su serial sin lanzar otro."
tags: [android, emulator, linux, avd, wsl]
params:
- name: avd_name
desc: "Nombre del AVD a arrancar (visible con android_emulator_list o `emulator.exe -list-avds`)"
desc: "Nombre del AVD a arrancar (visible con android_emulator_list o `emulator -list-avds`)"
- name: timeout_s
desc: "Timeout total en segundos para esperar el boot completo. Opcional, default 180"
output: "Serial del device emulado (ej. emulator-5554) en stdout. Exit 0 = boot completo, exit 1 = timeout o emulador murio."
@@ -29,21 +29,31 @@ file_path: "bash/functions/infra/android_emulator_start.sh"
## Ejemplo
```bash
source ~/android-sdk/env.sh # exporta ANDROID_HOME -> resuelve emulator/adb nativos
source bash/functions/infra/android_emulator_start.sh
# Arrancar AVD con timeout por defecto (180s)
serial=$(android_emulator_start "Pixel_6_API_34")
serial=$(android_emulator_start "Pixel_API34")
echo "Emulador listo: $serial" # emulator-5554
# Con timeout personalizado
serial=$(android_emulator_start "Pixel_6_API_34" 300)
serial=$(android_emulator_start "Pixel_API34" 300)
```
## Notas
Para ver la ventana del emulador en un escritorio Linux, exporta `DISPLAY` (y `XAUTHORITY`) antes de invocar.
- Sourcea `adb_wsl.sh` del mismo directorio si existe (provee `ADB`, `adb_run`, `adb_wait_boot`). Si no, usa implementacion inline.
- Resuelve `EMULATOR` y `ADB` desde `ANDROID_SDK_WIN` (default `/mnt/c/Users/lucas/AppData/Local/Android/Sdk`) o desde las variables de entorno `EMULATOR=` / `ADB=` si ya están fijadas.
- Idempotente: si `adb devices` ya muestra un `emulator-*`, imprime "already running" + el serial y sale con exit 0 sin lanzar un segundo proceso.
- Log del emulador en `/tmp/emulator_<avd>.log`. PID en `/tmp/emulator_<avd>.pid`.
- El timeout total se reparte: primera mitad para `adb wait-for-device`, segunda mitad para esperar `sys.boot_completed=1`.
- Diseñado para WSL2 con Android SDK instalado en Windows. En Linux nativo basta cambiar las rutas de los binarios via `EMULATOR=` y `ADB=`.
## Cuando usarla
Cuando un script necesita un emulador booteado antes de instalar un APK o correr tests instrumentados (`gradle_instrumented_test`, `run_kotlin_app_tests`). Es idempotente, así que se puede llamar al principio de cualquier pipeline sin comprobar antes si ya hay uno arriba.
## Gotchas
- **Linux-first.** Resuelve `EMULATOR`/`ADB` desde `$ANDROID_HOME/{emulator/emulator, platform-tools/adb}` o del PATH; `emulator.exe`/`adb.exe` solo como fallback bajo WSL2. Override manual con `EMULATOR=`/`ADB=`.
- **Necesita `DISPLAY` para ventana.** Sin un servidor X accesible el emulador puede fallar al abrir ventana. Para headless/CI añade `-no-window` (editar la función o lanzar el emulador aparte).
- **Aceleración KVM.** Requiere acceso a `/dev/kvm` (grupo `kvm` o ACL). Sin ella el boot es lentísimo o falla.
- Log del emulador en `/tmp/emulator_<avd>.log`, PID en `/tmp/emulator_<avd>.pid`.
- El timeout total se reparte: primera mitad para `adb wait-for-device`, segunda para `sys.boot_completed=1`.
## Capability growth log
- v1.1.0 (2026-06-03) — Linux-first: resuelve emulator/adb nativos del SDK (`$ANDROID_HOME`) antes que los `.exe` de Windows (ahora solo fallback WSL2); se elimina el default hardcodeado `/mnt/c/Users/lucas/...`. fix: `timeout <n> adb_run wait-for-device` fallaba siempre porque `timeout` no puede ejecutar la función shell `adb_run`; ahora invoca el binario `"$ADB"` directamente.
+29 -14
View File
@@ -11,11 +11,17 @@ if [[ -f "$_ADB_WSL_SH" ]]; then
# shellcheck source=adb_wsl.sh
source "$_ADB_WSL_SH"
else
# Fallback inline: resolver ADB
# Fallback inline: resolver ADB (Linux-first; WSL fallback)
if [[ -z "${ADB:-}" ]]; then
_sdk_root="${ANDROID_SDK_WIN:-/mnt/c/Users/lucas/AppData/Local/Android/Sdk}"
ADB="${_sdk_root}/platform-tools/adb.exe"
unset _sdk_root
_sdk="${ANDROID_HOME:-${ANDROID_SDK_ROOT:-}}"
if [[ -n "$_sdk" && -x "$_sdk/platform-tools/adb" ]]; then
ADB="$_sdk/platform-tools/adb"
elif command -v adb &>/dev/null; then
ADB="$(command -v adb)"
else
ADB="${ANDROID_SDK_WIN:-/mnt/c/Users/$USER/AppData/Local/Android/Sdk}/platform-tools/adb.exe"
fi
unset _sdk
fi
adb_run() { "$ADB" "$@"; }
adb_wait_boot() {
@@ -33,12 +39,18 @@ else
fi
# ---------------------------------------------------------------------------
# Resolver EMULATOR
# Resolver EMULATOR (Linux-first; WSL fallback)
# ---------------------------------------------------------------------------
if [[ -z "${EMULATOR:-}" ]]; then
_sdk_root="${ANDROID_SDK_WIN:-/mnt/c/Users/lucas/AppData/Local/Android/Sdk}"
EMULATOR="${_sdk_root}/emulator/emulator.exe"
unset _sdk_root
_sdk="${ANDROID_HOME:-${ANDROID_SDK_ROOT:-}}"
if [[ -n "$_sdk" && -x "$_sdk/emulator/emulator" ]]; then
EMULATOR="$_sdk/emulator/emulator"
elif command -v emulator &>/dev/null; then
EMULATOR="$(command -v emulator)"
elif grep -qiE "(microsoft|wsl)" /proc/version 2>/dev/null; then
EMULATOR="${ANDROID_SDK_WIN:-/mnt/c/Users/$USER/AppData/Local/Android/Sdk}/emulator/emulator.exe"
fi
unset _sdk
fi
# ---------------------------------------------------------------------------
@@ -49,12 +61,12 @@ android_emulator_start() {
local timeout_s="${2:-180}"
# Validaciones de entorno
if [[ ! -f "$EMULATOR" ]]; then
echo "android_emulator_start: emulator.exe no encontrado en '$EMULATOR'. Fija EMULATOR= o ANDROID_SDK_WIN=." >&2
if [[ -z "${EMULATOR:-}" ]] || ! command -v "$EMULATOR" &>/dev/null; then
echo "android_emulator_start: emulator no encontrado. Instala el SDK + paquete 'emulator', exporta ANDROID_HOME, o fija EMULATOR=." >&2
return 1
fi
if [[ ! -f "$ADB" ]]; then
echo "android_emulator_start: adb.exe no encontrado en '$ADB'. Fija ADB= o ANDROID_SDK_WIN=." >&2
if [[ -z "${ADB:-}" ]] || ! command -v "$ADB" &>/dev/null; then
echo "android_emulator_start: adb no encontrado. Instala platform-tools, exporta ANDROID_HOME, o fija ADB=." >&2
return 1
fi
@@ -74,9 +86,12 @@ android_emulator_start() {
local emu_pid=$!
echo "$emu_pid" > "$pid_file"
# Esperar a que el dispositivo aparezca en adb
# Esperar a que el dispositivo aparezca en adb.
# Usamos el binario "$ADB" directamente (no la funcion adb_run): `timeout`
# ejecuta un comando externo y no puede ver funciones del shell, asi que
# `timeout ... adb_run` fallaba siempre con "command not found".
local wait_timeout=$(( timeout_s / 2 ))
if ! timeout "$wait_timeout" adb_run wait-for-device 2>/dev/null; then
if ! timeout "$wait_timeout" "$ADB" wait-for-device 2>/dev/null; then
echo "android_emulator_start: timeout esperando que el dispositivo aparezca en adb (${wait_timeout}s)." >&2
return 1
fi
+16 -1
View File
@@ -3,7 +3,7 @@ name: install_android_sdk
kind: function
lang: bash
domain: infra
version: "1.0.0"
version: "1.0.1"
purity: impure
signature: "install_android_sdk() -> void"
description: "Descarga e instala Android SDK command-line tools y JDK 17 localmente (sin root/sudo) en $ANDROID_SDK_DIR (default: $HOME/android-sdk). Idempotente: detecta instalacion existente y sale sin hacer nada. Genera env.sh con JAVA_HOME, ANDROID_HOME y PATH listos para hacer source."
@@ -50,6 +50,17 @@ ANDROID_SDK_DIR=/opt/android source install_android_sdk.sh
source ~/android-sdk/env.sh
```
## Cuando usarla
Cuando necesites un Android SDK funcional en una maquina Linux sin permisos de root: CI, contenedores, o un PC de desarrollo donde quieras un SDK aislado en `$HOME`. Instala la base minima para compilar (cmdline-tools + JDK 17 + platform-tools + API 34 + build-tools). Hazle `source` para tener `sdkmanager`/`avdmanager`/`adb` en el PATH antes de invocar `gradle_run`, `gradle_assemble_debug` o `capacitor_build_apk`.
## Gotchas
- **No instala `emulator` ni system images.** Solo la base de compilacion. Para correr un AVD: tras hacer `source env.sh`, instala `emulator` y una imagen (`sdkmanager "emulator" "system-images;android-34;google_apis;x86_64"`) y crea el AVD con `avdmanager create avd`.
- **Aceleracion KVM:** el emulador necesita acceso a `/dev/kvm`. Verifica con `[ -w /dev/kvm ]`; si no, anade tu usuario al grupo `kvm` (`sudo usermod -aG kvm $USER` + re-login) o concede ACL.
- **URL de cmdline-tools clavada** a la build 11076708 (2024). Si Google la retira, actualizar `tools_url` en el `.sh`.
- **Idempotente:** re-ejecutar no reinstala; detecta `sdkmanager` existente y sale en 0.
## Notas
Requiere `curl` y `unzip` (disponibles en la mayoria de distros Linux). No requiere root ni sudo.
@@ -61,3 +72,7 @@ La reorganizacion del zip es necesaria porque Google distribuye cmdline-tools co
El archivo `env.sh` generado en `$ANDROID_SDK_DIR/env.sh` contiene las variables de entorno necesarias (`JAVA_HOME`, `ANDROID_HOME`, `ANDROID_SDK_ROOT`, `PATH`) y puede hacerse source desde `.bashrc`, `.zshrc` o desde scripts de CI.
Paquetes instalados: `platform-tools` (adb, fastboot), `platforms;android-34` (API 34), `build-tools;34.0.0`.
## Capability growth log
- v1.0.1 (2026-06-03) — fix: `yes | sdkmanager --licenses` daba falso negativo bajo `pipefail` (SIGPIPE de `yes`, exit 141) abortando una instalacion exitosa; ahora se desactiva `pipefail` solo en ese pipe. fix: el trap `EXIT` referenciaba `$tmp_dir` (variable `local`) fuera del scope de la funcion → "unbound variable" con `set -u`; ahora es global con expansion defensiva.
+13 -3
View File
@@ -5,11 +5,14 @@ set -euo pipefail
install_android_sdk() {
local sdk_dir="${ANDROID_SDK_DIR:-$HOME/android-sdk}"
local tmp_dir
# tmp_dir es global a proposito: el trap EXIT se dispara al terminar el
# script (fuera del scope de la funcion), donde una variable `local` ya no
# existiria y `set -u` la marcaria como unbound. La expansion defensiva
# ${tmp_dir:-} evita el fallo aunque el trap corra antes de la asignacion.
tmp_dir="$(mktemp -d)"
# Limpia temporales al salir
trap 'rm -rf "$tmp_dir"' EXIT
trap 'rm -rf "${tmp_dir:-}"' EXIT
# 1. Verifica si ya está instalado
if [[ -f "$sdk_dir/cmdline-tools/latest/bin/sdkmanager" ]]; then
@@ -103,11 +106,18 @@ install_android_sdk() {
export PATH="$JAVA_HOME/bin:$sdk_dir/cmdline-tools/latest/bin:$sdk_dir/platform-tools:$PATH"
# 4. Acepta licencias e instala paquetes necesarios
# `yes` recibe SIGPIPE (exit 141) cuando sdkmanager termina de leer y cierra
# el pipe; bajo `set -o pipefail` eso convierte un exito real en falso
# negativo. Desactivamos pipefail solo aqui para que el exit del pipeline
# refleje el de sdkmanager (ultimo comando), no el SIGPIPE de `yes`.
echo "Aceptando licencias de Android SDK..."
if ! yes | "$sdkmanager" --licenses; then
set +o pipefail
if ! yes | "$sdkmanager" --licenses >/dev/null 2>&1; then
set -o pipefail
echo "ERROR: fallo al aceptar licencias de Android SDK" >&2
return 1
fi
set -o pipefail
echo "Instalando platform-tools, platforms;android-34, build-tools;34.0.0..."
if ! "$sdkmanager" "platform-tools" "platforms;android-34" "build-tools;34.0.0"; then
@@ -3,12 +3,12 @@ name: write_mcp_jupyter_config
kind: function
lang: bash
domain: infra
version: "1.1.0"
version: "1.2.0"
purity: impure
signature: "write_mcp_jupyter_config([project_dir: string], [port: int]) -> string"
description: "Genera o actualiza .mcp.json con la config de jupyter-mcp-server apuntando al console-script del venv local (transport stdio + flags --jupyter-url/--jupyter-token). Merge con jq reemplazando la entrada jupyter entera."
description: "Genera o actualiza .mcp.json para un analisis Jupyter. La entrada jupyter usa el wrapper jupyter_mcp_serve.sh con env overrides (venv, root y puerto del analisis), de modo que el MCP arranca su propio Jupyter con el venv del analisis. Merge con jq reemplazando la entrada jupyter entera."
tags: [mcp, jupyter, config, setup, infra, notebook]
uses_functions: []
uses_functions: [jupyter_mcp_serve_bash_infra]
uses_types: []
returns: []
returns_optional: false
@@ -16,9 +16,9 @@ error_type: "error_go_core"
imports: []
params:
- name: project_dir
desc: "directorio del proyecto Jupyter (default: directorio actual)"
desc: "directorio del proyecto/analisis Jupyter (default: directorio actual)"
- name: port
desc: "puerto Jupyter (default: detectado automáticamente)"
desc: "puerto Jupyter del analisis (default: 8888)"
output: "ruta del archivo .mcp.json generado o actualizado"
tested: false
tests: []
@@ -33,25 +33,33 @@ source write_mcp_jupyter_config.sh
path=$(write_mcp_jupyter_config $HOME/fn_registry/analysis/finanzas 8890)
echo "Config MCP en: $path"
# Genera .mcp.json con:
# "command": ".../.venv/bin/jupyter-mcp-server"
# "args": ["--transport","stdio","--jupyter-url","http://localhost:8890","--jupyter-token",""]
# "command": "bash"
# "args": [".../bash/functions/infra/jupyter_mcp_serve.sh"]
# "env": {
# "JUPYTER_MCP_VENV": ".../analysis/finanzas/.venv",
# "JUPYTER_MCP_ROOT": ".../analysis/finanzas",
# "JUPYTER_MCP_PORT": "8890",
# "JUPYTER_MCP_TOKEN": ""
# }
```
## Cuando usarla
- Al crear un analysis Jupyter nuevo (la usa el pipeline `init_jupyter_analysis`).
- Tras mover/recrear un venv y necesitar regenerar el `.mcp.json` del analysis.
- Para reparar un `.mcp.json` con el comando viejo roto (`python -m jupyter_mcp_server.server`).
- Para reparar un `.mcp.json` con el comando viejo (console-script directo que no arranca Jupyter, o `python -m jupyter_mcp_server.server`).
## Gotchas
- **NUNCA `python -m jupyter_mcp_server.server`** — `server.py` no tiene bloque `__main__`; el proceso importa y sale 0 y el MCP nunca arranca. El entrypoint real es la CLI (`jupyter_mcp_server.CLI:server`), expuesta como console-script `jupyter-mcp-server`. Sin subcomando arranca en stdio por defecto.
- **No usa env vars** `SERVER_URL`/`TOKEN`. La CLI lee flags `--jupyter-url` / `--jupyter-token` (cubren document + runtime). Configs viejas con bloque `env` quedan inertes.
- **Tolera Jupyter apagado al boot**: el MCP responde `initialize` tras un connect-timeout (~10s) y sirve igual. Arrancar Jupyter despues en `:port` y los tools se enganchan. No hace falta reiniciar Claude por tener Jupyter caido al inicio.
- **Requiere `jupyter-mcp-server` instalado en el venv**: `uv pip install jupyter-mcp-server`. La funcion aborta si el console-script no existe.
- **Path atado al venv del analysis**: si borras el analysis, ese `.mcp.json` apunta a un binario inexistente. Para un MCP jupyter global e independiente, el `.mcp.json` raiz de `fn_registry` usa el binario del venv canonico `python/.venv/bin/jupyter-mcp-server` (sobrevive el borrado de cualquier analysis).
- **Merge con jq usa `+` (shallow)** en el mapa de servidores para reemplazar la entrada `jupyter` entera; `*` (deep) dejaba keys huerfanas de configs viejas.
- **Usa el wrapper, no el console-script directo**: el `.mcp.json` apunta a `jupyter_mcp_serve.sh` (ver `jupyter_mcp_serve_bash_infra`), que arranca (o reusa) el Jupyter del analisis con su venv antes de exec del MCP. Con el console-script directo (`jupyter-mcp-server --jupyter-url ...`) el MCP solo se CONECTA: si el server no esta levantado no hay kernel y las operaciones sobre notebooks fallan. Con el wrapper basta abrir Claude desde el analisis — no hace falta lanzar `run-jupyter-lab.sh` aparte.
- **El venv del kernel es el del analisis** (`JUPYTER_MCP_VENV`), no `python/.venv` del repo. Asi cada analisis ejecuta con sus propias dependencias sin contaminar el venv canonico. Este fix nacio de un caso real (analisis `nats`): trabajar desde la raiz de `fn_registry` cargaba el MCP global (8899, venv `python/.venv`) que no tenia `nats-py`.
- **Reuso por puerto**: si ya hay un Jupyter escuchando en `JUPYTER_MCP_PORT` (p.ej. lanzado por `run-jupyter-lab.sh`, que es colaborativo), el wrapper lo reusa en vez de arrancar otro. Si no hay ninguno, el wrapper levanta uno propio (sin `--collaborative`, suficiente para el MCP). Para colaboracion humana en tiempo real, lanzar `run-jupyter-lab.sh` antes.
- **NUNCA `python -m jupyter_mcp_server.server`** — `server.py` no tiene bloque `__main__`; importa y sale 0, el MCP nunca arranca. El entrypoint real es el console-script `jupyter-mcp-server`, que el wrapper localiza dentro del venv del analisis.
- **Requiere `jupyter-mcp-server` instalado en el venv del analisis**: `uv pip install jupyter-mcp-server`. La funcion aborta si el console-script no existe.
- **Localiza el wrapper subiendo directorios** desde `project_dir` (hasta 8 niveles) buscando `bash/functions/infra/jupyter_mcp_serve.sh`; si no lo encuentra, usa `FN_REGISTRY_ROOT`. Aborta si no aparece por ninguna via.
- **Merge con jq usa `+` (shallow)** en el mapa de servidores para reemplazar la entrada `jupyter` entera; `*` (deep) dejaba keys huerfanas de configs viejas (p.ej. el bloque `args` del console-script directo).
## Capability growth log
- v1.2.0 (2026-06-03) — el `.mcp.json` generado usa el wrapper `jupyter_mcp_serve.sh` con env overrides (`JUPYTER_MCP_VENV/ROOT/PORT/TOKEN`) en vez del console-script directo. Garantiza que el MCP arranca su propio Jupyter con el venv del analisis (antes solo conectaba y usaba el venv equivocado si se abria Claude desde la raiz del repo). Declara dependencia `jupyter_mcp_serve_bash_infra`.
- v1.1.0 (2026-05-28) — fix comando roto: console-script `jupyter-mcp-server` + flags stdio en vez de `python -m ...server` + env vars. Merge `+` para reemplazar entrada entera. Tag `notebook`.
@@ -1,21 +1,32 @@
# write_mcp_jupyter_config
# -------------------------
# Genera o actualiza .mcp.json con la configuracion de jupyter-mcp-server.
# Usa el console-script `jupyter-mcp-server` del venv local con transport stdio
# y los flags --jupyter-url / --jupyter-token (NO env vars, NO `-m ...server`).
# Hace merge si ya existe .mcp.json (requiere jq).
# Genera o actualiza .mcp.json con la configuracion de jupyter-mcp-server para un
# analisis/proyecto. La entrada `jupyter` usa el wrapper `jupyter_mcp_serve.sh`
# (no el console-script directo), de modo que el MCP SIEMPRE tiene servidor: el
# wrapper arranca (o reusa) un Jupyter Lab en el puerto indicado usando el venv
# del propio analisis y lo engancha al MCP por stdio.
#
# Por que el wrapper y no el console-script directo: el console-script
# `jupyter-mcp-server --jupyter-url http://localhost:PORT` solo se CONECTA, no
# arranca Jupyter. Si el server no esta levantado, el MCP responde `initialize`
# pero no hay kernel y toda operacion sobre notebooks falla. El wrapper levanta el
# server con el venv correcto (JUPYTER_MCP_VENV) antes de exec del MCP, asi que
# abrir Claude desde el analisis basta — no hace falta lanzar run-jupyter-lab.sh
# aparte. Si ya hay un Jupyter en ese puerto (p.ej. run-jupyter-lab.sh), lo reusa.
#
# Env overrides que se inyectan al wrapper (ver jupyter_mcp_serve.sh):
# JUPYTER_MCP_VENV venv del analisis (su .venv, con jupyter + jupyter-mcp-server)
# JUPYTER_MCP_ROOT root de notebooks = directorio del analisis
# JUPYTER_MCP_PORT puerto del Jupyter gestionado
# JUPYTER_MCP_TOKEN token (vacio: solo escucha en 127.0.0.1)
#
# GOTCHA (2026-05-28): `python -m jupyter_mcp_server.server` NO arranca nada —
# server.py no tiene bloque __main__, asi que el proceso importa y sale 0 y el
# MCP nunca levanta. El entrypoint real es la CLI (`jupyter_mcp_server.CLI:server`,
# expuesta como console-script `jupyter-mcp-server`), que sin subcomando arranca
# en stdio por defecto. La config tampoco lee SERVER_URL/TOKEN: usa los flags
# --jupyter-url / --jupyter-token. El MCP tolera que Jupyter este apagado al
# arrancar (responde `initialize` tras un connect-timeout ~10s y sirve igual).
# server.py no tiene bloque __main__. El entrypoint real es el console-script
# `jupyter-mcp-server` (que el wrapper localiza dentro del venv del analisis).
#
# USO (sourced):
# source write_mcp_jupyter_config.sh
# write_mcp_jupyter_config /path/to/project 8888
# write_mcp_jupyter_config /path/to/analysis 8890
write_mcp_jupyter_config() {
local project_dir="${1:-.}"
@@ -31,23 +42,47 @@ write_mcp_jupyter_config() {
return 1
fi
# Verificar que el console-script esta instalado
# Verificar que el console-script esta instalado en el venv del analisis
if [ ! -x "$mcp_bin" ]; then
echo "write_mcp_jupyter_config: jupyter-mcp-server no instalado en el venv (${mcp_bin}). Instala con: uv pip install jupyter-mcp-server" >&2
return 1
fi
# Localizar el wrapper jupyter_mcp_serve.sh subiendo desde el directorio del
# analisis hasta la raiz del repo. Fallback a FN_REGISTRY_ROOT.
local wrapper="" d="$abs_project"
local i
for i in 1 2 3 4 5 6 7 8; do
if [ -f "$d/bash/functions/infra/jupyter_mcp_serve.sh" ]; then
wrapper="$d/bash/functions/infra/jupyter_mcp_serve.sh"
break
fi
d="$(dirname "$d")"
[ "$d" = "/" ] && break
done
if [ -z "$wrapper" ] && [ -n "${FN_REGISTRY_ROOT:-}" ] && [ -f "${FN_REGISTRY_ROOT}/bash/functions/infra/jupyter_mcp_serve.sh" ]; then
wrapper="${FN_REGISTRY_ROOT}/bash/functions/infra/jupyter_mcp_serve.sh"
fi
if [ -z "$wrapper" ]; then
echo "write_mcp_jupyter_config: no encuentro bash/functions/infra/jupyter_mcp_serve.sh subiendo desde ${abs_project} ni en FN_REGISTRY_ROOT" >&2
return 1
fi
local new_config
new_config=$(cat << EOF
{
"mcpServers": {
"jupyter": {
"command": "${mcp_bin}",
"command": "bash",
"args": [
"--transport", "stdio",
"--jupyter-url", "http://localhost:${port}",
"--jupyter-token", ""
]
"${wrapper}"
],
"env": {
"JUPYTER_MCP_VENV": "${abs_project}/.venv",
"JUPYTER_MCP_ROOT": "${abs_project}",
"JUPYTER_MCP_PORT": "${port}",
"JUPYTER_MCP_TOKEN": ""
}
}
}
}
@@ -57,7 +92,7 @@ EOF
if [ -f "$mcp_file" ] && command -v jq &>/dev/null; then
# Merge conservando otros servidores MCP. Usa `+` (shallow) en el mapa de
# servidores para REEMPLAZAR la entrada `jupyter` entera — `*` (deep) dejaba
# keys huerfanas de configs viejas (ej. bloque `env` obsoleto).
# keys huerfanas de configs viejas (ej. flags `args` obsoletos).
jq -s '.[0] * {mcpServers: ((.[0].mcpServers // {}) + (.[1].mcpServers // {}))}' \
"$mcp_file" <(echo "$new_config") > "${mcp_file}.tmp"
mv "${mcp_file}.tmp" "$mcp_file"
+2 -1
View File
@@ -23,7 +23,7 @@ Indice de grupos de capacidades del registry. Cada grupo agrupa >=3 funciones qu
| [nlp](nlp.md) | 33 | Extraccion NLP: PDFs, OCR, chunking, GLiNER/GLiREL, dedup, agregacion de entities/relations |
| [docker](docker.md) | 38 | Operar Docker desde Go/Bash: build/run/stop, compose, networks, volumes, logs, deploys |
| [android](android.md) | 37 | Toolbelt Android desde WSL2: adb, emuladores AVD, APK build/install, Capacitor, logcat |
| [web-proxy](web-proxy.md) | 4 | Captura de trafico HTTP/HTTPS liviana (mitmproxy): proxy con rotacion, navegador proxeado, consulta de capturas. Alternativa ligera a ZAP/Burp |
| [web-proxy](web-proxy.md) | 5 | Captura de trafico HTTP/HTTPS liviana (mitmproxy): proxy con rotacion, navegador proxeado, consulta de capturas, tee del SSE de claude. Alternativa ligera a ZAP/Burp |
| [metabase](metabase.md) | 106 | Operar Metabase via API REST: auth, cards, dashboards, collections, snippets, permissions |
| [doctor](doctor.md) | 11 | Diagnostico read-only del registry: artefactos, servicios, drift, funciones huerfanas |
| [notebook](notebook.md) | 5 | Operar Jupyter Lab colaborativo (discover/read/exec/write/kernel) |
@@ -45,6 +45,7 @@ Indice de grupos de capacidades del registry. Cada grupo agrupa >=3 funciones qu
| [wireguard](wireguard.md) | 7 | Instalar, configurar, operar y monitorizar mesh WireGuard hub-and-spoke: keygen, hub setup, peer add/revoke, status JSON |
| [matrix-mas](matrix-mas.md) | 5 | Migración Synapse→MAS: habilitar MSC3861, verificar login flows, parche .well-known, registro clientes OAuth2, syn2mas |
| [mesh-3d](mesh-3d.md) | 3 | Carga y upload a GPU de meshes 3D (OBJ, GLB/glTF 2.0): loaders CPU + mesh_gpu_upload OpenGL |
| [terminal-capture](terminal-capture.md) | 6 | Automatizar y capturar el texto de una CLI/TUI interactiva via PTY headless: spawn+input scripteado (one-shot y streaming), render del layout 2D (emulador VT), strip ANSI, delta por prefijo, y parseo de la TUI de claude a datos |
## Como anadir grupo
+8 -4
View File
@@ -1,12 +1,14 @@
# Capability: android
Toolbelt Android operable desde WSL2. Cubre: ADB (`adb_wsl`, conexion al daemon Windows), AVD emulator management (list/start/stop/wait, geo-fix), APK lifecycle (`android_apk_install`, `android_app_clear`, `android_app_launch`, `android_uninstall`), Capacitor build pipelines (`capacitor_build_apk`, `deploy_capacitor_to_emulator`), logcat streaming. WSL2 -> Windows adb daemon, no requiere Android Studio.
Toolbelt Android **Linux-first** (con fallback WSL2 legacy). Cubre: ADB (`adb_wsl` resuelve el adb nativo del SDK), AVD emulator management (list/start/stop/wait, geo-fix), APK lifecycle (`android_apk_install`, `android_app_clear`, `android_app_launch`, `android_uninstall`), Capacitor build pipelines (`capacitor_build_apk`, `deploy_capacitor_to_emulator`), build Gradle nativo (`gradle_*`, `init_kotlin_app`, `run_kotlin_app_tests`), logcat streaming. Usa el SDK nativo en `~/android-sdk` (via `install_android_sdk`); el adb/emulator de Windows solo se usa como fallback cuando se detecta WSL2.
Design system Compose: las apps Kotlin nativas (`init_kotlin_app`) heredan `FnTheme` + `FnTokens` del módulo `kotlin/functions/ui` (`fn.compose:ui`), con la paleta exacta de Mantine v9 dark + indigo (misma que web `@fn_library` y C++ `fn_tokens`).
## Funciones
| ID | Firma | Que hace |
|---|---|---|
| `adb_wsl_bash_infra` | `source adb_wsl.sh [ADB=<path>] [ANDROID_SDK_WIN=<sdk_root>]` | Wrapper sourceable para usar adb.exe Windows desde WSL2. Resuelve binario, convierte paths, espera boot del emulador. |
| `adb_wsl_bash_infra` | `source adb_wsl.sh [ADB=<path>] [ANDROID_HOME=<sdk_root>]` | Wrapper sourceable para resolver e invocar adb. Linux-first: usa el adb nativo del Android SDK ($ANDROID_HOME) o del PATH; fallback a adb.exe solo si detecta WSL2. Expone adb_run, adb_devices, adb_pick_serial, adb_s, adb_wait_boot. |
| `android_apk_install_bash_infra` | `android_apk_install([--serial S], apk_path: string, package_name?: string, activity_name?: string) -> void` | Instala APK en device/emulador via adb y opcionalmente lanza la app. Multi-emulator via --serial. |
| `android_app_clear_bash_infra` | `android_app_clear([--serial <S>], package: string) -> void` | Wipe app data + cache via pm clear. App keeps installed but factory-state. Multi-emulator via --serial. |
| `android_app_info_bash_infra` | `android_app_info([--serial <S>], package, [--json]) -> stdout` | Inspect installed app: version, target SDK, activities via dumpsys package. |
@@ -16,8 +18,8 @@ Toolbelt Android operable desde WSL2. Cubre: ADB (`adb_wsl`, conexion al daemon
| `android_emu_battery_bash_infra` | `android_emu_battery([--serial <S>], level: int, [--charging <true\|false>]) -> void` | Simulate battery state on emulator (level + charging). Emulator-only. |
| `android_emu_geo_fix_bash_infra` | `android_emu_geo_fix([--serial <S>], longitude: string, latitude: string, [altitude: string]) -> void` | Fake GPS location on Android emulator via emu geo fix. Emulator-only (not physical devices). |
| `android_emu_rotate_bash_infra` | `android_emu_rotate([--serial <S>] [portrait\|landscape\|0\|90\|180\|270])` | Rotate emulator screen. Empty=toggle, or fixed orientation. Locks autorotate. |
| `android_emulator_list_bash_infra` | `android_emulator_list([--json])` | Lista los AVDs disponibles invocando emulator.exe Windows desde WSL2. |
| `android_emulator_start_bash_infra` | `android_emulator_start(avd_name: string, timeout_s: int) -> string` | Arranca un AVD en background y espera a que termine de bootear. Idempotente: si ya hay emulador corriendo no lanza otro. |
| `android_emulator_list_bash_infra` | `android_emulator_list([--json])` | Lista los AVDs disponibles. Linux-first: usa el emulator nativo del Android SDK ($ANDROID_HOME); fallback a emulator.exe solo bajo WSL2. |
| `android_emulator_start_bash_infra` | `android_emulator_start(avd_name: string, timeout_s: int) -> string` | Arranca un AVD Android en background y espera a que termine de bootear. Linux-first: resuelve el emulator/adb nativos del SDK; fallback a binarios .exe solo bajo WSL2. Idempotente: si ya hay un emulador corriendo, imprime 'already running' y su serial sin lanzar otro. |
| `android_emulator_stop_bash_infra` | `android_emulator_stop(serial?: string) -> void` | Para uno o todos los emuladores Android via adb emu kill. Si serial esta vacio, detecta todos los emulator-* activos y los para. Idempotente: exit 0 aunque no haya nada que matar. |
| `android_input_keyevent_bash_infra` | `android_input_keyevent([--serial <S>] key: string)` | Send key event via adb shell input keyevent. Accepts aliases (BACK, HOME, POWER, ENTER, MENU, RECENT_APPS, VOLUME_UP, VOLUME_DOWN), raw numeric codes, or explicit KEYCODE_* names. |
| `android_input_swipe_bash_infra` | `android_input_swipe([--serial <S>], x1: int, y1: int, x2: int, y2: int, [duration_ms: int])` | Send swipe gesture between two points with duration. |
@@ -31,6 +33,8 @@ Toolbelt Android operable desde WSL2. Cubre: ADB (`adb_wsl`, conexion al daemon
| `android_shell_bash_infra` | `android_shell([--serial <S>], cmd ...args)` | Execute arbitrary shell command on Android device. Multi-emulator via --serial. |
| `capacitor_build_apk_bash_pipelines` | `capacitor_build_apk(web_app_dir: string, [app_id: string], [app_name: string]) -> void` | Pipeline que convierte una web app en un APK de Android usando Capacitor. Valida el entorno (ANDROID_HOME, Java 17+), construye el bundle web si no existe dist/, inicializa Capacitor si no está configurado, añade la plataforma Android, sincroniza y compila el APK con Gradle. El APK final queda en el directorio raíz de la web app. |
| `deploy_capacitor_to_emulator_bash_pipelines` | `deploy_capacitor_to_emulator(app_dir: string, avd_name?: string, package_name?: string) -> void` | Pipeline end-to-end: build Capacitor APK + arranca AVD + instala + opcionalmente lanza la app. Valida que el AVD existe, construye el APK con capacitor_build_apk, arranca el emulador de forma idempotente, instala el APK y lanza la app si se da package_name. Imprime comando logcat sugerido al final. |
| `fn_theme_kt_ui` | `@Composable fun FnTheme(darkMode: Boolean = true, content: @Composable () -> Unit)` | Provider raiz del design system Compose del registry (@fn_compose). Envuelve MaterialTheme con un ColorScheme derivado de FnColors (Mantine v9 dark + indigo). Dark por defecto, mirror de FnMantineProvider (web) y fn::run_app ThemeMode::FnDark (C++). Toda app del registry envuelve su contenido en FnTheme. |
| `fn_tokens_kt_ui` | `object FnTokens { colors; spacing; radius; typography; shadows }` | Design tokens del design system Compose del registry (@fn_compose). Paleta heredada exacta (mismos hex) de cpp/DESIGN_SYSTEM.md / Mantine v9 dark + indigo: FnColors, FnSpacing (Dp), FnRadius (Dp), FnTypography (sp + weights), FnShadows (Dp). Fuente unica de valores visuales para apps Android del registry. |
| `gradle_assemble_debug_bash_infra` | `gradle_assemble_debug(project_dir: string, module: string) -> string` | Build APK debug de un modulo Android via gradlew assembleDebug. |
| `gradle_clean_bash_infra` | `gradle_clean(project_dir: string) -> int` | Limpia build artifacts de un proyecto Android (gradle clean + rm .gradle + rm build). |
| `gradle_instrumented_test_bash_infra` | `gradle_instrumented_test(project_dir: string, module: string) -> int` | Corre instrumented tests Compose en emulador/device Android conectado. |
+97
View File
@@ -0,0 +1,97 @@
---
group: e2e-messaging
description: "Criptografía extremo a extremo para bus de mensajería: identidades duales Ed25519/X25519, distribución de claves de sala con sealed box anónimo, cifrado simétrico AEAD por mensaje, y firma/verificación de mensajes."
functions:
- generate_identity_go_cybersecurity
- seal_aead_go_cybersecurity
- open_aead_go_cybersecurity
- seal_key_box_go_cybersecurity
- open_key_box_go_cybersecurity
- sign_ed25519_go_cybersecurity
- verify_ed25519_go_cybersecurity
---
## Funciones del grupo
| ID | Firma corta | Qué hace |
|---|---|---|
| `generate_identity_go_cybersecurity` | `GenerateIdentity() (Identity, error)` | Genera par Ed25519 (firma) + par X25519 (kex) para un participante |
| `seal_aead_go_cybersecurity` | `SealAEAD(key, plaintext, aad []byte) (nonce, ct []byte, err error)` | Cifra mensaje con ChaCha20-Poly1305, nonce aleatorio por llamada |
| `open_aead_go_cybersecurity` | `OpenAEAD(key, nonce, ct, aad []byte) ([]byte, error)` | Descifra y autentica; error explícito si el tag falla |
| `seal_key_box_go_cybersecurity` | `SealKeyBox(recipientKexPub, secret []byte) ([]byte, error)` | Cifra room key para un destinatario con su X25519 pubkey (sealed box anónimo) |
| `open_key_box_go_cybersecurity` | `OpenKeyBox(kexPub, kexPriv, sealedMsg []byte) ([]byte, error)` | Abre sealed box con el par X25519 propio para recuperar la room key |
| `sign_ed25519_go_cybersecurity` | `SignEd25519(priv, msg []byte) []byte` | Firma determinista Ed25519 (pura, sin I/O) |
| `verify_ed25519_go_cybersecurity` | `VerifyEd25519(pub, msg, sig []byte) bool` | Verifica firma Ed25519 (pura, sin I/O) |
## Ejemplo canónico end-to-end
```go
package main
import (
"fmt"
"log"
cs "fn-registry/functions/cybersecurity"
)
func main() {
// 1. Cada participante genera su identidad una sola vez
server, err := cs.GenerateIdentity()
if err != nil { log.Fatal(err) }
user, err := cs.GenerateIdentity()
if err != nil { log.Fatal(err) }
// 2. Servidor genera room key y la distribuye al usuario cifrada
roomKey := make([]byte, 32)
// ... llenar roomKey con crypto/rand en producción ...
sealed, err := cs.SealKeyBox(user.KexPub, roomKey)
if err != nil { log.Fatal(err) }
// 3. Usuario recupera la room key
gotKey, err := cs.OpenKeyBox(user.KexPub, user.KexPriv, sealed)
if err != nil { log.Fatal(err) }
// 4. Usuario cifra un mensaje con la room key
aad := []byte("room:sala-general:seq:1")
nonce, ct, err := cs.SealAEAD(gotKey, []byte("hola sala"), aad)
if err != nil { log.Fatal(err) }
// 5. Usuario firma el ciphertext para autenticar autoría
sig := cs.SignEd25519(user.SignPriv, ct)
// 6. Receptor verifica firma y descifra
if !cs.VerifyEd25519(user.SignPub, ct, sig) {
log.Fatal("firma inválida")
}
plain, err := cs.OpenAEAD(gotKey, nonce, ct, aad)
if err != nil { log.Fatal(err) }
fmt.Printf("recibido: %s\n", plain)
_ = server // server.SignPub publicado en directorio de participantes
}
```
## Fronteras
Este grupo cubre las primitivas criptográficas del bus, no el protocolo completo:
- **No cubre**: transporte (WebSocket, gRPC), gestión de sesiones, ratchet de claves (doble ratchet), persistencia de identidades, revocación de claves.
- **No cubre**: cifrado de archivos adjuntos (usar SealAEAD directamente con una key derivada).
- **No reemplaza**: libsodium ni libolm para implementaciones de producción de Signal/Matrix — estas funciones son el sustrato criptográfico, no el protocolo completo.
## Prerequisitos
- `golang.org/x/crypto` ya en `go.mod` (presente en fn-registry).
- `crypto/ed25519` de stdlib (Go 1.13+).
- Identidades persistidas de forma segura (keyring, HSM, archivo cifrado): este grupo no gestiona almacenamiento.
## Patrón de uso recomendado
```
GenerateIdentity() → persiste Identity por participante
SealKeyBox(kexPub, roomKey) → distribuye room key al unirse a sala
OpenKeyBox(kexPub, kexPriv) → recupera room key
SealAEAD(roomKey, msg, aad) → cifra cada mensaje
SignEd25519(signPriv, ct) → autentica autoría sobre ciphertext
VerifyEd25519(signPub, ct) → verifica antes de descifrar
OpenAEAD(roomKey, nonce, ct)→ descifra mensaje verificado
```
+86
View File
@@ -0,0 +1,86 @@
# terminal-capture
Automatizar una CLI/TUI interactiva y capturar su texto, de forma headless, a través de un
pseudo-terminal (PTY). Cubre el ciclo completo: lanzar el proceso con un TTY real, inyectarle
input scripteado, esperar a que el render se estabilice, y convertir el stream crudo de bytes a
texto plano — bien reconstruyendo el layout 2D (TUIs con cursor absoluto), bien limpiando ANSI
de output secuencial.
Existe porque muchas CLIs (sobre todo la CLI `claude`) solo entran en su modo interactivo rico
cuando detectan un TTY; un pipe normal las degrada. El PTY es virtual, en memoria: **nunca abre
una ventana de terminal**.
## Funciones
| ID | Firma | Qué hace |
|---|---|---|
| `pty_capture_idle_go_infra` | `func PTYCaptureIdle(ctx, name string, args []string, warmup time.Duration, inputs []string, stepDelay, idle, maxDur time.Duration) (string, error)` | Lanza `name args` en un PTY (40×120), espera `warmup`, escribe cada `inputs` separado por `stepDelay`, y captura todos los bytes hasta que pasa `idle` sin output nuevo o se alcanza `maxDur`. Devuelve el stream **crudo** (ANSI intacto). One-shot. |
| `pty_capture_stream_go_infra` | `func PTYCaptureStream(ctx, name string, args []string, warmup time.Duration, inputs []string, stepDelay, snapshotInterval, idle, maxDur time.Duration) (<-chan string, error)` | Igual que `pty_capture_idle` pero emite **snapshots acumulativos** del buffer por un canal cada `snapshotInterval` — para hacer streaming de la TUI mientras renderiza. El consumidor renderiza/parsea cada snapshot. |
| `text_prefix_delta_go_core` | `func PrefixDelta(prev, curr string) string` | Devuelve la parte de `curr` que sigue al prefijo común con `prev` (delta de streaming por snapshots). Pura, compara por runas. Heurística ante reflow. |
| `vt_render_go_tui` | `func VTRender(raw string, rows, cols int) string` | Emula un terminal VT100 de `rows×cols`, alimenta `raw`, y devuelve el estado final de la pantalla como texto plano **con el layout reconstruido** (espacios reales donde el stream tenía movimientos de cursor). Pura. |
| `strip_ansi_go_core` | `func StripANSI(s string) string` | Elimina secuencias ANSI/VT100 y caracteres de control de un stream **secuencial** (logs), preservando `\n`, `\t`, `\r`. Pura. NO reconstruye layout 2D. |
| `parse_claude_tui_go_tui` | `func ParseClaudeTUI(screen string) ClaudeTUIParse` | Parsea la pantalla renderizada de la TUI de `claude` (salida de `vt_render`) y extrae los turnos (user/assistant/tool_use/tool_result) + la respuesta final (`Answer`), equivalente a lo que devolvería `claude -p`. Pura, heurística, específica de la TUI de claude. |
## Cuándo usar cada limpiador
El corazón del grupo es `pty_capture_idle` (la captura). Lo que cambia es cómo conviertes el raw a texto:
| Si la salida es… | Usa | Porque |
|---|---|---|
| Una TUI con posicionamiento absoluto (`claude`, `htop`, `dialog`) | `vt_render_go_tui` (modo screen) | Los "espacios" entre columnas eran movimientos de cursor; sin emular el grid las palabras se pegan (`2newMCPservers`). |
| Output secuencial línea a línea (logs, builds) | `strip_ansi_go_core` (modo stream) | No hay layout 2D que reconstruir; basta quitar los escape codes. |
| Quieres procesar los escape codes tú mismo | (ninguno — usa el raw) | El raw de `pty_capture_idle` ya los conserva. |
## Ejemplo canónico (end-to-end)
Capturar la respuesta de la CLI `claude` como texto con layout, en Go:
```go
import (
"context"
"time"
"fn-registry/functions/infra"
"fn-registry/functions/tui"
)
func main() {
ctx := context.Background()
// Teclear el prompt y pulsar Enter como pasos separados: un "\r" pegado al
// texto lo trata claude como newline literal, no como submit.
inputs := []string{"resume el README en 3 lineas", "\r"}
raw, _ := infra.PTYCaptureIdle(ctx, "claude", nil,
4*time.Second, // warmup: deja cargar la TUI
inputs, 600*time.Millisecond,
4*time.Second, // idle: corta tras 4s de silencio
60*time.Second) // maxDur: tope duro
screen := tui.VTRender(raw, 40, 120) // reconstruye el layout 2D
print(screen)
}
```
La app `claude_extract` (`apps/claude_extract`) empaqueta exactamente este flujo como CLI, con
modos `screen|stream|raw`, `--exec` para pipear a otro proceso, y `--cwd` para saltar el diálogo
de arranque de claude. Es el consumidor de referencia del grupo.
La app `claude_pipe` (`apps/claude_pipe`) va un paso más allá: añade `parse_claude_tui_go_tui`
al final del pipeline para devolver la respuesta de claude **como dato** con el mismo shape que
`claude -p --output-format json` (`--format json|text|turns`). Es la alternativa "parsea la TUI"
a `claude -p`, para cuando se quiere expresamente ir a través de la TUI en vez del stream-json.
## Fronteras
- **No es `claude -p`**: este grupo captura la TUI real (lo que se ve). Para interacción programática
limpia con la CLI `claude`, usa `claude_stream_go_core` (`claude -p --output-format stream-json`).
- **Linux/Unix only**: PTY POSIX (`creack/pty`). No Windows.
- **Sin color**: `vt_render` reconstruye texto y layout, no atributos de color.
- **Idle es heurístico**: TUIs con render periódico (spinners, relojes) no disparan el idle y caen
al `maxDur`. Para `claude` el spinner se detiene al terminar la respuesta, así que corta bien.
- **Dimensiones fijas 40×120**: el render debe usar el mismo tamaño que la captura o el wrapping no
cuadra.
## Notas
- Las dos funciones de limpieza son **puras**; solo `pty_capture_idle` es impura (lanza procesos).
Puras en los bordes, impura en el centro de la captura.
- `pty_capture_idle` no fija el cwd del hijo: para controlarlo, cambia el cwd del proceso que la
invoca antes de llamarla (lo que hace `claude_extract --cwd`).
+1
View File
@@ -12,6 +12,7 @@ Filtro MCP: `mcp__registry__fn_search query="" tag="web-proxy"`.
| [rotate_capture_flows_py_cybersecurity](../../python/functions/cybersecurity/rotate_capture_flows.md) | `mitmdump -s rotate_capture_flows.py --set rotate_min=N --set capture_dir=DIR` | Addon de mitmproxy que trocea las capturas en archivos `traffic-YYYYmmdd-HHMMSS.mitm` por ventanas de tiempo. Hace `flush()` por flujo, asi que la captura sobrevive a un `kill -9`. |
| [query_mitm_flows_bash_cybersecurity](../../bash/functions/cybersecurity/query_mitm_flows.md) | `query_mitm_flows <file_or_glob> [--filter EXPR] [--har OUT]` | Consulta capturas `.mitm` guardadas: vuelca los flujos que matchean un filtro de mitmproxy, o exporta a HAR. Acepta globs de varios archivos. |
| [launch_chromium_proxy_bash_browser](../../bash/functions/browser/launch_chromium_proxy.md) | `launch_chromium_proxy [--proxy URL] [--profile DIR] [--url URL]` | Lanza Chromium apuntando al proxy con un perfil aislado, sin contaminar la sesion normal. Maneja el CA del proxy o cae a `--ignore-certificate-errors`. |
| [tee_anthropic_sse_py_cybersecurity](../../python/functions/cybersecurity/tee_anthropic_sse.md) | `mitmdump -s tee_anthropic_sse.py` | Addon mitmproxy que intercepta el SSE de `POST api.anthropic.com/v1/messages` (la respuesta del modelo de la CLI claude) y emite el texto exacto token a token como NDJSON. Filtra la respuesta principal (`has_tools`) de las auxiliares (titulo/clasificador en haiku). Strip de `Accept-Encoding` para ver el SSE sin comprimir. Lo consume `apps/claude_wire`. |
Complementa: `port_kill_bash_infra` (limpieza de puertos ocupados).
+38
View File
@@ -0,0 +1,38 @@
package core
import (
"regexp"
"strings"
)
// ansiCSI matches CSI sequences: ESC [ ... <final byte>
// Covers colors (SGR), cursor movement, erase, etc.
var ansiCSI = regexp.MustCompile(`\x1b\[[0-9;?]*[ -/]*[@-~]`)
// ansiOSC matches OSC sequences: ESC ] ... <BEL or ST>
// Used for window titles, hyperlinks, etc.
var ansiOSC = regexp.MustCompile(`\x1b\][^\x07\x1b]*(\x07|\x1b\\)`)
// ansiEsc matches other two-character escape sequences: ESC <char>
// Covers ESC c (reset), ESC ( B, ESC ) 0, etc.
var ansiEsc = regexp.MustCompile(`\x1b[@-Z\\-_]|\x1b[()][0-9A-Za-z]`)
// StripANSI removes ANSI/VT100 terminal escape sequences from s and filters
// non-printable control characters, preserving newlines (\n), tabs (\t) and
// carriage returns (\r).
func StripANSI(s string) string {
s = ansiCSI.ReplaceAllString(s, "")
s = ansiOSC.ReplaceAllString(s, "")
s = ansiEsc.ReplaceAllString(s, "")
return strings.Map(func(r rune) rune {
// Preserve printable characters, \n (0x0A), \t (0x09), \r (0x0D).
if r == '\n' || r == '\t' || r == '\r' {
return r
}
// Drop C0 control characters (0x00-0x1F) and DEL (0x7F).
if r < 0x20 || r == 0x7F {
return -1
}
return r
}, s)
}
+55
View File
@@ -0,0 +1,55 @@
---
name: strip_ansi
kind: function
lang: go
domain: core
version: "1.0.0"
purity: pure
signature: "func StripANSI(s string) string"
description: "Elimina secuencias de escape ANSI/VT100 de un string y filtra caracteres de control no imprimibles, preservando \\n, \\t y \\r."
tags: ["terminal", "ansi", "string", "sanitize", "terminal-capture"]
uses_functions: []
uses_types: []
returns: []
returns_optional: false
error_type: ""
imports: ["regexp", "strings"]
params:
- name: s
desc: "String que puede contener secuencias de escape de terminal (CSI, OSC, escapes simples) y/o caracteres de control."
output: "String limpio: sin secuencias ANSI ni caracteres de control, preservando saltos de línea (\\n), tabulaciones (\\t) y retornos de carro (\\r)."
tested: true
tests:
- "golden: color SGR codes"
- "edge OSC titulo de ventana"
- "edge movimientos de cursor"
- "edge string sin escapes preserva saltos de linea"
- "edge string vacio"
- "edge preserva tabs"
test_file_path: "functions/core/strip_ansi_test.go"
file_path: "functions/core/strip_ansi.go"
---
## Ejemplo
```go
// Limpiar output de terminal con color rojo
raw := "\x1b[31mError:\x1b[0m archivo no encontrado"
clean := core.StripANSI(raw)
// clean == "Error: archivo no encontrado"
// Limpiar título de ventana OSC
raw2 := "\x1b]0;mi titulo\x07contenido real"
clean2 := core.StripANSI(raw2)
// clean2 == "contenido real"
```
## Cuando usarla
Cuando captures output de un PTY/TUI/subprocess y necesites texto plano: antes de indexar logs con ANSI en un buscador, antes de difar output de terminal, o cuando muestres salida de comando en un contexto sin soporte de escape (UI web, archivo, base de datos).
## Gotchas
- Preserva `\n`, `\t` y `\r` a propósito: el output de terminales suele tener CRLF y tabulaciones con semántica propia.
- Cubre CSI, OSC y escapes simples de dos caracteres. Secuencias DCS o PM (rarísimas) no se eliminan; si las necesitas, añade una regex adicional antes de llamar a esta función.
- Las regexes están precompiladas a nivel de paquete: no hay coste de compilación por llamada.
+53
View File
@@ -0,0 +1,53 @@
package core
import "testing"
func TestStripANSI(t *testing.T) {
t.Run("golden: color SGR codes", func(t *testing.T) {
got := StripANSI("\x1b[31mhola\x1b[0m mundo")
want := "hola mundo"
if got != want {
t.Errorf("got %q, want %q", got, want)
}
})
t.Run("edge OSC titulo de ventana", func(t *testing.T) {
got := StripANSI("\x1b]0;mi titulo\x07texto")
want := "texto"
if got != want {
t.Errorf("got %q, want %q", got, want)
}
})
t.Run("edge movimientos de cursor", func(t *testing.T) {
got := StripANSI("linea1\x1b[2K\x1b[1Glinea2")
want := "linea1linea2"
if got != want {
t.Errorf("got %q, want %q", got, want)
}
})
t.Run("edge string sin escapes preserva saltos de linea", func(t *testing.T) {
got := StripANSI("plano\ncon\nlineas")
want := "plano\ncon\nlineas"
if got != want {
t.Errorf("got %q, want %q", got, want)
}
})
t.Run("edge string vacio", func(t *testing.T) {
got := StripANSI("")
want := ""
if got != want {
t.Errorf("got %q, want %q", got, want)
}
})
t.Run("edge preserva tabs", func(t *testing.T) {
got := StripANSI("a\tb")
want := "a\tb"
if got != want {
t.Errorf("got %q, want %q", got, want)
}
})
}
+23
View File
@@ -0,0 +1,23 @@
package core
// PrefixDelta returns the portion of curr that follows the longest common
// prefix (LCP) shared with prev, comparing rune-by-rune to avoid splitting
// multi-byte characters.
//
// In the monotone streaming case (curr = prev + new), this returns exactly
// the new suffix. When the text diverges mid-way (reflow), it returns
// everything from the point of divergence to the end of curr.
func PrefixDelta(prev, curr string) string {
prevRunes := []rune(prev)
currRunes := []rune(curr)
common := 0
for common < len(prevRunes) && common < len(currRunes) {
if prevRunes[common] != currRunes[common] {
break
}
common++
}
return string(currRunes[common:])
}
+63
View File
@@ -0,0 +1,63 @@
---
name: text_prefix_delta
kind: function
lang: go
domain: core
version: "1.0.0"
purity: pure
signature: "func PrefixDelta(prev, curr string) string"
description: "Calcula el delta de streaming entre dos versiones de un texto: devuelve la porción de curr que sigue al prefijo común más largo con prev, comparando runa a runa para no partir caracteres multibyte."
tags: [string, diff, streaming, delta, terminal-capture]
uses_functions: []
uses_types: []
returns: []
returns_optional: false
error_type: ""
imports: []
params:
- name: prev
desc: "Versión anterior del texto acumulativo (snapshot anterior del stream)."
- name: curr
desc: "Versión actual del texto acumulativo (snapshot actual, normalmente extiende a prev)."
output: "La porción de curr que sigue al prefijo común con prev (el 'delta' de streaming). Devuelve cadena vacía si curr no añade nada nuevo tras el prefijo común."
tested: true
tests:
- "monotono append normal"
- "prev vacio devuelve curr completo"
- "sin cambios devuelve vacio"
- "divergencia en medio devuelve desde divergencia"
- "curr mas corto que prev devuelve vacio"
- "multibyte cafe streaming"
- "multibyte prefijo parcial antes de acento"
- "ambos vacios devuelve vacio"
- "prev no vacio curr vacio devuelve vacio"
- "determinismo misma entrada misma salida"
test_file_path: "functions/core/text_prefix_delta_test.go"
file_path: "functions/core/text_prefix_delta.go"
---
## Ejemplo
```go
// Bucle de streaming por snapshots acumulativos:
prev := ""
snapshots := []string{"Hola", "Hola, mun", "Hola, mundo!"}
for _, curr := range snapshots {
delta := PrefixDelta(prev, curr)
if delta != "" {
fmt.Print(delta) // emite solo la parte nueva
}
prev = curr
}
// Output: Hola, mundo!
```
## Cuando usarla
Cuando hagas streaming por snapshots acumulativos y necesites emitir solo la parte nueva de cada snapshot. Caso típico: consumir `pty_capture_stream_go_infra` donde cada captura de la TUI es un snapshot que extiende al anterior, y quieres emitir eventos `text_delta` estilo SSE/streaming sin reenviar texto ya enviado.
## Gotchas
- Compara por prefijo común, no por diff completo. Si el texto cambia en medio (reflow, borrado, sobreescritura de terminal), el delta incluye todo desde el punto de divergencia hasta el final de curr — puede re-emitir texto ya visto. Adecuado para append monótono; en streaming de TUI con reflow es heurístico, no exacto.
- Trabaja sobre runas (no bytes) para no partir caracteres UTF-8 multibyte como 'é', '中', '→'. El offset de corte siempre cae en un límite de runa válido.
+87
View File
@@ -0,0 +1,87 @@
package core
import "testing"
func TestPrefixDelta(t *testing.T) {
tests := []struct {
name string
prev string
curr string
want string
}{
{
name: "monotono append normal",
prev: "PON",
curr: "PONG",
want: "G",
},
{
name: "prev vacio devuelve curr completo",
prev: "",
curr: "abc",
want: "abc",
},
{
name: "sin cambios devuelve vacio",
prev: "abc",
curr: "abc",
want: "",
},
{
name: "divergencia en medio devuelve desde divergencia",
prev: "abc",
curr: "abXY",
want: "XY",
},
{
name: "curr mas corto que prev devuelve vacio",
prev: "abcdef",
curr: "abc",
want: "",
},
{
name: "multibyte cafe streaming",
prev: "café",
curr: "café con leche",
want: " con leche",
},
{
name: "multibyte prefijo parcial antes de acento",
prev: "ca",
curr: "café",
want: "fé",
},
{
name: "ambos vacios devuelve vacio",
prev: "",
curr: "",
want: "",
},
{
name: "prev no vacio curr vacio devuelve vacio",
prev: "hola",
curr: "",
want: "",
},
{
name: "determinismo misma entrada misma salida",
prev: "hello world",
curr: "hello world!",
want: "!",
},
}
for _, tc := range tests {
t.Run(tc.name, func(t *testing.T) {
got := PrefixDelta(tc.prev, tc.curr)
if got != tc.want {
t.Errorf("PrefixDelta(%q, %q) = %q, want %q", tc.prev, tc.curr, got, tc.want)
}
// Verificar determinismo: segunda llamada produce el mismo resultado.
got2 := PrefixDelta(tc.prev, tc.curr)
if got != got2 {
t.Errorf("no determinista: primera=%q segunda=%q", got, got2)
}
})
}
}
@@ -0,0 +1,304 @@
package cybersecurity
import (
"bytes"
"testing"
)
// --- GenerateIdentity ---
func TestGenerateIdentity(t *testing.T) {
t.Run("genera keypairs con longitudes correctas", func(t *testing.T) {
id, err := GenerateIdentity()
if err != nil {
t.Fatalf("GenerateIdentity() error = %v", err)
}
if len(id.SignPub) != 32 {
t.Errorf("SignPub len = %d, want 32", len(id.SignPub))
}
if len(id.SignPriv) != 64 {
t.Errorf("SignPriv len = %d, want 64", len(id.SignPriv))
}
if len(id.KexPub) != 32 {
t.Errorf("KexPub len = %d, want 32", len(id.KexPub))
}
if len(id.KexPriv) != 32 {
t.Errorf("KexPriv len = %d, want 32", len(id.KexPriv))
}
})
t.Run("dos llamadas producen identidades distintas", func(t *testing.T) {
id1, err1 := GenerateIdentity()
id2, err2 := GenerateIdentity()
if err1 != nil || err2 != nil {
t.Fatal("GenerateIdentity() error inesperado")
}
if bytes.Equal(id1.SignPub, id2.SignPub) {
t.Error("SignPub idénticos en dos identidades distintas")
}
if bytes.Equal(id1.KexPub, id2.KexPub) {
t.Error("KexPub idénticos en dos identidades distintas")
}
})
}
// --- SealAEAD / OpenAEAD ---
func TestSealOpenAEAD(t *testing.T) {
key := make([]byte, 32)
for i := range key {
key[i] = byte(i + 1)
}
plaintext := []byte("mensaje secreto del bus de mensajería")
aad := []byte("room:sala-general")
t.Run("round-trip con aad", func(t *testing.T) {
nonce, ct, err := SealAEAD(key, plaintext, aad)
if err != nil {
t.Fatalf("SealAEAD error = %v", err)
}
got, err := OpenAEAD(key, nonce, ct, aad)
if err != nil {
t.Fatalf("OpenAEAD error = %v", err)
}
if !bytes.Equal(got, plaintext) {
t.Errorf("got %q, want %q", got, plaintext)
}
})
t.Run("round-trip sin aad (nil)", func(t *testing.T) {
nonce, ct, err := SealAEAD(key, plaintext, nil)
if err != nil {
t.Fatalf("SealAEAD error = %v", err)
}
got, err := OpenAEAD(key, nonce, ct, nil)
if err != nil {
t.Fatalf("OpenAEAD error = %v", err)
}
if !bytes.Equal(got, plaintext) {
t.Errorf("got %q, want %q", got, plaintext)
}
})
t.Run("error con clave de longitud incorrecta", func(t *testing.T) {
_, _, err := SealAEAD(key[:16], plaintext, nil)
if err == nil {
t.Error("esperaba error con clave de 16 bytes, got nil")
}
})
t.Run("error de autenticacion con ciphertext modificado", func(t *testing.T) {
nonce, ct, err := SealAEAD(key, plaintext, aad)
if err != nil {
t.Fatalf("SealAEAD error = %v", err)
}
ct[0] ^= 0xFF // corromper el primer byte
_, err = OpenAEAD(key, nonce, ct, aad)
if err == nil {
t.Error("esperaba error de autenticación con ciphertext corrupto, got nil")
}
})
t.Run("error de autenticacion con aad distinto", func(t *testing.T) {
nonce, ct, err := SealAEAD(key, plaintext, aad)
if err != nil {
t.Fatalf("SealAEAD error = %v", err)
}
_, err = OpenAEAD(key, nonce, ct, []byte("room:otra-sala"))
if err == nil {
t.Error("esperaba error de autenticación con aad distinto, got nil")
}
})
t.Run("nonces distintos en llamadas sucesivas", func(t *testing.T) {
n1, _, err1 := SealAEAD(key, plaintext, nil)
n2, _, err2 := SealAEAD(key, plaintext, nil)
if err1 != nil || err2 != nil {
t.Fatal("SealAEAD error inesperado")
}
if bytes.Equal(n1, n2) {
t.Error("nonces iguales en dos llamadas sucesivas (no aleatorios)")
}
})
}
// --- SealKeyBox / OpenKeyBox ---
func TestSealOpenKeyBox(t *testing.T) {
t.Run("round-trip con identidad generada", func(t *testing.T) {
id, err := GenerateIdentity()
if err != nil {
t.Fatalf("GenerateIdentity error = %v", err)
}
roomKey := make([]byte, 32)
for i := range roomKey {
roomKey[i] = byte(i + 42)
}
sealed, err := SealKeyBox(id.KexPub, roomKey)
if err != nil {
t.Fatalf("SealKeyBox error = %v", err)
}
opened, err := OpenKeyBox(id.KexPub, id.KexPriv, sealed)
if err != nil {
t.Fatalf("OpenKeyBox error = %v", err)
}
if !bytes.Equal(opened, roomKey) {
t.Errorf("got %x, want %x", opened, roomKey)
}
})
t.Run("error con recipientKexPub de longitud incorrecta", func(t *testing.T) {
_, err := SealKeyBox(make([]byte, 16), []byte("secret"))
if err == nil {
t.Error("esperaba error con kexPub de 16 bytes, got nil")
}
})
t.Run("error al abrir con clave equivocada", func(t *testing.T) {
id, _ := GenerateIdentity()
other, _ := GenerateIdentity()
sealed, err := SealKeyBox(id.KexPub, []byte("roomkey"))
if err != nil {
t.Fatalf("SealKeyBox error = %v", err)
}
_, err = OpenKeyBox(other.KexPub, other.KexPriv, sealed)
if err == nil {
t.Error("esperaba error al abrir con keypair distinto, got nil")
}
})
t.Run("error con mensaje truncado", func(t *testing.T) {
id, _ := GenerateIdentity()
_, err := OpenKeyBox(id.KexPub, id.KexPriv, []byte("corto"))
if err == nil {
t.Error("esperaba error con sealedMsg truncado, got nil")
}
})
}
// --- SignEd25519 / VerifyEd25519 ---
func TestSignVerifyEd25519(t *testing.T) {
t.Run("firma y verificacion exitosa", func(t *testing.T) {
id, err := GenerateIdentity()
if err != nil {
t.Fatalf("GenerateIdentity error = %v", err)
}
msg := []byte("evento:room_key_rotation:v2")
sig := SignEd25519(id.SignPriv, msg)
if len(sig) != 64 {
t.Errorf("sig len = %d, want 64", len(sig))
}
if !VerifyEd25519(id.SignPub, msg, sig) {
t.Error("VerifyEd25519 devolvió false para firma válida")
}
})
t.Run("firma es determinista (misma entrada, misma firma)", func(t *testing.T) {
id, _ := GenerateIdentity()
msg := []byte("determinismo criptografico")
sig1 := SignEd25519(id.SignPriv, msg)
sig2 := SignEd25519(id.SignPriv, msg)
if !bytes.Equal(sig1, sig2) {
t.Error("Ed25519 debe ser determinista: mismas entradas deben producir misma firma")
}
})
t.Run("falla con mensaje modificado", func(t *testing.T) {
id, _ := GenerateIdentity()
msg := []byte("mensaje original")
sig := SignEd25519(id.SignPriv, msg)
modified := []byte("mensaje modificado")
if VerifyEd25519(id.SignPub, modified, sig) {
t.Error("VerifyEd25519 devolvió true para mensaje modificado")
}
})
t.Run("falla con clave publica incorrecta", func(t *testing.T) {
id1, _ := GenerateIdentity()
id2, _ := GenerateIdentity()
msg := []byte("autenticidad del remitente")
sig := SignEd25519(id1.SignPriv, msg)
if VerifyEd25519(id2.SignPub, msg, sig) {
t.Error("VerifyEd25519 devolvió true con clave pública de otra identidad")
}
})
t.Run("falla con firma corrupta", func(t *testing.T) {
id, _ := GenerateIdentity()
msg := []byte("integridad")
sig := SignEd25519(id.SignPriv, msg)
sig[0] ^= 0xFF
if VerifyEd25519(id.SignPub, msg, sig) {
t.Error("VerifyEd25519 devolvió true con firma corrupta")
}
})
}
// --- Integración: flujo completo megolm-reducido ---
func TestE2EMessagingFlow(t *testing.T) {
t.Run("flujo completo: generar identidad, distribuir clave de sala, cifrar y firmar mensaje", func(t *testing.T) {
// Servidor genera identidad
server, err := GenerateIdentity()
if err != nil {
t.Fatalf("GenerateIdentity server: %v", err)
}
// Usuario genera identidad
user, err := GenerateIdentity()
if err != nil {
t.Fatalf("GenerateIdentity user: %v", err)
}
// Servidor genera clave de sala y la distribuye al usuario cifrada con su KexPub
roomKey := make([]byte, 32)
for i := range roomKey {
roomKey[i] = byte(i)
}
sealedKey, err := SealKeyBox(user.KexPub, roomKey)
if err != nil {
t.Fatalf("SealKeyBox: %v", err)
}
// Usuario desella la clave de sala
gotRoomKey, err := OpenKeyBox(user.KexPub, user.KexPriv, sealedKey)
if err != nil {
t.Fatalf("OpenKeyBox: %v", err)
}
if !bytes.Equal(gotRoomKey, roomKey) {
t.Fatal("clave de sala distribuida no coincide")
}
// Usuario cifra un mensaje con la clave de sala
plainMsg := []byte("hola sala, este es mi primer mensaje cifrado e2e")
aad := []byte("room:sala-secreta:seq:1")
nonce, ct, err := SealAEAD(gotRoomKey, plainMsg, aad)
if err != nil {
t.Fatalf("SealAEAD: %v", err)
}
// Usuario firma el ciphertext para autenticación del remitente
sig := SignEd25519(user.SignPriv, ct)
// Receptor verifica firma del remitente
if !VerifyEd25519(user.SignPub, ct, sig) {
t.Fatal("verificación de firma del remitente falló")
}
// Receptor descifra el mensaje
decrypted, err := OpenAEAD(gotRoomKey, nonce, ct, aad)
if err != nil {
t.Fatalf("OpenAEAD: %v", err)
}
if !bytes.Equal(decrypted, plainMsg) {
t.Errorf("mensaje descifrado %q != original %q", decrypted, plainMsg)
}
// Servidor tiene distinta identidad que el usuario (las claves no se confunden)
if bytes.Equal(server.SignPub, user.SignPub) {
t.Error("server y user tienen la misma clave pública de firma")
}
})
}
@@ -0,0 +1,40 @@
package cybersecurity
import (
"crypto/ed25519"
"crypto/rand"
"golang.org/x/crypto/nacl/box"
)
// Identity holds a dual keypair for a messaging participant:
// an Ed25519 keypair for signing and a X25519 keypair for key exchange.
type Identity struct {
SignPub []byte // Ed25519 public key (32 bytes)
SignPriv []byte // Ed25519 private key (64 bytes)
KexPub []byte // X25519 public key (32 bytes)
KexPriv []byte // X25519 private key (32 bytes)
}
// GenerateIdentity creates a new Identity with freshly generated Ed25519 and X25519 keypairs.
// Ed25519 keys are used for signing; X25519 keys for key exchange (sealed box).
func GenerateIdentity() (Identity, error) {
// Ed25519 keypair for message signing
signPub, signPriv, err := ed25519.GenerateKey(rand.Reader)
if err != nil {
return Identity{}, err
}
// X25519 keypair for key exchange (nacl/box uses Curve25519 internally)
kexPub, kexPriv, err := box.GenerateKey(rand.Reader)
if err != nil {
return Identity{}, err
}
return Identity{
SignPub: []byte(signPub),
SignPriv: []byte(signPriv),
KexPub: kexPub[:],
KexPriv: kexPriv[:],
}, nil
}
@@ -0,0 +1,53 @@
---
name: generate_identity
kind: function
lang: go
domain: cybersecurity
version: "1.0.0"
purity: impure
signature: "func GenerateIdentity() (Identity, error)"
description: "Genera una identidad criptográfica dual con un par Ed25519 (firma) y un par X25519 (intercambio de claves). Punto de entrada obligatorio para cualquier participante en el bus de mensajería cifrado."
tags: [messaging, e2e-crypto, crypto, identity, ed25519, x25519, keygen, e2e-messaging]
uses_functions: []
uses_types: []
returns: []
returns_optional: false
error_type: "error_go_core"
imports:
- crypto/ed25519
- crypto/rand
- golang.org/x/crypto/nacl/box
params:
- name: "(ninguno)"
desc: "Sin parámetros. Usa crypto/rand como fuente de entropía del sistema."
output: "Identity{SignPub []byte, SignPriv []byte, KexPub []byte, KexPriv []byte} o error si falla el RNG del sistema."
tested: true
tests:
- "genera keypairs con longitudes correctas"
- "dos llamadas producen identidades distintas"
test_file_path: "functions/cybersecurity/e2e_messaging_crypto_test.go"
file_path: "functions/cybersecurity/generate_identity.go"
---
## Ejemplo
```go
id, err := cybersecurity.GenerateIdentity()
if err != nil {
log.Fatal(err)
}
// id.SignPub / id.SignPriv — par Ed25519 para firmar mensajes
// id.KexPub / id.KexPriv — par X25519 para recibir claves de sala cifradas
fmt.Printf("identity pub(sign)=%x pub(kex)=%x\n", id.SignPub, id.KexPub)
```
## Cuando usarla
Al registrar un nuevo participante en el bus de mensajería: llama GenerateIdentity una sola vez por dispositivo/sesión, persiste los bytes de las cuatro claves de forma segura, y publica `SignPub` + `KexPub` en el directorio de participantes.
## Gotchas
- La función depende de `crypto/rand`; en entornos con entropía insuficiente (contenedores recién arrancados) puede bloquearse brevemente.
- `SignPriv` tiene 64 bytes (no 32): Ed25519 concatena seed (32) + clave pública (32) internamente. No truncar.
- `KexPub`/`KexPriv` son exactamente 32 bytes (Curve25519). Pasar exactamente esos slices a `SealKeyBox`/`OpenKeyBox`.
- Nunca reutilizar una identidad entre dispositivos distintos del mismo usuario sin un protocolo de clonado seguro.
+29
View File
@@ -0,0 +1,29 @@
package cybersecurity
import (
"fmt"
"golang.org/x/crypto/chacha20poly1305"
)
// OpenAEAD decrypts a ciphertext produced by SealAEAD using ChaCha20-Poly1305.
// key must be exactly 32 bytes. nonce must match the one returned by SealAEAD.
// aad must match what was passed to SealAEAD (can be nil).
// Returns an error if authentication fails (tampered ciphertext, wrong key, or wrong aad).
func OpenAEAD(key, nonce, ciphertext, aad []byte) ([]byte, error) {
if len(key) != chacha20poly1305.KeySize {
return nil, fmt.Errorf("open_aead: key must be %d bytes, got %d", chacha20poly1305.KeySize, len(key))
}
aead, err := chacha20poly1305.New(key)
if err != nil {
return nil, fmt.Errorf("open_aead: create cipher: %w", err)
}
plaintext, err := aead.Open(nil, nonce, ciphertext, aad)
if err != nil {
return nil, fmt.Errorf("open_aead: authentication failed: %w", err)
}
return plaintext, nil
}
+62
View File
@@ -0,0 +1,62 @@
---
name: open_aead
kind: function
lang: go
domain: cybersecurity
version: "1.0.0"
purity: impure
signature: "func OpenAEAD(key, nonce, ciphertext, aad []byte) ([]byte, error)"
description: "Descifra y autentica un ciphertext producido por SealAEAD usando ChaCha20-Poly1305. Devuelve error explícito si la autenticación falla (ciphertext alterado, clave incorrecta o AAD distinto)."
tags: [messaging, e2e-crypto, crypto, aead, chacha20poly1305, symmetric, e2e-messaging]
uses_functions: []
uses_types: []
returns: []
returns_optional: false
error_type: "error_go_core"
imports:
- golang.org/x/crypto/chacha20poly1305
params:
- name: key
desc: "Clave simétrica de exactamente 32 bytes. Debe ser la misma usada en SealAEAD."
- name: nonce
desc: "Nonce de 12 bytes devuelto por SealAEAD. Debe transmitirse junto al ciphertext."
- name: ciphertext
desc: "Ciphertext producido por SealAEAD (incluye los 16 bytes del tag Poly1305)."
- name: aad
desc: "Datos autenticados adicionales. Debe ser idéntico al aad usado en SealAEAD, o nil si se pasó nil."
output: "Plaintext descifrado, o error si la autenticación falla o la clave tiene longitud incorrecta."
tested: true
tests:
- "round-trip con aad"
- "round-trip sin aad (nil)"
- "error con clave de longitud incorrecta"
- "error de autenticacion con ciphertext modificado"
- "error de autenticacion con aad distinto"
- "nonces distintos en llamadas sucesivas"
test_file_path: "functions/cybersecurity/e2e_messaging_crypto_test.go"
file_path: "functions/cybersecurity/open_aead.go"
---
## Ejemplo
```go
// nonce y ct vienen de SealAEAD; aad debe reconstruirse igual
aad := []byte("room:sala-general:seq:42")
plaintext, err := cybersecurity.OpenAEAD(key, nonce, ct, aad)
if err != nil {
// mensaje alterado, clave incorrecta o aad distinto — descartar
log.Printf("autenticación fallida: %v", err)
return
}
fmt.Printf("mensaje: %s\n", plaintext)
```
## Cuando usarla
Al recibir un mensaje del bus: después de resolver la room key con OpenKeyBox, llama OpenAEAD para descifrar y verificar integridad. Si devuelve error, el mensaje llegó corrupto o fue alterado en tránsito — descartar siempre, nunca procesar plaintext parcial.
## Gotchas
- El error no distingue entre "clave incorrecta", "nonce incorrecto" y "ciphertext alterado": todos devuelven el mismo error de autenticación por diseño (evita oráculos de padding).
- Si el ciphertext tiene menos de 16 bytes, la función devuelve error antes de intentar descifrar.
- El aad debe ser reconstructible por el receptor de forma independiente (no viaja en el mensaje cifrado).
+32
View File
@@ -0,0 +1,32 @@
package cybersecurity
import (
"fmt"
"golang.org/x/crypto/nacl/box"
)
// OpenKeyBox decrypts a sealed box produced by SealKeyBox using the recipient's X25519 keypair.
// kexPub and kexPriv must each be exactly 32 bytes and correspond to the public key
// passed to SealKeyBox as recipientKexPub.
// Returns an error if decryption or authentication fails.
func OpenKeyBox(kexPub, kexPriv, sealedMsg []byte) ([]byte, error) {
if len(kexPub) != 32 {
return nil, fmt.Errorf("open_key_box: kexPub must be 32 bytes, got %d", len(kexPub))
}
if len(kexPriv) != 32 {
return nil, fmt.Errorf("open_key_box: kexPriv must be 32 bytes, got %d", len(kexPriv))
}
var pub [32]byte
var priv [32]byte
copy(pub[:], kexPub)
copy(priv[:], kexPriv)
plaintext, ok := box.OpenAnonymous(nil, sealedMsg, &pub, &priv)
if !ok {
return nil, fmt.Errorf("open_key_box: decryption failed (authentication error or corrupted message)")
}
return plaintext, nil
}
+58
View File
@@ -0,0 +1,58 @@
---
name: open_key_box
kind: function
lang: go
domain: cybersecurity
version: "1.0.0"
purity: impure
signature: "func OpenKeyBox(kexPub, kexPriv, sealedMsg []byte) ([]byte, error)"
description: "Descifra un sealed box anónimo producido por SealKeyBox usando el par X25519 del destinatario. Devuelve error si la autenticación falla o el mensaje está corrupto."
tags: [messaging, e2e-crypto, crypto, nacl, x25519, sealed-box, key-distribution, e2e-messaging]
uses_functions: []
uses_types: []
returns: []
returns_optional: false
error_type: "error_go_core"
imports:
- golang.org/x/crypto/nacl/box
params:
- name: kexPub
desc: "Clave pública X25519 del destinatario (exactamente 32 bytes). Debe coincidir con la usada en SealKeyBox."
- name: kexPriv
desc: "Clave privada X25519 del destinatario (exactamente 32 bytes). Viene del campo KexPriv de su Identity."
- name: sealedMsg
desc: "Sealed box producido por SealKeyBox. Mínimo 48 bytes (32 overhead ephemeral + 16 tag)."
output: "Secreto descifrado (ej. room key de 32 bytes), o error si la autenticación falla, el par de claves no coincide, o el mensaje está truncado."
tested: true
tests:
- "round-trip con identidad generada"
- "error con recipientKexPub de longitud incorrecta"
- "error al abrir con clave equivocada"
- "error con mensaje truncado"
test_file_path: "functions/cybersecurity/e2e_messaging_crypto_test.go"
file_path: "functions/cybersecurity/open_key_box.go"
---
## Ejemplo
```go
// Receptor obtiene su Identity del almacén seguro
id, _ := loadIdentityFromSecureStorage()
roomKey, err := cybersecurity.OpenKeyBox(id.KexPub, id.KexPriv, sealedMsgFromServer)
if err != nil {
log.Printf("no se pudo abrir la room key: %v", err)
return
}
// roomKey lista para usar en SealAEAD / OpenAEAD
```
## Cuando usarla
Al recibir una distribución de clave de sala del servidor: llama OpenKeyBox con el par X25519 propio para recuperar la room key simétrica. Después de obtenerla, úsala en OpenAEAD para descifrar los mensajes de esa sala.
## Gotchas
- El error no distingue entre "clave incorrecta" y "mensaje corrupto" por diseño de seguridad.
- Si `sealedMsg` tiene menos de 48 bytes (overhead mínimo del sealed box), la función devuelve error sin intentar descifrar.
- `kexPub` y `kexPriv` deben ser el par correspondiente: pasar la pubkey de otro usuario con la privkey propia siempre falla.
- La room key recuperada es sensible: no logearla ni incluirla en mensajes de error.
+31
View File
@@ -0,0 +1,31 @@
package cybersecurity
import (
"crypto/rand"
"fmt"
"io"
"golang.org/x/crypto/chacha20poly1305"
)
// SealAEAD encrypts plaintext with ChaCha20-Poly1305, returning a random nonce and ciphertext.
// key must be exactly 32 bytes. aad (additional authenticated data) may be nil.
// The returned nonce must be stored alongside the ciphertext and passed to OpenAEAD.
func SealAEAD(key, plaintext, aad []byte) (nonce, ciphertext []byte, err error) {
if len(key) != chacha20poly1305.KeySize {
return nil, nil, fmt.Errorf("seal_aead: key must be %d bytes, got %d", chacha20poly1305.KeySize, len(key))
}
aead, err := chacha20poly1305.New(key)
if err != nil {
return nil, nil, fmt.Errorf("seal_aead: create cipher: %w", err)
}
nonce = make([]byte, aead.NonceSize())
if _, err = io.ReadFull(rand.Reader, nonce); err != nil {
return nil, nil, fmt.Errorf("seal_aead: generate nonce: %w", err)
}
ciphertext = aead.Seal(nil, nonce, plaintext, aad)
return nonce, ciphertext, nil
}
+60
View File
@@ -0,0 +1,60 @@
---
name: seal_aead
kind: function
lang: go
domain: cybersecurity
version: "1.0.0"
purity: impure
signature: "func SealAEAD(key, plaintext, aad []byte) (nonce, ciphertext []byte, err error)"
description: "Cifra plaintext con ChaCha20-Poly1305 usando una clave simétrica de 32 bytes. Genera un nonce aleatorio por llamada. Admite datos autenticados adicionales (AAD) para vincular contexto al cifrado sin cifrarlo."
tags: [messaging, e2e-crypto, crypto, aead, chacha20poly1305, symmetric, e2e-messaging]
uses_functions: []
uses_types: []
returns: []
returns_optional: false
error_type: "error_go_core"
imports:
- crypto/rand
- golang.org/x/crypto/chacha20poly1305
params:
- name: key
desc: "Clave simétrica de exactamente 32 bytes (256 bits). Típicamente la room key distribuida con SealKeyBox."
- name: plaintext
desc: "Bytes a cifrar. Puede ser vacío."
- name: aad
desc: "Datos autenticados adicionales (AAD): se autentican pero no se cifran. Útil para room ID, número de secuencia, etc. Puede ser nil."
output: "nonce (12 bytes aleatorios), ciphertext (plaintext cifrado + 16 bytes de tag Poly1305), o error si la clave tiene longitud incorrecta o falla el RNG."
tested: true
tests:
- "round-trip con aad"
- "round-trip sin aad (nil)"
- "error con clave de longitud incorrecta"
- "error de autenticacion con ciphertext modificado"
- "error de autenticacion con aad distinto"
- "nonces distintos en llamadas sucesivas"
test_file_path: "functions/cybersecurity/e2e_messaging_crypto_test.go"
file_path: "functions/cybersecurity/seal_aead.go"
---
## Ejemplo
```go
key := make([]byte, 32) // en producción: room key distribuida con SealKeyBox
aad := []byte("room:sala-general:seq:42")
nonce, ct, err := cybersecurity.SealAEAD(key, []byte("hola sala"), aad)
if err != nil {
log.Fatal(err)
}
// Almacenar nonce junto al ciphertext para descifrar después
```
## Cuando usarla
Al cifrar cada mensaje en una sala del bus: usa la room key de la sala como `key`, incluye el ID de sala y número de secuencia en `aad` para prevenir replay attacks entre salas, y transmite `nonce + ciphertext` juntos al destinatario.
## Gotchas
- El nonce es aleatorio (12 bytes): con una misma key, la probabilidad de colisión de nonces es despreciable para <2^32 mensajes, pero en escenarios de alto volumen considera rotar la room key periódicamente.
- El ciphertext es 16 bytes más largo que el plaintext (tag Poly1305).
- `aad` no viaja cifrado: el destinatario debe reconstruirlo independientemente para verificar. Si aad difiere aunque sea 1 bit, OpenAEAD falla con error de autenticación.
- Nunca reutilizar `(key, nonce)` para dos plaintexts distintos: rompe la confidencialidad de ChaCha20.
+28
View File
@@ -0,0 +1,28 @@
package cybersecurity
import (
"crypto/rand"
"fmt"
"golang.org/x/crypto/nacl/box"
)
// SealKeyBox encrypts secret for a recipient identified by their X25519 public key,
// using an anonymous sealed box (ephemeral sender keypair, no sender authentication).
// Intended for distributing a symmetric room key to a participant.
// recipientKexPub must be exactly 32 bytes.
func SealKeyBox(recipientKexPub, secret []byte) ([]byte, error) {
if len(recipientKexPub) != 32 {
return nil, fmt.Errorf("seal_key_box: recipientKexPub must be 32 bytes, got %d", len(recipientKexPub))
}
var recipientKey [32]byte
copy(recipientKey[:], recipientKexPub)
sealed, err := box.SealAnonymous(nil, secret, &recipientKey, rand.Reader)
if err != nil {
return nil, fmt.Errorf("seal_key_box: %w", err)
}
return sealed, nil
}
+56
View File
@@ -0,0 +1,56 @@
---
name: seal_key_box
kind: function
lang: go
domain: cybersecurity
version: "1.0.0"
purity: impure
signature: "func SealKeyBox(recipientKexPub, secret []byte) ([]byte, error)"
description: "Cifra un secreto (típicamente una room key simétrica) para un destinatario identificado por su clave pública X25519, usando un sealed box anónimo (nacl/box). El emisor no se autentica; usar SignEd25519 por separado si se necesita autenticación del remitente."
tags: [messaging, e2e-crypto, crypto, nacl, x25519, sealed-box, key-distribution, e2e-messaging]
uses_functions: []
uses_types: []
returns: []
returns_optional: false
error_type: "error_go_core"
imports:
- crypto/rand
- golang.org/x/crypto/nacl/box
params:
- name: recipientKexPub
desc: "Clave pública X25519 del destinatario (exactamente 32 bytes). Viene del campo KexPub de su Identity."
- name: secret
desc: "Bytes a cifrar. Típicamente una room key de 32 bytes, pero puede ser cualquier secreto."
output: "Sealed box cifrado (overhead: 32 bytes de ephemeral pubkey + 16 bytes de tag Poly1305), o error si recipientKexPub no tiene 32 bytes o falla el RNG."
tested: true
tests:
- "round-trip con identidad generada"
- "error con recipientKexPub de longitud incorrecta"
- "error al abrir con clave equivocada"
- "error con mensaje truncado"
test_file_path: "functions/cybersecurity/e2e_messaging_crypto_test.go"
file_path: "functions/cybersecurity/seal_key_box.go"
---
## Ejemplo
```go
// Distribuir room key al usuario al unirse a la sala
roomKey := make([]byte, 32) // generada por el servidor de sala
sealed, err := cybersecurity.SealKeyBox(user.KexPub, roomKey)
if err != nil {
log.Fatal(err)
}
// Enviar sealed al usuario; solo él puede abrirlo con OpenKeyBox
```
## Cuando usarla
Al distribuir una clave simétrica de sala a un nuevo participante: cifra la room key con la KexPub del destinatario antes de transmitirla. El destinatario usa OpenKeyBox para recuperarla. Combinar con SignEd25519 sobre el sealed box si se necesita autenticar que el servidor distribuyó la clave.
## Gotchas
- El sealed box es anónimo: el receptor no puede verificar quién lo generó. Firmar el sealed box con SignEd25519 si la autenticación del emisor importa.
- Overhead fijo: 48 bytes adicionales sobre el secreto (32 ephemeral pubkey + 16 tag).
- El sealed box no puede abrirse sin la clave privada X25519 correspondiente: si el usuario pierde KexPriv, la room key es irrecuperable.
- `recipientKexPub` debe tener exactamente 32 bytes; la función valida y devuelve error claro si no.
+10
View File
@@ -0,0 +1,10 @@
package cybersecurity
import "crypto/ed25519"
// SignEd25519 signs msg with an Ed25519 private key and returns the 64-byte signature.
// priv must be a valid Ed25519 private key (64 bytes as returned by GenerateIdentity or ed25519.GenerateKey).
// This function is pure: same inputs always produce the same output (ed25519 is deterministic).
func SignEd25519(priv, msg []byte) []byte {
return ed25519.Sign(ed25519.PrivateKey(priv), msg)
}
+45
View File
@@ -0,0 +1,45 @@
---
name: sign_ed25519
kind: function
lang: go
domain: cybersecurity
version: "1.0.0"
purity: pure
signature: "func SignEd25519(priv, msg []byte) []byte"
description: "Firma un mensaje con una clave privada Ed25519 y devuelve la firma de 64 bytes. Determinista: mismas entradas producen siempre la misma firma. Sin efectos secundarios ni I/O."
tags: [messaging, e2e-crypto, crypto, ed25519, signing, pure, e2e-messaging]
uses_functions: []
uses_types: []
returns: []
returns_optional: false
error_type: ""
imports:
- crypto/ed25519
params:
- name: priv
desc: "Clave privada Ed25519 de 64 bytes. Viene del campo SignPriv de Identity."
- name: msg
desc: "Bytes a firmar. Puede ser cualquier dato: ciphertext, evento, room key distribuida, etc."
output: "Firma Ed25519 de exactamente 64 bytes. Siempre determinista para la misma (priv, msg)."
tested: true
tests:
- "firma y verificacion exitosa"
- "firma es determinista (misma entrada, misma firma)"
- "falla con mensaje modificado"
- "falla con clave publica incorrecta"
- "falla con firma corrupta"
test_file_path: "functions/cybersecurity/e2e_messaging_crypto_test.go"
file_path: "functions/cybersecurity/sign_ed25519.go"
---
## Ejemplo
```go
// Firmar el ciphertext de un mensaje antes de transmitirlo
sig := cybersecurity.SignEd25519(id.SignPriv, ciphertext)
// Transmitir ciphertext + sig; el receptor verifica con VerifyEd25519
```
## Cuando usarla
Después de cifrar un mensaje con SealAEAD: firma el ciphertext (no el plaintext) con tu SignPriv para que el receptor pueda verificar la autoría con VerifyEd25519. También útil para firmar eventos de control del bus (rotación de clave, join/leave de sala).
+10
View File
@@ -0,0 +1,10 @@
package cybersecurity
import "crypto/ed25519"
// VerifyEd25519 reports whether sig is a valid Ed25519 signature of msg under pub.
// pub must be a valid Ed25519 public key (32 bytes as returned by GenerateIdentity).
// Returns true only if the signature is authentic; false on any mismatch or invalid input.
func VerifyEd25519(pub, msg, sig []byte) bool {
return ed25519.Verify(ed25519.PublicKey(pub), msg, sig)
}
+51
View File
@@ -0,0 +1,51 @@
---
name: verify_ed25519
kind: function
lang: go
domain: cybersecurity
version: "1.0.0"
purity: pure
signature: "func VerifyEd25519(pub, msg, sig []byte) bool"
description: "Verifica una firma Ed25519 sobre un mensaje usando la clave pública del firmante. Devuelve true solo si la firma es auténtica. Sin efectos secundarios ni I/O."
tags: [messaging, e2e-crypto, crypto, ed25519, signing, pure, e2e-messaging]
uses_functions: []
uses_types: []
returns: []
returns_optional: false
error_type: ""
imports:
- crypto/ed25519
params:
- name: pub
desc: "Clave pública Ed25519 de 32 bytes del firmante. Viene del campo SignPub de su Identity."
- name: msg
desc: "Mensaje original que fue firmado. Debe ser idéntico al pasado a SignEd25519."
- name: sig
desc: "Firma de 64 bytes producida por SignEd25519."
output: "true si la firma es válida para (pub, msg). false en cualquier otro caso: firma incorrecta, pub equivocada, msg alterado, o sig corrupta."
tested: true
tests:
- "firma y verificacion exitosa"
- "firma es determinista (misma entrada, misma firma)"
- "falla con mensaje modificado"
- "falla con clave publica incorrecta"
- "falla con firma corrupta"
test_file_path: "functions/cybersecurity/e2e_messaging_crypto_test.go"
file_path: "functions/cybersecurity/verify_ed25519.go"
---
## Ejemplo
```go
// Receptor verifica autoría antes de descifrar
if !cybersecurity.VerifyEd25519(sender.SignPub, ciphertext, sig) {
log.Println("firma inválida: mensaje descartado")
return
}
// Solo si la firma es válida, descifrar con OpenAEAD
plaintext, err := cybersecurity.OpenAEAD(roomKey, nonce, ciphertext, aad)
```
## Cuando usarla
Al recibir un mensaje del bus: verifica la firma del remitente sobre el ciphertext antes de intentar descifrar. Devuelve false para cualquier fallo de autenticación — nunca procesar un mensaje con firma inválida.
+149
View File
@@ -0,0 +1,149 @@
package infra
import (
"bytes"
"context"
"fmt"
"os/exec"
"sync"
"syscall"
"time"
"github.com/creack/pty"
)
// PTYCaptureIdle launches a command inside a pseudo-terminal (PTY) and captures
// all output until the terminal has been idle for at least idle duration, or
// maxDur has elapsed. Before sending inputs it waits warmup to let the process
// initialize. Between each input step it waits stepDelay.
//
// The returned string is the raw PTY output, ANSI escape sequences included.
// To turn it into plain text: use vt_render_go_tui to reconstruct the 2D screen
// layout for TUIs with absolute cursor positioning (claude, htop), or
// strip_ansi_go_core for sequential, log-like output.
func PTYCaptureIdle(
ctx context.Context,
name string,
args []string,
warmup time.Duration,
inputs []string,
stepDelay time.Duration,
idle time.Duration,
maxDur time.Duration,
) (string, error) {
cmd := exec.CommandContext(ctx, name, args...)
ptmx, err := pty.Start(cmd)
if err != nil {
return "", fmt.Errorf("pty_capture_idle: pty.Start: %w", err)
}
// Set a reasonable terminal size so TUIs render without truncating.
if szErr := pty.Setsize(ptmx, &pty.Winsize{Rows: 40, Cols: 120}); szErr != nil {
// Non-fatal: continue even if resize fails.
_ = szErr
}
var (
mu sync.Mutex
buf bytes.Buffer
lastByte = time.Now()
)
// Reader goroutine: copy PTY output into buf and track last-byte time.
readDone := make(chan struct{})
go func() {
defer close(readDone)
tmp := make([]byte, 4096)
for {
n, rerr := ptmx.Read(tmp)
if n > 0 {
mu.Lock()
buf.Write(tmp[:n])
lastByte = time.Now()
mu.Unlock()
}
if rerr != nil {
// EIO/EOF is normal on Linux when the PTY master is closed
// after the child exits. Not a real error.
return
}
}
}()
start := time.Now()
// Wait for warmup so the TUI/CLI has time to initialize.
select {
case <-time.After(warmup):
case <-ctx.Done():
_ = ptmx.Close()
<-readDone
mu.Lock()
out := buf.String()
mu.Unlock()
return out, fmt.Errorf("pty_capture_idle: context cancelled during warmup: %w", ctx.Err())
}
// Send inputs one by one with stepDelay between them.
for _, in := range inputs {
if _, werr := ptmx.Write([]byte(in)); werr != nil {
// PTY may have closed already; stop sending.
break
}
select {
case <-time.After(stepDelay):
case <-ctx.Done():
goto done
}
}
done:
// Poll until idle or maxDur.
ticker := time.NewTicker(100 * time.Millisecond)
defer ticker.Stop()
for {
select {
case <-ticker.C:
mu.Lock()
sinceLastByte := time.Since(lastByte)
mu.Unlock()
elapsed := time.Since(start)
if sinceLastByte >= idle || elapsed >= maxDur {
goto shutdown
}
case <-ctx.Done():
goto shutdown
}
}
shutdown:
// Close the PTY master. This signals EOF to the reader goroutine.
_ = ptmx.Close()
// Graceful shutdown: SIGTERM first, then SIGKILL after 2s.
if cmd.Process != nil {
_ = cmd.Process.Signal(syscall.SIGTERM)
killTimer := time.NewTimer(2 * time.Second)
waitCh := make(chan error, 1)
go func() { waitCh <- cmd.Wait() }()
select {
case <-waitCh:
// Process exited cleanly.
case <-killTimer.C:
_ = cmd.Process.Kill()
<-waitCh
}
killTimer.Stop()
}
<-readDone
mu.Lock()
out := buf.String()
mu.Unlock()
return out, nil
}
+83
View File
@@ -0,0 +1,83 @@
---
name: pty_capture_idle
kind: function
lang: go
domain: infra
version: "1.0.0"
purity: impure
signature: "func PTYCaptureIdle(ctx context.Context, name string, args []string, warmup time.Duration, inputs []string, stepDelay, idle, maxDur time.Duration) (string, error)"
description: "Lanza un comando dentro de un pseudo-terminal (PTY) en memoria y captura todo su output hasta que el terminal permanece idle durante al menos `idle`, o se alcanza `maxDur`. Soporta envío de inputs interactivos tras el warmup inicial. Devuelve el output RAW con secuencias ANSI intactas."
tags: ["terminal", "pty", "tui", "capture", "automation", "terminal-capture"]
uses_functions: []
uses_types: []
returns: []
returns_optional: false
error_type: "error_go_core"
imports:
- "context"
- "time"
- "github.com/creack/pty"
tested: true
tests:
- "captura output de echo hola"
- "input interactivo con cat"
- "timeout duro con sleep 10"
test_file_path: "functions/infra/pty_capture_idle_test.go"
file_path: "functions/infra/pty_capture_idle.go"
params:
- name: ctx
desc: "Contexto de cancelación. Si se cancela, la función aborta la captura y retorna el output acumulado hasta ese momento."
- name: name
desc: "Nombre o path del ejecutable a lanzar (debe existir en PATH o ser un path absoluto)."
- name: args
desc: "Argumentos posicionales para el ejecutable. Puede ser nil o vacío."
- name: warmup
desc: "Tiempo que la función espera después de arrancar el proceso antes de enviar inputs. Permite que la TUI inicialice su render. Típico: 500ms2s para CLIs lentas."
- name: inputs
desc: "Lista de strings a escribir al PTY en secuencia, uno por vez. Incluir '\\r' al final de cada string para simular Enter. Puede ser nil si solo se quiere observar la salida sin interactuar."
- name: stepDelay
desc: "Espera entre cada input enviado. Permite que la TUI procese y renderice la respuesta de cada paso antes de enviar el siguiente."
- name: idle
desc: "Tiempo sin nuevos bytes en el PTY que se considera 'respuesta terminada'. Cuando el terminal lleva idle sin actividad, la función retorna. Típico: 500ms2s."
- name: maxDur
desc: "Timeout duro desde el inicio de la función. Garantiza que la función retorna aunque la TUI siga emitiendo output indefinidamente (spinners, relojes). Típico: 30s120s."
output: "String con el output completo del terminal desde el arranque hasta la captura, incluyendo secuencias de escape ANSI. Vacío string si el proceso no produjo nada. Error si el PTY no pudo arrancar o el contexto fue cancelado durante warmup."
---
## Ejemplo
```go
// Capturar una sesión de claude con un prompt automático
ctx, cancel := context.WithTimeout(context.Background(), 2*time.Minute)
defer cancel()
raw, err := PTYCaptureIdle(
ctx,
"claude", nil,
2*time.Second, // warmup: esperar que claude cargue
[]string{"hola, responde PONG\r"}, // inputs: enviar mensaje + Enter
300*time.Millisecond, // stepDelay entre inputs
2*time.Second, // idle: cortar cuando lleve 2s sin output
120*time.Second, // maxDur: timeout duro
)
if err != nil {
log.Fatal(err)
}
// raw contiene el render completo con ANSI; limpiar antes de procesar texto:
// clean := StripANSI(raw) // strip_ansi_go_tui
fmt.Println(raw)
```
## Cuando usarla
Cuando necesites automatizar o scriptear una CLI interactiva que solo entra en modo interactivo si detecta un TTY real (como `claude`, `vim`, `fzf`, `htop`, `python` REPL, `psql`). El PTY hace creer al proceso que habla con un terminal real, sin abrir ninguna ventana gráfica.
## Gotchas
- **Linux/Unix only.** Usa PTY POSIX (`creack/pty`). No funciona en Windows.
- **Output RAW con ANSI.** El string devuelto contiene secuencias de escape (`\x1b[...m`, cursor moves, etc.). Para convertirlo a texto plano: usa `vt_render_go_tui` (reconstruye el layout 2D — correcto para TUIs con posicionamiento absoluto como `claude` o `htop`) o `strip_ansi_go_core` (para output secuencial tipo log). `strip_ansi` sobre una TUI con layout absoluto deja las palabras pegadas porque los espacios entre columnas eran movimientos de cursor.
- **Idle es heurístico.** Si la TUI hace render periódico (spinners, relojes en pantalla, progress bars continuas), el idle nunca se dispara y la función esperará hasta `maxDur`. Aumentar `maxDur` o matar el spinner antes de capturar.
- **El binario debe existir en PATH** (o usar path absoluto en `name`). La función devuelve error si `pty.Start` falla.
- **EIO/EOF al cerrar PTY es normal en Linux.** El goroutine lector lo absorbe silenciosamente; no se propaga como error.
- **SIGTERM → SIGKILL.** Al terminar la captura, la función envía SIGTERM al proceso y espera 2s antes de SIGKILL. Procesos que ignoran SIGTERM (como `sleep`) se matan limpiamente.
- **Tamaño de terminal fijado a 40×120.** Suficiente para la mayoría de TUIs. Si el render se ve truncado, el llamador puede hacer `pty.Setsize` adicional después de obtener el ptmx (no expuesto por esta función; para casos avanzados, reimplementar con acceso directo al ptmx).
+74
View File
@@ -0,0 +1,74 @@
package infra
import (
"context"
"strings"
"testing"
"time"
)
func TestPTYCaptureIdle(t *testing.T) {
t.Run("captura output de echo hola", func(t *testing.T) {
if testing.Short() {
t.Skip("skip en modo corto")
}
ctx := context.Background()
out, err := PTYCaptureIdle(ctx, "echo", []string{"hola"}, 100*time.Millisecond, nil, 0, 300*time.Millisecond, 5*time.Second)
if err != nil {
t.Fatalf("error inesperado: %v", err)
}
if !strings.Contains(out, "hola") {
t.Errorf("se esperaba 'hola' en el output, got: %q", out)
}
})
t.Run("input interactivo con cat", func(t *testing.T) {
if testing.Short() {
t.Skip("skip en modo corto: timing sensible en CI")
}
ctx := context.Background()
// cat repite stdin a stdout via PTY; el PTY hace echo del input ademas.
// "ping\r" simula Enter; la palabra "ping" debe aparecer en el output.
out, err := PTYCaptureIdle(
ctx,
"cat", nil,
200*time.Millisecond,
[]string{"ping\r"},
100*time.Millisecond,
500*time.Millisecond,
5*time.Second,
)
if err != nil {
t.Fatalf("error inesperado: %v", err)
}
if !strings.Contains(out, "ping") {
t.Errorf("se esperaba 'ping' en el output, got: %q", out)
}
})
t.Run("timeout duro con sleep 10", func(t *testing.T) {
if testing.Short() {
t.Skip("skip en modo corto: espera ~1s de timeout")
}
ctx := context.Background()
start := time.Now()
_, err := PTYCaptureIdle(
ctx,
"sleep", []string{"10"},
50*time.Millisecond,
nil,
0,
600*time.Millisecond,
1*time.Second,
)
elapsed := time.Since(start)
if err != nil {
// Un error de señal/exit es esperado; no falla el test.
t.Logf("error (esperado al matar sleep): %v", err)
}
// La función debe retornar en menos de 3s, no esperar los 10s del sleep.
if elapsed >= 3*time.Second {
t.Errorf("la función tardó %v, se esperaba < 3s", elapsed)
}
})
}
+176
View File
@@ -0,0 +1,176 @@
package infra
import (
"bytes"
"context"
"fmt"
"os/exec"
"sync"
"syscall"
"time"
"github.com/creack/pty"
)
// PTYCaptureStream launches a command inside a pseudo-terminal (PTY) and
// streams periodic snapshots of the accumulated output through a channel.
// Unlike PTYCaptureIdle, which returns the full output at the end,
// PTYCaptureStream emits the ENTIRE buffer accumulated so far on every
// snapshotInterval tick — allowing callers to observe the terminal render
// while the process is still running.
//
// The returned channel is closed when capture ends (idle/maxDur/ctx cancel).
// The last value sent before closing is always a final snapshot of the
// complete buffer, regardless of tick alignment.
//
// Callers MUST drain the channel or cancel ctx to avoid blocking the
// internal goroutine. Error is returned only if pty.Start fails.
func PTYCaptureStream(
ctx context.Context,
name string,
args []string,
warmup time.Duration,
inputs []string,
stepDelay time.Duration,
snapshotInterval time.Duration,
idle time.Duration,
maxDur time.Duration,
) (<-chan string, error) {
cmd := exec.CommandContext(ctx, name, args...)
ptmx, err := pty.Start(cmd)
if err != nil {
return nil, fmt.Errorf("pty_capture_stream: pty.Start: %w", err)
}
// Set a reasonable terminal size so TUIs render without truncating.
if szErr := pty.Setsize(ptmx, &pty.Winsize{Rows: 40, Cols: 120}); szErr != nil {
// Non-fatal: continue even if resize fails.
_ = szErr
}
var (
mu sync.Mutex
buf bytes.Buffer
lastByte = time.Now()
)
// Reader goroutine: copy PTY output into buf and track last-byte time.
readDone := make(chan struct{})
go func() {
defer close(readDone)
tmp := make([]byte, 4096)
for {
n, rerr := ptmx.Read(tmp)
if n > 0 {
mu.Lock()
buf.Write(tmp[:n])
lastByte = time.Now()
mu.Unlock()
}
if rerr != nil {
// EIO/EOF is normal on Linux when the PTY master is closed
// after the child exits. Not a real error.
return
}
}
}()
ch := make(chan string, 16)
// snapshot returns a copy of the current buffer contents.
snapshot := func() string {
mu.Lock()
s := buf.String()
mu.Unlock()
return s
}
// send emits a snapshot to ch, respecting ctx cancellation.
send := func(s string) bool {
select {
case ch <- s:
return true
case <-ctx.Done():
return false
}
}
// Conducting goroutine: handles warmup, inputs, periodic snapshots,
// idle/maxDur detection, and shutdown.
go func() {
defer func() {
// Shutdown: close PTY master, SIGTERM → SIGKILL, wait reader.
_ = ptmx.Close()
if cmd.Process != nil {
_ = cmd.Process.Signal(syscall.SIGTERM)
killTimer := time.NewTimer(2 * time.Second)
waitCh := make(chan error, 1)
go func() { waitCh <- cmd.Wait() }()
select {
case <-waitCh:
// Process exited cleanly.
case <-killTimer.C:
_ = cmd.Process.Kill()
<-waitCh
}
killTimer.Stop()
}
<-readDone
// Final snapshot — always emitted so consumers get the complete state.
send(snapshot())
close(ch)
}()
start := time.Now()
// Wait for warmup so the TUI/CLI has time to initialize.
select {
case <-time.After(warmup):
case <-ctx.Done():
return
}
// Send inputs one by one with stepDelay between them.
for _, in := range inputs {
if _, werr := ptmx.Write([]byte(in)); werr != nil {
// PTY may have closed already; stop sending.
break
}
select {
case <-time.After(stepDelay):
case <-ctx.Done():
return
}
}
// Main loop: emit snapshots on ticker, cut on idle or maxDur.
ticker := time.NewTicker(snapshotInterval)
defer ticker.Stop()
for {
select {
case <-ticker.C:
// Emit current accumulated snapshot.
if !send(snapshot()) {
return
}
// Check termination conditions.
mu.Lock()
sinceLastByte := time.Since(lastByte)
mu.Unlock()
elapsed := time.Since(start)
if sinceLastByte >= idle || elapsed >= maxDur {
return
}
case <-ctx.Done():
return
}
}
}()
return ch, nil
}
+100
View File
@@ -0,0 +1,100 @@
---
name: pty_capture_stream
kind: function
lang: go
domain: infra
version: "1.0.0"
purity: impure
signature: "func PTYCaptureStream(ctx context.Context, name string, args []string, warmup time.Duration, inputs []string, stepDelay, snapshotInterval, idle, maxDur time.Duration) (<-chan string, error)"
description: "Lanza un comando dentro de un pseudo-terminal (PTY) y emite snapshots acumulativos del buffer de output a través de un canal, en intervalos regulares. Cada snapshot es el contenido RAW completo del PTY hasta ese instante (ANSI incluido). Permite hacer streaming del render de una TUI mientras sigue generando, sin esperar al final."
tags: ["terminal", "pty", "tui", "capture", "automation", "streaming", "terminal-capture"]
uses_functions: []
uses_types: []
returns: []
returns_optional: false
error_type: "error_go_core"
imports:
- "context"
- "time"
- "github.com/creack/pty"
tested: true
tests:
- "snapshots crecientes con pausas"
- "snapshot final siempre presente"
- "timeout duro con sleep 10"
test_file_path: "functions/infra/pty_capture_stream_test.go"
file_path: "functions/infra/pty_capture_stream.go"
params:
- name: ctx
desc: "Contexto de cancelación. Si se cancela, la goroutine interna aborta, emite el snapshot final y cierra el canal."
- name: name
desc: "Nombre o path del ejecutable a lanzar (debe existir en PATH o ser un path absoluto)."
- name: args
desc: "Argumentos posicionales para el ejecutable. Puede ser nil o vacío."
- name: warmup
desc: "Tiempo que se espera después de arrancar el proceso antes de enviar inputs. Permite que la TUI inicialice su render. Típico: 500ms4s para CLIs lentas como claude."
- name: inputs
desc: "Lista de strings a escribir al PTY en secuencia. Incluir '\\r' al final para simular Enter. Puede ser nil si solo se quiere observar sin interactuar."
- name: stepDelay
desc: "Espera entre cada input enviado. Permite que la TUI procese y renderice la respuesta de cada paso antes de enviar el siguiente."
- name: snapshotInterval
desc: "Cada cuánto tiempo se emite un snapshot del buffer acumulado al canal. Controla la frecuencia de actualización del streaming. Valores recomendados: 100ms300ms. Por debajo de 50ms genera mucho ruido y CPU innecesario."
- name: idle
desc: "Tiempo sin nuevos bytes en el PTY que se considera 'respuesta terminada'. Cuando el terminal lleva este tiempo sin actividad, la captura finaliza. Típico: 2s4s para claude, 500ms para CLIs rápidas."
- name: maxDur
desc: "Timeout duro desde el inicio de la función. Garantiza que el canal se cierra aunque la TUI siga emitiendo (spinners, relojes, progress bars). Típico: 60s120s para prompts de claude."
output: "Canal de strings (<-chan string). Cada string es el output RAW acumulado del terminal desde el arranque hasta ese instante, con secuencias ANSI intactas (no deltas). El canal se cierra cuando termina la captura; el último valor enviado antes del cierre es siempre el snapshot final completo. Error si pty.Start falla al arrancar el proceso."
---
## Ejemplo
```go
// Streaming de una sesión claude: ver la respuesta formarse en tiempo real.
ctx, cancel := context.WithTimeout(context.Background(), 2*time.Minute)
defer cancel()
ch, err := PTYCaptureStream(
ctx,
"claude", nil,
4*time.Second, // warmup: esperar que claude cargue
[]string{"hola, responde PONG\r"}, // inputs: enviar mensaje + Enter
300*time.Millisecond, // stepDelay entre inputs
150*time.Millisecond, // snapshotInterval: snapshot cada 150ms
4*time.Second, // idle: cortar cuando lleve 4s sin output
120*time.Second, // maxDur: timeout duro
)
if err != nil {
log.Fatal(err)
}
var lastRender string
for raw := range ch {
// Aplicar VT render para reconstruir la pantalla 2D desde ANSI.
screen := VTRender(raw) // vt_render_go_tui
// Parsear el estado actual de la respuesta de claude.
resp := ParseClaudeTUI(screen) // parse_claude_tui_go_tui
if resp.Response != lastRender {
fmt.Printf("\r[streaming] %s", resp.Response)
lastRender = resp.Response
}
}
// Al salir del for, el canal está cerrado: captura terminada.
fmt.Println("\n[done]", lastRender)
```
## Cuando usarla
Cuando quieras observar el render de una TUI **mientras sigue generando** — por ejemplo, ver la respuesta de `claude` formarse en tiempo real en lugar de esperar al final. Cada snapshot del canal es el estado completo de la pantalla en ese instante; aplica `vt_render_go_tui` + `parse_claude_tui_go_tui` para extraer texto interpretado de cada frame.
Para captura one-shot (solo quieres el output final), usa `pty_capture_idle_go_infra` — más simple, sin goroutina consumidora.
## Gotchas
- **Linux/Unix only.** Usa PTY POSIX (`creack/pty`). No funciona en Windows.
- **Snapshots ACUMULATIVOS, no deltas.** Cada string del canal es el buffer completo desde el inicio, no solo los bytes nuevos. Para calcular lo nuevo en cada tick: `delta := snapshot[len(prevSnapshot):]` — o usa `text_prefix_delta_go_core` si existe. El consumidor decide si quiere el frame completo o el incremento.
- **El consumidor DEBE drenar el canal o cancelar ctx.** Si el canal (capacidad 16) se llena y el consumidor deja de leer, la goroutina interna se bloquea. Patrón seguro: `for range ch {}` en goroutina separada si no se necesita el contenido.
- **La TUI re-renderiza el frame entero.** El buffer crudo crece monotónicamente en bytes, pero el render VT interpretado puede no ser monótono (la TUI puede limpiar la pantalla y re-dibujar). Comparar `VTRender(snapshot)` frame a frame para detectar cambios reales.
- **snapshotInterval < 50ms genera ruido.** El output ANSI de una TUI activa puede cambiar miles de veces por segundo; muestrear muy rápido satura el canal con frames casi idénticos y consume CPU innecesariamente.
- **Idle es heurístico.** Si la TUI tiene spinners o progress bars que emiten bytes continuamente, `idle` nunca se dispara y la función espera hasta `maxDur`. Subir `maxDur` o detener el spinner antes de capturar.
- **EIO/EOF al cerrar PTY es normal en Linux.** El goroutine lector lo absorbe silenciosamente.
- **SIGTERM → SIGKILL.** Al terminar, se envía SIGTERM y se espera 2s antes de SIGKILL.
+120
View File
@@ -0,0 +1,120 @@
package infra
import (
"context"
"strings"
"testing"
"time"
)
func TestPTYCaptureStream(t *testing.T) {
t.Run("snapshots crecientes con pausas", func(t *testing.T) {
if testing.Short() {
t.Skip("skip en modo corto: timing sensible")
}
ctx := context.Background()
// bash -lc imprime A, pausa 0.3s, B, pausa 0.3s, C, pausa 0.3s.
// Con snapshotInterval 100ms e idle 400ms debería recibir varios snapshots
// y el último debe contener A, B y C.
ch, err := PTYCaptureStream(
ctx,
"bash", []string{"-lc", "printf A; sleep 0.3; printf B; sleep 0.3; printf C; sleep 0.3"},
50*time.Millisecond, // warmup
nil, // inputs
0, // stepDelay
100*time.Millisecond, // snapshotInterval
400*time.Millisecond, // idle
5*time.Second, // maxDur
)
if err != nil {
t.Fatalf("error inesperado al arrancar: %v", err)
}
var snapshots []string
for s := range ch {
snapshots = append(snapshots, s)
}
if len(snapshots) < 2 {
t.Errorf("se esperaban >=2 snapshots, got %d", len(snapshots))
}
// Snapshots deben ser acumulativos (monótonos en longitud).
for i := 1; i < len(snapshots); i++ {
if len(snapshots[i]) < len(snapshots[i-1]) {
t.Errorf("snapshot[%d] len=%d < snapshot[%d] len=%d — no acumulativo",
i, len(snapshots[i]), i-1, len(snapshots[i-1]))
}
}
// El último snapshot debe contener A, B y C.
last := snapshots[len(snapshots)-1]
for _, want := range []string{"A", "B", "C"} {
if !strings.Contains(last, want) {
t.Errorf("último snapshot no contiene %q: %q", want, last)
}
}
})
t.Run("snapshot final siempre presente", func(t *testing.T) {
if testing.Short() {
t.Skip("skip en modo corto")
}
ctx := context.Background()
// Output instantáneo; con idle 300ms el canal cierra rápido.
ch, err := PTYCaptureStream(
ctx,
"bash", []string{"-lc", "printf HOLA"},
50*time.Millisecond,
nil,
0,
150*time.Millisecond, // snapshotInterval
300*time.Millisecond, // idle
5*time.Second,
)
if err != nil {
t.Fatalf("error inesperado: %v", err)
}
var last string
for s := range ch {
last = s
}
if !strings.Contains(last, "HOLA") {
t.Errorf("último snapshot no contiene 'HOLA': %q", last)
}
})
t.Run("timeout duro con sleep 10", func(t *testing.T) {
if testing.Short() {
t.Skip("skip en modo corto: espera ~1s de timeout")
}
ctx := context.Background()
start := time.Now()
ch, err := PTYCaptureStream(
ctx,
"sleep", []string{"10"},
50*time.Millisecond,
nil,
0,
200*time.Millisecond, // snapshotInterval
600*time.Millisecond, // idle
1*time.Second, // maxDur duro en 1s
)
if err != nil {
t.Fatalf("error inesperado al arrancar: %v", err)
}
// Drenar completamente el canal.
for range ch {
}
elapsed := time.Since(start)
// La función debe retornar en menos de 3s, no esperar los 10s del sleep.
if elapsed >= 3*time.Second {
t.Errorf("la función tardó %v, se esperaba < 3s", elapsed)
}
})
}
+390
View File
@@ -0,0 +1,390 @@
package tui
import (
"regexp"
"strings"
"unicode"
)
// Unicode markers used by the Claude Code TUI.
const (
markerUser = '' // U+276F — user prompt
markerAssistant = '●' // U+25CF — assistant response / tool call
markerToolResult = '⎿' // U+23BF — tool result
markerProgress = '✻' // U+273B — progress indicator (ignore)
markerBoxTL = '╭' // U+256D — top-left box corner (banner start)
markerBoxBL = '╰' // U+2570 — bottom-left box corner (banner end)
markerBoxBR = '╯' // U+256F — bottom-right box corner (banner end)
markerHRule = '─' // U+2500 — horizontal rule
)
// reToolUse matches "Identifier(anything)" — a tool_use line.
var reToolUse = regexp.MustCompile(`^([A-Za-z_][A-Za-z0-9_]*)\((.*)\)\s*$`)
// reProgress matches Claude's generation status/spinner line by its stable
// signature: "(Ns … tokens" or "esc to interrupt". Used when the line still
// carries that suffix, e.g. "✽ Whatchamacalliting… (2s · ↓ 1 tokens · esc to interrupt)".
var reProgress = regexp.MustCompile(`\(\d+s\b[^)]*tokens?\b|esc to interrupt`)
// reSpinner matches the spinner line by STRUCTURE rather than by its (infinite,
// ever-changing) gerund word: a non-alphanumeric glyph (✻ ✽ ✢ ✶ ✺ …) followed by
// a single word and a horizontal ellipsis, e.g. "✽ Forging…" or "✶ Puzzling…".
// This catches early frames that don't yet show the "(Ns · tokens)" suffix. The
// caller guards known turn markers (●//⎿) so a legitimate answer ending in "…"
// is not misclassified.
var reSpinner = regexp.MustCompile(`^\s*[^\p{L}\p{N}\s]\s+\p{L}[\p{L}'\-]*…`)
// ClaudeTurnRole classifies each turn/block extracted from the screen.
type ClaudeTurnRole string
const (
// ClaudeTurnUser is a message typed by the user (line starting with " ").
ClaudeTurnUser ClaudeTurnRole = "user"
// ClaudeTurnAssistant is a response block from the assistant.
ClaudeTurnAssistant ClaudeTurnRole = "assistant"
// ClaudeTurnToolUse is a tool invocation "● ToolName(args)".
ClaudeTurnToolUse ClaudeTurnRole = "tool_use"
// ClaudeTurnToolResult is a result line "⎿ ..." following a tool_use.
ClaudeTurnToolResult ClaudeTurnRole = "tool_result"
)
// ClaudeTurn is a single conversation block extracted from the rendered screen.
type ClaudeTurn struct {
Role ClaudeTurnRole `json:"role"`
Text string `json:"text"` // textual content (multiline joined with \n)
ToolName string `json:"tool_name,omitempty"` // only for tool_use
}
// ClaudeTUIParse is the result of parsing one captured Claude TUI screen.
type ClaudeTUIParse struct {
Turns []ClaudeTurn `json:"turns"` // all visible turns in order
Answer string `json:"answer"` // assistant reply to the last user turn (like `claude -p`)
}
// ParseClaudeTUI parses the rendered text of a Claude Code TUI screen and
// extracts the conversation turns and the final assistant answer.
//
// The screen is expected to be the output of VTRender applied to a PTY
// capture of the claude CLI. Heuristics handle the welcome banner, status
// bar, progress lines and multi-line continuations.
func ParseClaudeTUI(screen string) ClaudeTUIParse {
lines := strings.Split(screen, "\n")
// --- Step 1: strip the welcome banner (box drawn with ╭...╰/╯) ---
lines = stripBanner(lines)
// --- Step 2: strip the status bar at the bottom ---
lines = stripStatusBar(lines)
// --- Step 3: collect turns from the remaining lines ---
turns := extractTurns(lines)
// --- Step 4: compute Answer from turns ---
answer := computeAnswer(turns)
return ClaudeTUIParse{Turns: turns, Answer: answer}
}
// stripBanner removes the welcome banner block from the top of the lines
// slice. The banner is a Unicode box that starts with a line containing ╭
// and ends with a line containing ╰ or ╯.
func stripBanner(lines []string) []string {
// Find a banner start (╭) in the first ~15 lines.
startIdx := -1
for i := 0; i < len(lines) && i < 15; i++ {
if strings.ContainsRune(lines[i], markerBoxTL) {
startIdx = i
break
}
}
if startIdx < 0 {
return lines
}
// Find the matching close (╰ or ╯) after the start.
for i := startIdx; i < len(lines); i++ {
if strings.ContainsRune(lines[i], markerBoxBL) || strings.ContainsRune(lines[i], markerBoxBR) {
return lines[i+1:]
}
}
return lines
}
// isHRule returns true when the line consists mostly of ─ (U+2500) characters
// — at least 40 of them and the line has no other significant content.
func isHRule(line string) bool {
count := 0
for _, r := range line {
if r == markerHRule {
count++
}
}
return count >= 40
}
// isStatusBarLine returns true for lines that belong to the Claude status bar
// (CTX:, IN:, OUT:, Total:, Limits:, $, "← for agents", etc.).
func isStatusBarLine(line string) bool {
trimmed := strings.TrimSpace(line)
if trimmed == "" {
return false
}
prefixes := []string{
"CTX:", "IN:", "OUT:", "Total:", "Limits:", "$", "← for agents",
}
for _, p := range prefixes {
if strings.Contains(trimmed, p) {
return true
}
}
return false
}
// stripStatusBar removes the status bar at the bottom of the lines slice.
// Strategy: scan from the bottom upward. The footer looks like:
//
// <hrule>
// (empty prompt)
// <hrule>
// <status lines with CTX: / $0.xxx / ← for agents ...>
//
// We look for the LAST hrule that is followed by an empty-prompt line and
// another hrule, and discard everything from that hrule onward.
// Additionally, any trailing status-bar-flavored lines are dropped first.
func stripStatusBar(lines []string) []string {
if len(lines) == 0 {
return lines
}
// Trim trailing blank lines first.
end := len(lines)
for end > 0 && strings.TrimSpace(lines[end-1]) == "" {
end--
}
lines = lines[:end]
// Remove explicit status-bar lines from the bottom.
for len(lines) > 0 && isStatusBarLine(lines[len(lines)-1]) {
lines = lines[:len(lines)-1]
}
// Now find the pattern: hrule / empty- / hrule and cut there.
// Scan from the bottom upward.
for i := len(lines) - 1; i >= 2; i-- {
if !isHRule(lines[i]) {
continue
}
// Check that lines[i-1] is the empty prompt "" (optional surrounding spaces).
mid := strings.TrimSpace(lines[i-1])
if mid != string([]rune{markerUser}) && mid != string([]rune{markerUser, ' '}) {
// Also allow a completely empty line (prompt area can be blank).
if mid != "" {
continue
}
}
// Check lines[i-2] is also an hrule.
if isHRule(lines[i-2]) {
// Cut from lines[i-2] onward.
lines = lines[:i-2]
break
}
}
// Trim trailing blank lines again after stripping.
for len(lines) > 0 && strings.TrimSpace(lines[len(lines)-1]) == "" {
lines = lines[:len(lines)-1]
}
return lines
}
// firstRune returns the first non-space rune in s, or 0 if s is blank.
func firstRune(s string) rune {
for _, r := range s {
if !unicode.IsSpace(r) {
return r
}
}
return 0
}
// isMarkerLine returns true when the line starts with one of the recognised
// turn markers (, ●, ⎿, ✻).
func isMarkerLine(line string) bool {
r := firstRune(line)
return r == markerUser || r == markerAssistant || r == markerToolResult || r == markerProgress
}
// isProgressLine reports whether the line is a Claude generation status/spinner
// line (the animated "✻/✽ Word… (Ns · ↓ N tokens · esc to interrupt)" indicator).
// The glyph and the gerund word change on every frame, so it is detected by
// structure/signature, never by the specific word. These lines are noise and must
// never be folded into an assistant answer — critical when capturing frames
// mid-generation (streaming), where a different "loading" word appears each tick.
func isProgressLine(line string) bool {
r := firstRune(line)
if r == markerProgress {
return true
}
// Known turn markers are never progress, even if they end in "…".
if r == markerUser || r == markerAssistant || r == markerToolResult {
return false
}
return reProgress.MatchString(line) || reSpinner.MatchString(line)
}
// isBreakLine reports whether the line should end an assistant/user/tool
// continuation: either a turn marker or a progress/spinner line.
func isBreakLine(line string) bool {
return isMarkerLine(line) || isProgressLine(line)
}
// textAfterMarker returns the text that follows the first occurrence of
// marker in line, trimmed of leading spaces.
func textAfterMarker(line string, marker rune) string {
idx := strings.IndexRune(line, marker)
if idx < 0 {
return ""
}
rest := line[idx+len(string(marker)):]
return strings.TrimLeft(rest, " ")
}
// extractTurns scans lines and groups them into ClaudeTurn slices.
func extractTurns(lines []string) []ClaudeTurn {
var turns []ClaudeTurn
i := 0
for i < len(lines) {
line := lines[i]
// Progress/spinner lines are noise in any position — skip early so they
// are never folded into an assistant continuation (matters for streaming).
if isProgressLine(line) {
i++
continue
}
r := firstRune(line)
switch r {
case markerProgress:
// ✻ lines are noise — skip (also covered by isProgressLine above).
i++
case markerUser:
text := textAfterMarker(line, markerUser)
if text == "" {
// Empty prompt — skip.
i++
continue
}
// Collect continuation lines (indented, non-marker, non-empty).
i++
for i < len(lines) {
cont := lines[i]
if isBreakLine(cont) || strings.TrimSpace(cont) == "" {
break
}
text += "\n" + strings.TrimRight(cont, " ")
i++
}
turns = append(turns, ClaudeTurn{Role: ClaudeTurnUser, Text: strings.TrimRight(text, " ")})
case markerAssistant:
body := textAfterMarker(line, markerAssistant)
i++
// Determine if this is a tool_use or assistant text.
if m := reToolUse.FindStringSubmatch(body); m != nil {
// tool_use — do NOT collect continuation lines.
turns = append(turns, ClaudeTurn{
Role: ClaudeTurnToolUse,
Text: body,
ToolName: m[1],
})
} else {
// assistant text — collect continuation lines.
for i < len(lines) {
cont := lines[i]
if isBreakLine(cont) {
break
}
trimmed := strings.TrimSpace(cont)
if trimmed == "" {
// A single blank line may separate paragraphs; peek ahead.
// If the next non-blank line is also a continuation, keep it.
j := i + 1
for j < len(lines) && strings.TrimSpace(lines[j]) == "" {
j++
}
if j < len(lines) && !isBreakLine(lines[j]) {
// Include the blank line(s) as paragraph separator.
body += "\n"
i = j
continue
}
break
}
body += "\n" + strings.TrimRight(cont, " ")
i++
}
turns = append(turns, ClaudeTurn{
Role: ClaudeTurnAssistant,
Text: strings.TrimRight(body, " \n"),
})
}
case markerToolResult:
text := textAfterMarker(line, markerToolResult)
// Also accept └ as alias (some terminals substitute).
if text == "" {
text = textAfterMarker(line, '└')
}
i++
// Collect continuation lines for the tool result.
for i < len(lines) {
cont := lines[i]
if isBreakLine(cont) || strings.TrimSpace(cont) == "" {
break
}
text += "\n" + strings.TrimRight(cont, " ")
i++
}
turns = append(turns, ClaudeTurn{
Role: ClaudeTurnToolResult,
Text: strings.TrimRight(text, " "),
})
default:
// Blank or unrecognised line — skip.
i++
}
}
return turns
}
// computeAnswer finds the last user turn and concatenates all assistant
// (non-tool_use, non-tool_result) turns that follow it.
// If there is no user turn, concatenates all assistant turns.
func computeAnswer(turns []ClaudeTurn) string {
lastUserIdx := -1
for i, t := range turns {
if t.Role == ClaudeTurnUser {
lastUserIdx = i
}
}
var parts []string
start := 0
if lastUserIdx >= 0 {
start = lastUserIdx + 1
}
for _, t := range turns[start:] {
if t.Role == ClaudeTurnAssistant {
parts = append(parts, t.Text)
}
}
return strings.TrimSpace(strings.Join(parts, "\n"))
}
+67
View File
@@ -0,0 +1,67 @@
---
name: parse_claude_tui
kind: function
lang: go
domain: tui
version: "1.0.0"
purity: pure
signature: "func ParseClaudeTUI(screen string) ClaudeTUIParse"
description: "Parsea el texto renderizado de la pantalla de la TUI de Claude Code y extrae los turnos de la conversación (user, assistant, tool_use, tool_result) y la respuesta final del asistente. Equivalente a lo que devolvería `claude -p` pero operando sobre el render visual."
tags: [terminal-capture, claude, tui, parse, conversation]
uses_functions:
- vt_render_go_tui
uses_types:
- claude_turn_go_tui
- claude_tui_parse_go_tui
returns:
- claude_tui_parse_go_tui
returns_optional: false
error_type: ""
imports: []
params:
- name: screen
desc: "Texto renderizado de la pantalla de la TUI de Claude Code, producido por VTRender(raw, rows, cols). Debe incluir el contenido visible completo: banner opcional, conversación y status bar opcional."
output: "ClaudeTUIParse con los turnos visibles en orden (Role, Text, ToolName) y Answer — la concatenación de los bloques assistant que siguen al último turno user, equivalente al output de `claude -p`."
tested: true
tests:
- "golden screen — banner + status bar + single Q&A"
- "multiline assistant response"
- "tool_use + tool_result + final assistant text"
- "multi-turn — answer from last user only"
- "no banner no status bar — minimal screen"
- "determinism — same input produces same output"
test_file_path: "functions/tui/parse_claude_tui_test.go"
file_path: "functions/tui/parse_claude_tui.go"
---
## Ejemplo
```go
// Pipeline completo: PTY capture → VTRender → ParseClaudeTUI → usar .Answer
import (
"fmt"
"fn-registry/functions/infra"
"fn-registry/functions/tui"
)
raw, _ := infra.PtyCaptureIdle("claude", []string{}, 40, 220, 8000)
screen := tui.VTRender(raw, 40, 220)
result := tui.ParseClaudeTUI(screen)
fmt.Println(result.Answer) // imprime la respuesta final del asistente
for _, turn := range result.Turns {
fmt.Printf("[%s] %s\n", turn.Role, turn.Text)
}
```
## Cuando usarla
Cuando captures la TUI de `claude` con `pty_capture_idle_go_infra` + `vt_render_go_tui` y necesites extraer la respuesta como dato estructurado (equivalente a `claude -p`) en vez de procesar el render visual crudo. Úsala para agentes que lanzan `claude` como subproceso TUI y quieren leer la respuesta sin requerir modo headless.
## Gotchas
- **Heurístico y dependiente del layout de la TUI de Claude Code**: si Claude cambia los marcadores (``, `●`, `⎿`, `✻`) o el formato del banner/status-bar, el parser puede dejar de funcionar sin aviso.
- **Solo ve lo visible en el grid**: `VTRender` reconstruye únicamente lo que cabe en el terminal emulado (rows × cols). Respuestas largas que hacen scroll hacia arriba se truncan por arriba — no hay scrollback. Para respuestas largas, aumentar `rows` en `VTRender` o usar `claude -p` directamente.
- **tool_use/tool_result best-effort**: la TUI colapsa algunos bloques de herramientas. Los `ToolName` y textos de `tool_result` pueden quedar incompletos si la TUI los trunca con `…`.
- **Answer asume captura post-respuesta**: `PtyCaptureIdle` debe haberse disparado DESPUÉS de que la respuesta terminó de renderizarse (el spinner `✻` desapareció). Si se captura durante el streaming, `Answer` puede estar incompleto.
+214
View File
@@ -0,0 +1,214 @@
package tui
import (
"testing"
)
// goldenScreen is the exact sample screen from the spec.
const goldenScreen = `╭─── Claude Code v2.1.161 ─────────────────────────────────────────────────────────────────────────────────────────────╮
│ │ Tips for getting started │
│ Welcome back Enmanuel! │ Run /init to create a CLAUDE.md file with instructions for Cla… │
│ │ ─────────────────────────────────────────────────────────────── │
│ ▐▛███▜▌ │ What's new │
│ ▝▜█████▛▘ │ ` + "`OTEL_RESOURCE_ATTRIBUTES`" + ` values are now included as labels o… │
│ ▘▘ ▝▝ │ ` + "`claude agents`" + ` rows now show ` + "`done/total`" + ` before the detail w… │
│ Opus 4.8 (1M context) with xh… · Claude Max · │ ` + "`/mcp`" + ` now collapses claude.ai connectors you've never signed … │
│ gutierenmanuel15@gmail.com's Organization │ /release-notes for more │
│ ~/fn_registry │ │
╰──────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╯
responde unicamente con la palabra PONG, sin explicaciones
● PONG
✻ Crunched for 2s
────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────
────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────
Opus 4.8 (1M context) │ CTX: █░░░░░░░░░ 11% (107k/1.0M) │ IN:6k OUT:5 (cache:17k) │ ⎇ master [~4 ?28 ↑1] │ 22:26
$0.565 │ +0/-0 │ Total: ↓107k/↑5 │ Limits: 5h:6% →02:40 │ 7d:11% →Sun 17:00 │ ⏱ 7s │ ~/fn_registry
← for agents`
func TestParseClaudeTUI(t *testing.T) {
t.Run("golden screen — banner + status bar + single Q&A", func(t *testing.T) {
got := ParseClaudeTUI(goldenScreen)
if got.Answer != "PONG" {
t.Errorf("Answer = %q, want %q", got.Answer, "PONG")
}
if len(got.Turns) != 2 {
t.Errorf("len(Turns) = %d, want 2", len(got.Turns))
for i, turn := range got.Turns {
t.Logf(" Turns[%d]: role=%s text=%q", i, turn.Role, turn.Text)
}
return
}
if got.Turns[0].Role != ClaudeTurnUser {
t.Errorf("Turns[0].Role = %q, want %q", got.Turns[0].Role, ClaudeTurnUser)
}
wantUserText := "responde unicamente con la palabra PONG, sin explicaciones"
if got.Turns[0].Text != wantUserText {
t.Errorf("Turns[0].Text = %q, want %q", got.Turns[0].Text, wantUserText)
}
if got.Turns[1].Role != ClaudeTurnAssistant {
t.Errorf("Turns[1].Role = %q, want %q", got.Turns[1].Role, ClaudeTurnAssistant)
}
if got.Turns[1].Text != "PONG" {
t.Errorf("Turns[1].Text = %q, want %q", got.Turns[1].Text, "PONG")
}
})
t.Run("multiline assistant response", func(t *testing.T) {
screen := ` explica brevemente
● linea uno
linea dos`
got := ParseClaudeTUI(screen)
if len(got.Turns) != 2 {
t.Fatalf("len(Turns) = %d, want 2; turns: %+v", len(got.Turns), got.Turns)
}
wantText := "linea uno\nlinea dos"
if got.Turns[1].Text != wantText {
t.Errorf("Turns[1].Text = %q, want %q", got.Turns[1].Text, wantText)
}
if !contains(got.Answer, "linea uno") || !contains(got.Answer, "linea dos") {
t.Errorf("Answer %q should contain both continuation lines", got.Answer)
}
})
t.Run("tool_use + tool_result + final assistant text", func(t *testing.T) {
screen := ` pregunta
● Read(main.go)
⎿ Read 50 lines
● aqui esta el resumen`
got := ParseClaudeTUI(screen)
if len(got.Turns) != 4 {
t.Fatalf("len(Turns) = %d, want 4; turns: %+v", len(got.Turns), got.Turns)
}
if got.Turns[0].Role != ClaudeTurnUser {
t.Errorf("Turns[0].Role = %q", got.Turns[0].Role)
}
if got.Turns[1].Role != ClaudeTurnToolUse {
t.Errorf("Turns[1].Role = %q, want tool_use", got.Turns[1].Role)
}
if got.Turns[1].ToolName != "Read" {
t.Errorf("Turns[1].ToolName = %q, want Read", got.Turns[1].ToolName)
}
if got.Turns[2].Role != ClaudeTurnToolResult {
t.Errorf("Turns[2].Role = %q, want tool_result", got.Turns[2].Role)
}
if got.Turns[3].Role != ClaudeTurnAssistant {
t.Errorf("Turns[3].Role = %q, want assistant", got.Turns[3].Role)
}
// Answer must be ONLY the assistant text, not the tool_use.
if got.Answer != "aqui esta el resumen" {
t.Errorf("Answer = %q, want %q", got.Answer, "aqui esta el resumen")
}
})
t.Run("multi-turn — answer from last user only", func(t *testing.T) {
screen := ` primera pregunta
● primera respuesta
segunda pregunta
● segunda respuesta`
got := ParseClaudeTUI(screen)
if len(got.Turns) != 4 {
t.Fatalf("len(Turns) = %d, want 4; turns: %+v", len(got.Turns), got.Turns)
}
if got.Answer != "segunda respuesta" {
t.Errorf("Answer = %q, want %q", got.Answer, "segunda respuesta")
}
})
t.Run("no banner no status bar — minimal screen", func(t *testing.T) {
screen := " hola\n\n● mundo"
got := ParseClaudeTUI(screen)
if len(got.Turns) != 2 {
t.Fatalf("len(Turns) = %d, want 2; turns: %+v", len(got.Turns), got.Turns)
}
if got.Answer != "mundo" {
t.Errorf("Answer = %q, want %q", got.Answer, "mundo")
}
})
t.Run("determinism — same input produces same output", func(t *testing.T) {
first := ParseClaudeTUI(goldenScreen)
second := ParseClaudeTUI(goldenScreen)
if first.Answer != second.Answer {
t.Errorf("non-deterministic: %q != %q", first.Answer, second.Answer)
}
if len(first.Turns) != len(second.Turns) {
t.Errorf("non-deterministic turns count: %d != %d", len(first.Turns), len(second.Turns))
}
})
}
// TestParseClaudeTUI_Spinner verifies that the generation spinner — which shows a
// DIFFERENT random gerund word on every frame ("Whatchamacalliting", "Forging",
// "Puzzling", "Crunched"...) — is never folded into the answer, regardless of the
// word, the glyph, or whether the "(Ns · tokens)" suffix is present yet.
func TestParseClaudeTUI_Spinner(t *testing.T) {
cases := []struct {
name string
screen string
want string
}{
{
name: "spinner with tokens suffix glued after answer",
screen: " di PONG\n\n● PONG\n\n✽ Whatchamacalliting… (2s · ↓ 1 tokens · esc to interrupt)\n",
want: "PONG",
},
{
name: "spinner early frame, no suffix yet, different word",
screen: " di HOLA\n\n● HOLA\n\n✶ Puzzling…\n",
want: "HOLA",
},
{
name: "classic crunched line",
screen: " x\n\n● respuesta\n\n✻ Crunched for 4s\n",
want: "respuesta",
},
{
name: "spinner BEFORE the answer block (mid-generation snapshot)",
screen: " pregunta\n\n✽ Forging… (1s · ↑ 3 tokens · esc to interrupt)\n\n● respuesta parcial\n",
want: "respuesta parcial",
},
{
name: "assistant line ending in ellipsis is NOT treated as spinner",
screen: " x\n\n● la historia continua…\n",
want: "la historia continua…",
},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
got := ParseClaudeTUI(tc.screen)
if got.Answer != tc.want {
t.Errorf("Answer = %q, want %q", got.Answer, tc.want)
}
})
}
}
func contains(s, sub string) bool {
return len(sub) == 0 || (len(s) >= len(sub) && (s == sub ||
len(s) > 0 && containsStr(s, sub)))
}
func containsStr(s, sub string) bool {
for i := 0; i <= len(s)-len(sub); i++ {
if s[i:i+len(sub)] == sub {
return true
}
}
return false
}
+54
View File
@@ -0,0 +1,54 @@
package tui
import (
"strings"
"github.com/hinshun/vt10x"
)
// VTRender emulates a terminal of size cols×rows, feeds raw into it,
// and returns the resulting screen as plain text preserving the visual layout.
//
// Unlike strip_ansi which removes escape sequences from sequential output,
// VTRender correctly handles TUIs that use absolute cursor positioning
// (ESC[row;colH, ESC[colG, etc.) by maintaining a 2D grid and reconstructing
// real spaces between columns.
//
// Defaults: rows<=0 → 40, cols<=0 → 120.
// Trailing spaces on each line are trimmed. Trailing empty lines are removed.
func VTRender(raw string, rows, cols int) string {
if rows <= 0 {
rows = 40
}
if cols <= 0 {
cols = 120
}
// Create a fresh terminal emulator for each call — no shared state.
term := vt10x.New(vt10x.WithSize(cols, rows))
term.Write([]byte(raw)) //nolint:errcheck // Write on vt10x never returns a meaningful error
// String() returns all rows joined by '\n', one row per terminal line.
// Each row is exactly `cols` runes wide (padded with NUL/space for empty cells).
raw = term.String()
lines := strings.Split(raw, "\n")
// Trim trailing spaces from every line (cells that were never written
// contain NUL '\x00' in some versions, so we trim both NUL and space).
for i, line := range lines {
// Replace NUL characters (unwritten cells) with spaces first.
line = strings.ReplaceAll(line, "\x00", " ")
lines[i] = strings.TrimRight(line, " ")
}
// Remove trailing empty lines — the TUI probably only used the top portion
// of the grid. Keep intermediate empty lines (real visual separators).
last := len(lines) - 1
for last >= 0 && lines[last] == "" {
last--
}
lines = lines[:last+1]
return strings.Join(lines, "\n")
}
+64
View File
@@ -0,0 +1,64 @@
---
name: vt_render
kind: function
lang: go
domain: tui
version: "1.0.0"
purity: pure
signature: "func VTRender(raw string, rows, cols int) string"
description: "Emula un terminal virtual de tamaño cols×rows, alimenta raw (stream con secuencias ANSI/VT100 incluyendo posicionamiento absoluto de cursor) y devuelve el estado final de la pantalla como texto plano que preserva el layout visual. A diferencia de strip_ansi, reconstruye espacios reales entre columnas posicionadas con movimientos de cursor absolutos."
tags: ["terminal", "vt100", "tui", "render", "ansi", "screen", "terminal-capture"]
uses_functions: []
uses_types: []
returns: []
returns_optional: false
error_type: ""
imports:
- "github.com/hinshun/vt10x"
- "strings"
tested: true
tests:
- "layout absoluto basico A y B separados por movimiento de cursor"
- "dos palabras separadas por movimiento de columna no aparecen pegadas"
- "texto multilinea simple con CRLF"
- "trim de filas vacias al final de grid grande"
- "determinismo misma entrada misma salida"
- "defaults rows y cols al pasar cero"
test_file_path: "functions/tui/vt_render_test.go"
file_path: "functions/tui/vt_render.go"
params:
- name: raw
desc: "Stream crudo de bytes de terminal, con secuencias de escape ANSI/VT100 intactas (colores, cursor moves, borrados de línea, scroll). Típicamente la salida de pty_capture_idle_go_infra."
- name: rows
desc: "Número de filas del terminal virtual. Debe coincidir con el tamaño de PTY usado al capturar. Si <=0 usa 40 como default."
- name: cols
desc: "Número de columnas del terminal virtual. Debe coincidir con el ancho de PTY usado al capturar. Si <=0 usa 120 como default."
output: "Texto plano multilínea con el layout visual de la pantalla: espacios reales entre columnas, sin trailing spaces por línea, sin filas vacías finales. Las líneas vacías intermedias se conservan (son separación visual real)."
---
## Ejemplo
```go
// Capturar output crudo de una TUI (ej. claude CLI) con el PTY del mismo tamaño.
raw, _ := pty_capture_idle("claude", []string{"--help"}, 40, 120, 2*time.Second, 10*time.Second)
// Renderizar el grid final como texto plano.
screen := tui.VTRender(raw, 40, 120)
fmt.Println(screen)
// Salida: texto con columnas alineadas, igual a lo que se vería en pantalla.
// Ejemplo real: "foo bar" si foo y bar estaban separados por ESC[10G.
```
## Cuando usarla
Úsala cuando captures el output crudo de una TUI con layout absoluto (claude CLI, htop, dialog, ncurses) y `strip_ansi_go_core` te deje las palabras pegadas (ej. "2newMCPservers"). Contrasta con `strip_ansi_go_core` y `strip_ansi_go_tui`, que sirven para output secuencial tipo logs donde no hay movimientos de cursor absolutos. Si el stream tiene `ESC[row;colH` o `ESC[colG`, este es el correcto.
Librería emuladora usada: `github.com/hinshun/vt10x` (vt10x v0.0.0-20220301184237-5011da428d02). Implementa VT10x completo sin CGO. API: `vt10x.New(vt10x.WithSize(cols, rows))` + `Write([]byte)` + `String()`.
## Gotchas
- **Tamaño debe coincidir**: rows×cols deben ser iguales a los que se usaron al capturar (pty_capture_idle usa 40×120 por defecto). Si no coinciden, el wrapping del texto no cuadra y las columnas se descuadran.
- **Solo texto, sin color**: la función vuelca únicamente los caracteres (rune de cada celda). Los atributos de color se pierden — es texto plano.
- **Solo estado final del grid**: si la TUI hizo scroll durante su ejecución, solo se ve el estado final de las 40 filas visibles. El historial de scroll no está disponible.
- **Emojis y caracteres de doble ancho**: algunos caracteres Unicode (emojis, CJK) ocupan 2 columnas visualmente pero solo 1 celda en el grid de vt10x, lo que puede descuadrar columnas en TUIs que los usan.
- **NUL en celdas vacías**: las celdas no escritas contienen `\x00` en algunas versiones del emulador. La función los reemplaza por espacio antes del trim, pero si el raw contiene NUL intencional, se trataría como espacio.
+114
View File
@@ -0,0 +1,114 @@
package tui
import (
"strings"
"testing"
)
func TestVTRender(t *testing.T) {
t.Run("layout absoluto basico A y B separados por movimiento de cursor", func(t *testing.T) {
// ESC[1;5H mueve el cursor a fila 1 columna 5 (1-indexed).
// Resultado esperado: 'A' en col 1, espacios, 'B' en col 5.
out := VTRender("A\x1b[1;5HB", 2, 10)
lines := strings.Split(out, "\n")
if len(lines) == 0 {
t.Fatalf("resultado vacio")
}
first := lines[0]
if len(first) < 5 {
t.Fatalf("linea demasiado corta: %q", first)
}
if first[0] != 'A' {
t.Errorf("esperaba 'A' en columna 0, got %q en linea %q", string(first[0]), first)
}
if first[4] != 'B' {
t.Errorf("esperaba 'B' en columna 4 (0-indexed), got %q en linea %q", string(first[4]), first)
}
// Verificar que hay espacios entre A y B (no están pegadas).
if strings.Contains(first, "AB") {
t.Errorf("A y B estan pegadas en %q, deberían estar separadas", first)
}
})
t.Run("dos palabras separadas por movimiento de columna no aparecen pegadas", func(t *testing.T) {
// ESC[10G mueve el cursor a la columna 10 (1-indexed) de la línea actual.
out := VTRender("foo\x1b[10Gbar", 2, 20)
lines := strings.Split(out, "\n")
if len(lines) == 0 {
t.Fatalf("resultado vacio")
}
first := lines[0]
if strings.Contains(first, "foobar") {
t.Errorf("foo y bar estan pegadas: %q — esperaba espacios entre ellas", first)
}
if !strings.Contains(first, "foo") {
t.Errorf("no encontre 'foo' en %q", first)
}
if !strings.Contains(first, "bar") {
t.Errorf("no encontre 'bar' en %q", first)
}
// foo en col 0-2, bar en col 9-11 (columna 10 es 0-indexed 9).
if len(first) < 12 {
t.Fatalf("linea demasiado corta para verificar: %q", first)
}
// Debe haber al menos un espacio entre foo y bar.
fooEnd := strings.Index(first, "foo") + 3
barStart := strings.Index(first, "bar")
if barStart <= fooEnd {
t.Errorf("bar empieza en %d pero foo termina en %d — sin separacion en %q", barStart, fooEnd, first)
}
})
t.Run("texto multilinea simple con CRLF", func(t *testing.T) {
out := VTRender("linea1\r\nlinea2", 5, 40)
if !strings.Contains(out, "linea1") {
t.Errorf("no encontre 'linea1' en %q", out)
}
if !strings.Contains(out, "linea2") {
t.Errorf("no encontre 'linea2' en %q", out)
}
lines := strings.Split(out, "\n")
// linea1 y linea2 deben estar en líneas distintas.
found1, found2 := -1, -1
for i, l := range lines {
if strings.Contains(l, "linea1") {
found1 = i
}
if strings.Contains(l, "linea2") {
found2 = i
}
}
if found1 == found2 {
t.Errorf("linea1 y linea2 estan en la misma linea (%d) de la salida: %q", found1, out)
}
})
t.Run("trim de filas vacias al final de grid grande", func(t *testing.T) {
// Input corto en un grid de 40 filas — no debe producir 40 lineas.
out := VTRender("hola", 40, 120)
count := strings.Count(out, "\n")
if count >= 3 {
t.Errorf("demasiadas lineas (%d) para 'hola' en grid de 40 filas: %q", count, out)
}
if !strings.Contains(out, "hola") {
t.Errorf("no encontre 'hola' en %q", out)
}
})
t.Run("determinismo misma entrada misma salida", func(t *testing.T) {
input := "foo\x1b[10Gbar\r\n\x1b[2;1Hbaz"
out1 := VTRender(input, 10, 40)
out2 := VTRender(input, 10, 40)
if out1 != out2 {
t.Errorf("resultados distintos:\nout1=%q\nout2=%q", out1, out2)
}
})
t.Run("defaults rows y cols al pasar cero", func(t *testing.T) {
// Verificar que no entra en pánico con valores <= 0.
out := VTRender("test", 0, 0)
if !strings.Contains(out, "test") {
t.Errorf("no encontre 'test' con defaults (rows=0,cols=0): %q", out)
}
})
}
+2
View File
@@ -40,6 +40,7 @@ require (
github.com/clipperhouse/displaywidth v0.9.0 // indirect
github.com/clipperhouse/stringish v0.1.1 // indirect
github.com/clipperhouse/uax29/v2 v2.5.0 // indirect
github.com/creack/pty v1.1.24 // indirect
github.com/danieljoos/wincred v1.2.3 // indirect
github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f // indirect
github.com/go-faster/city v1.0.1 // indirect
@@ -49,6 +50,7 @@ require (
github.com/godbus/dbus/v5 v5.2.2 // indirect
github.com/google/flatbuffers v25.1.24+incompatible // indirect
github.com/gorilla/css v1.0.1 // indirect
github.com/hinshun/vt10x v0.0.0-20220301184237-5011da428d02 // indirect
github.com/jackc/pgpassfile v1.0.0 // indirect
github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 // indirect
github.com/jackc/puddle/v2 v2.2.2 // indirect
+4
View File
@@ -40,6 +40,8 @@ github.com/clipperhouse/stringish v0.1.1 h1:+NSqMOr3GR6k1FdRhhnXrLfztGzuG+VuFDfa
github.com/clipperhouse/stringish v0.1.1/go.mod h1:v/WhFtE1q0ovMta2+m+UbpZ+2/HEXNWYXQgCt4hdOzA=
github.com/clipperhouse/uax29/v2 v2.5.0 h1:x7T0T4eTHDONxFJsL94uKNKPHrclyFI0lm7+w94cO8U=
github.com/clipperhouse/uax29/v2 v2.5.0/go.mod h1:Wn1g7MK6OoeDT0vL+Q0SQLDz/KpfsVRgg6W7ihQeh4g=
github.com/creack/pty v1.1.24 h1:bJrF4RRfyJnbTJqzRLHzcGaZK1NeM5kTC9jGgovnR1s=
github.com/creack/pty v1.1.24/go.mod h1:08sCNb52WyoAwi2QDyzUCTgcvVFhUzewun7wtTfvcwE=
github.com/danieljoos/wincred v1.2.3 h1:v7dZC2x32Ut3nEfRH+vhoZGvN72+dQ/snVXo/vMFLdQ=
github.com/danieljoos/wincred v1.2.3/go.mod h1:6qqX0WNrS4RzPZ1tnroDzq9kY3fu1KwE7MRLQK4X0bs=
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
@@ -74,6 +76,8 @@ github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
github.com/gorilla/css v1.0.1 h1:ntNaBIghp6JmvWnxbZKANoLyuXTPZ4cAMlo6RyhlbO8=
github.com/gorilla/css v1.0.1/go.mod h1:BvnYkspnSzMmwRK+b8/xgNPLiIuNZr6vbZBTPQ2A3b0=
github.com/hinshun/vt10x v0.0.0-20220301184237-5011da428d02 h1:AgcIVYPa6XJnU3phs104wLj8l5GEththEw6+F79YsIY=
github.com/hinshun/vt10x v0.0.0-20220301184237-5011da428d02/go.mod h1:Q48J4R4DvxnHolD5P8pOtXigYlRuPLGl6moFx3ulM68=
github.com/jackc/pgpassfile v1.0.0 h1:/6Hmqy13Ss2zCq62VdNG8tM1wchn8zjSGOBJ6icpsIM=
github.com/jackc/pgpassfile v1.0.0/go.mod h1:CEx0iS5ambNFdcRtxPj5JhEz+xB6uRky5eyVu/W2HEg=
github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 h1:iCEnooe7UlwOQYpKFhBabPMi4aNAfoODPEFNiAnClxo=
+6
View File
@@ -0,0 +1,6 @@
.gradle/
build/
local.properties
*.iml
.idea/
.cxx/
+43
View File
@@ -0,0 +1,43 @@
plugins {
id("com.android.library") version "8.4.0"
id("org.jetbrains.kotlin.android") version "1.9.22"
}
// group:name must match the `fn.compose:ui` module notation that apps substitute.
group = "fn.compose"
version = "0.1.0"
android {
namespace = "fn.compose.ui"
compileSdk = 34
defaultConfig {
minSdk = 24
consumerProguardFiles("consumer-rules.pro")
}
buildFeatures {
compose = true
}
composeOptions {
kotlinCompilerExtensionVersion = "1.5.8"
}
compileOptions {
sourceCompatibility = JavaVersion.VERSION_17
targetCompatibility = JavaVersion.VERSION_17
}
kotlinOptions {
jvmTarget = "17"
}
}
dependencies {
// `api` so consuming apps inherit Compose transitively through fn.compose:ui.
implementation(platform("androidx.compose:compose-bom:2024.02.00"))
api("androidx.compose.ui:ui")
api("androidx.compose.ui:ui-graphics")
api("androidx.compose.ui:ui-text")
api("androidx.compose.foundation:foundation")
api("androidx.compose.material3:material3")
api("androidx.compose.ui:ui-tooling-preview")
}
+1
View File
@@ -0,0 +1 @@
# No consumer ProGuard rules required yet for the fn.compose:ui design system.
+4
View File
@@ -0,0 +1,4 @@
org.gradle.jvmargs=-Xmx2048m -Dfile.encoding=UTF-8
android.useAndroidX=true
kotlin.code.style=official
android.nonTransitiveRClass=true
Binary file not shown.
@@ -0,0 +1,7 @@
distributionBase=GRADLE_USER_HOME
distributionPath=wrapper/dists
distributionUrl=https\://services.gradle.org/distributions/gradle-8.6-bin.zip
networkTimeout=10000
validateDistributionUrl=true
zipStoreBase=GRADLE_USER_HOME
zipStorePath=wrapper/dists
Vendored Executable
+251
View File
@@ -0,0 +1,251 @@
#!/bin/sh
#
# Copyright © 2015-2021 the original authors.
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
# https://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.
#
# SPDX-License-Identifier: Apache-2.0
#
##############################################################################
#
# Gradle start up script for POSIX generated by Gradle.
#
# Important for running:
#
# (1) You need a POSIX-compliant shell to run this script. If your /bin/sh is
# noncompliant, but you have some other compliant shell such as ksh or
# bash, then to run this script, type that shell name before the whole
# command line, like:
#
# ksh Gradle
#
# Busybox and similar reduced shells will NOT work, because this script
# requires all of these POSIX shell features:
# * functions;
# * expansions «$var», «${var}», «${var:-default}», «${var+SET}»,
# «${var#prefix}», «${var%suffix}», and «$( cmd )»;
# * compound commands having a testable exit status, especially «case»;
# * various built-in commands including «command», «set», and «ulimit».
#
# Important for patching:
#
# (2) This script targets any POSIX shell, so it avoids extensions provided
# by Bash, Ksh, etc; in particular arrays are avoided.
#
# The "traditional" practice of packing multiple parameters into a
# space-separated string is a well documented source of bugs and security
# problems, so this is (mostly) avoided, by progressively accumulating
# options in "$@", and eventually passing that to Java.
#
# Where the inherited environment variables (DEFAULT_JVM_OPTS, JAVA_OPTS,
# and GRADLE_OPTS) rely on word-splitting, this is performed explicitly;
# see the in-line comments for details.
#
# There are tweaks for specific operating systems such as AIX, CygWin,
# Darwin, MinGW, and NonStop.
#
# (3) This script is generated from the Groovy template
# https://github.com/gradle/gradle/blob/HEAD/platforms/jvm/plugins-application/src/main/resources/org/gradle/api/internal/plugins/unixStartScript.txt
# within the Gradle project.
#
# You can find Gradle at https://github.com/gradle/gradle/.
#
##############################################################################
# Attempt to set APP_HOME
# Resolve links: $0 may be a link
app_path=$0
# Need this for daisy-chained symlinks.
while
APP_HOME=${app_path%"${app_path##*/}"} # leaves a trailing /; empty if no leading path
[ -h "$app_path" ]
do
ls=$( ls -ld "$app_path" )
link=${ls#*' -> '}
case $link in #(
/*) app_path=$link ;; #(
*) app_path=$APP_HOME$link ;;
esac
done
# This is normally unused
# shellcheck disable=SC2034
APP_BASE_NAME=${0##*/}
# Discard cd standard output in case $CDPATH is set (https://github.com/gradle/gradle/issues/25036)
APP_HOME=$( cd -P "${APP_HOME:-./}" > /dev/null && printf '%s\n' "$PWD" ) || exit
# Use the maximum available, or set MAX_FD != -1 to use that value.
MAX_FD=maximum
warn () {
echo "$*"
} >&2
die () {
echo
echo "$*"
echo
exit 1
} >&2
# OS specific support (must be 'true' or 'false').
cygwin=false
msys=false
darwin=false
nonstop=false
case "$( uname )" in #(
CYGWIN* ) cygwin=true ;; #(
Darwin* ) darwin=true ;; #(
MSYS* | MINGW* ) msys=true ;; #(
NONSTOP* ) nonstop=true ;;
esac
CLASSPATH="\\\"\\\""
# Determine the Java command to use to start the JVM.
if [ -n "$JAVA_HOME" ] ; then
if [ -x "$JAVA_HOME/jre/sh/java" ] ; then
# IBM's JDK on AIX uses strange locations for the executables
JAVACMD=$JAVA_HOME/jre/sh/java
else
JAVACMD=$JAVA_HOME/bin/java
fi
if [ ! -x "$JAVACMD" ] ; then
die "ERROR: JAVA_HOME is set to an invalid directory: $JAVA_HOME
Please set the JAVA_HOME variable in your environment to match the
location of your Java installation."
fi
else
JAVACMD=java
if ! command -v java >/dev/null 2>&1
then
die "ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH.
Please set the JAVA_HOME variable in your environment to match the
location of your Java installation."
fi
fi
# Increase the maximum file descriptors if we can.
if ! "$cygwin" && ! "$darwin" && ! "$nonstop" ; then
case $MAX_FD in #(
max*)
# In POSIX sh, ulimit -H is undefined. That's why the result is checked to see if it worked.
# shellcheck disable=SC2039,SC3045
MAX_FD=$( ulimit -H -n ) ||
warn "Could not query maximum file descriptor limit"
esac
case $MAX_FD in #(
'' | soft) :;; #(
*)
# In POSIX sh, ulimit -n is undefined. That's why the result is checked to see if it worked.
# shellcheck disable=SC2039,SC3045
ulimit -n "$MAX_FD" ||
warn "Could not set maximum file descriptor limit to $MAX_FD"
esac
fi
# Collect all arguments for the java command, stacking in reverse order:
# * args from the command line
# * the main class name
# * -classpath
# * -D...appname settings
# * --module-path (only if needed)
# * DEFAULT_JVM_OPTS, JAVA_OPTS, and GRADLE_OPTS environment variables.
# For Cygwin or MSYS, switch paths to Windows format before running java
if "$cygwin" || "$msys" ; then
APP_HOME=$( cygpath --path --mixed "$APP_HOME" )
CLASSPATH=$( cygpath --path --mixed "$CLASSPATH" )
JAVACMD=$( cygpath --unix "$JAVACMD" )
# Now convert the arguments - kludge to limit ourselves to /bin/sh
for arg do
if
case $arg in #(
-*) false ;; # don't mess with options #(
/?*) t=${arg#/} t=/${t%%/*} # looks like a POSIX filepath
[ -e "$t" ] ;; #(
*) false ;;
esac
then
arg=$( cygpath --path --ignore --mixed "$arg" )
fi
# Roll the args list around exactly as many times as the number of
# args, so each arg winds up back in the position where it started, but
# possibly modified.
#
# NB: a `for` loop captures its iteration list before it begins, so
# changing the positional parameters here affects neither the number of
# iterations, nor the values presented in `arg`.
shift # remove old arg
set -- "$@" "$arg" # push replacement arg
done
fi
# Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script.
DEFAULT_JVM_OPTS='"-Xmx64m" "-Xms64m"'
# Collect all arguments for the java command:
# * DEFAULT_JVM_OPTS, JAVA_OPTS, and optsEnvironmentVar are not allowed to contain shell fragments,
# and any embedded shellness will be escaped.
# * For example: A user cannot expect ${Hostname} to be expanded, as it is an environment variable and will be
# treated as '${Hostname}' itself on the command line.
set -- \
"-Dorg.gradle.appname=$APP_BASE_NAME" \
-classpath "$CLASSPATH" \
-jar "$APP_HOME/gradle/wrapper/gradle-wrapper.jar" \
"$@"
# Stop when "xargs" is not available.
if ! command -v xargs >/dev/null 2>&1
then
die "xargs is not available"
fi
# Use "xargs" to parse quoted args.
#
# With -n1 it outputs one arg per line, with the quotes and backslashes removed.
#
# In Bash we could simply go:
#
# readarray ARGS < <( xargs -n1 <<<"$var" ) &&
# set -- "${ARGS[@]}" "$@"
#
# but POSIX shell has neither arrays nor command substitution, so instead we
# post-process each arg (as a line of input to sed) to backslash-escape any
# character that might be a shell metacharacter, then use eval to reverse
# that process (while maintaining the separation between arguments), and wrap
# the whole thing up as a single "set" statement.
#
# This will of course break if any of these variables contains a newline or
# an unmatched quote.
#
eval "set -- $(
printf '%s\n' "$DEFAULT_JVM_OPTS $JAVA_OPTS $GRADLE_OPTS" |
xargs -n1 |
sed ' s~[^-[:alnum:]+,./:=@_]~\\&~g; ' |
tr '\n' ' '
)" '"$@"'
exec "$JAVACMD" "$@"
+21
View File
@@ -0,0 +1,21 @@
pluginManagement {
repositories {
gradlePluginPortal()
google()
mavenCentral()
}
}
dependencyResolutionManagement {
repositoriesMode.set(RepositoriesMode.FAIL_ON_PROJECT_REPOS)
repositories {
google()
mavenCentral()
}
}
// Single-project Android library build. The root project IS the `fn.compose:ui`
// library. Apps consume it via composite build:
// includeBuild("../../kotlin/functions/ui") {
// dependencySubstitution { substitute(module("fn.compose:ui")).using(project(":")) }
// }
rootProject.name = "ui"
@@ -0,0 +1,2 @@
<?xml version="1.0" encoding="utf-8"?>
<manifest xmlns:android="http://schemas.android.com/apk/res/android" />
@@ -0,0 +1,79 @@
package fn.compose.theme
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.darkColorScheme
import androidx.compose.material3.lightColorScheme
import androidx.compose.runtime.Composable
/**
* Material 3 [androidx.compose.material3.ColorScheme] derived from [FnColors].
*
* This is the registry's canonical dark identity (Mantine v9 dark + indigo).
* Equivalent to `FnMantineProvider` (web) and `fn::run_app` with
* `ThemeMode::FnDark` (C++).
*/
private val FnDarkColorScheme = darkColorScheme(
primary = FnColors.primary,
onPrimary = FnColors.white,
primaryContainer = FnColors.primaryActive,
onPrimaryContainer = FnColors.white,
secondary = FnColors.primaryLight,
onSecondary = FnColors.white,
background = FnColors.bg,
onBackground = FnColors.text,
surface = FnColors.surface,
onSurface = FnColors.text,
surfaceVariant = FnColors.surfaceHover,
onSurfaceVariant = FnColors.textMuted,
error = FnColors.error,
onError = FnColors.white,
outline = FnColors.border,
outlineVariant = FnColors.borderStrong,
)
private val FnLightColorScheme = lightColorScheme(
primary = FnColors.primary,
onPrimary = FnColors.white,
primaryContainer = FnColors.primaryLight,
onPrimaryContainer = FnColors.white,
secondary = FnColors.primaryActive,
onSecondary = FnColors.white,
background = FnColors.lightBg,
onBackground = FnColors.lightText,
surface = FnColors.lightSurface,
onSurface = FnColors.lightText,
surfaceVariant = FnColors.lightSurface,
onSurfaceVariant = FnColors.lightTextMuted,
error = FnColors.error,
onError = FnColors.white,
outline = FnColors.lightBorder,
)
/**
* Root theme provider for every registry Android app. Dark by default, mirroring
* the web frontend's `defaultColorScheme="dark"`.
*
* Usage:
* ```
* setContent {
* FnTheme {
* Surface(Modifier.fillMaxSize()) { /* ... */ }
* }
* }
* ```
*
* Override the scheme with `FnTheme(darkMode = false) { ... }` or follow the
* system with `FnTheme(darkMode = isSystemInDarkTheme()) { ... }`.
*
* Apps must NOT call `MaterialTheme {}` directly always go through `FnTheme`.
*/
@Composable
fun FnTheme(
darkMode: Boolean = true,
content: @Composable () -> Unit,
) {
MaterialTheme(
colorScheme = if (darkMode) FnDarkColorScheme else FnLightColorScheme,
content = content,
)
}
@@ -0,0 +1,59 @@
---
name: fn_theme
kind: component
lang: kt
domain: ui
version: "0.1.0"
framework: compose
purity: impure
signature: "@Composable fun FnTheme(darkMode: Boolean = true, content: @Composable () -> Unit)"
description: "Provider raiz del design system Compose del registry (@fn_compose). Envuelve MaterialTheme con un ColorScheme derivado de FnColors (Mantine v9 dark + indigo). Dark por defecto, mirror de FnMantineProvider (web) y fn::run_app ThemeMode::FnDark (C++). Toda app del registry envuelve su contenido en FnTheme."
tags: [compose, android, ui, theme, provider, material3, design-system]
props: []
emits: []
uses_functions: [fn_tokens_kt_ui]
uses_types: []
returns: []
returns_optional: false
error_type: ""
imports: []
output: "Aplica el ColorScheme del registry (dark o light) al arbol Composable hijo via MaterialTheme. No retorna valor; emite UI."
file_path: "kotlin/functions/ui/src/main/kotlin/fn/compose/theme/FnTheme.kt"
---
## Ejemplo
```kotlin
import fn.compose.theme.FnTheme
class MainActivity : ComponentActivity() {
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
setContent {
FnTheme { // darkMode = true por defecto
Surface(modifier = Modifier.fillMaxSize()) {
Text("Mi app")
}
}
}
}
}
// Forzar light, o seguir el sistema:
FnTheme(darkMode = false) { /* ... */ }
FnTheme(darkMode = isSystemInDarkTheme()) { /* ... */ }
```
## Cuando usarla
Una vez por app, en el `setContent {}` de la `MainActivity` (o en cualquier `@Preview`/test de Roborazzi), envolviendo todo el árbol de UI. Es el equivalente Compose de `FnMantineProvider`. Sin `FnTheme`, los componentes caen al Material3 stock (morado) y pierden la identidad del registry.
## Gotchas
- **NUNCA `MaterialTheme {}` directo en una app.** Siempre `FnTheme {}`. `MaterialTheme` stock no aplica los tokens del registry.
- **Dark-first.** El default es `darkMode = true`, igual que el frontend web. Pasar `darkMode = false` solo para capturas light o si la app lo requiere explícitamente.
- **Composite build requerido.** Las apps consumen `FnTheme` via `includeBuild("../../kotlin/functions/ui")` con substitución `fn.compose:ui`. No es un artifact Maven publicado; cambios en el módulo recompilan las apps automáticamente.
## Capability growth log
- v0.1.0 (2026-06-03) — versión inicial. Provider MaterialTheme dark/light con ColorScheme derivado de FnColors (paleta heredada exacta de C++/Mantine). Desbloquea el scaffolder `init_kotlin_app`.
@@ -0,0 +1,143 @@
package fn.compose.theme
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.TextUnit
import androidx.compose.ui.unit.sp
/**
* Design tokens for the registry's Android Compose apps.
*
* The color palette is inherited *exactly* (same hex literals) from the C++
* design system (`cpp/DESIGN_SYSTEM.md` section 4.1), which in turn mirrors
* Mantine v9 dark + indigo used by the web frontend (`@fn_library`). A button
* rendered here must look identical to the same button on web and on the C++
* ImGui apps. Do not invent new colors — extend this object and keep it aligned
* with the canonical Mantine values.
*/
object FnColors {
// --- Indigo brand scale (Mantine indigo) ---
val indigo4 = Color(0xFF748FFC)
val indigo5 = Color(0xFF5C7CFA)
val indigo6 = Color(0xFF4C6EF5)
val indigo7 = Color(0xFF4263EB)
// --- Dark gray scale (Mantine dark) ---
val dark0 = Color(0xFFC1C2C5)
val dark1 = Color(0xFFA6A7AB)
val dark2 = Color(0xFF909296)
val dark3 = Color(0xFF5C5F66)
val dark4 = Color(0xFF373A40)
val dark5 = Color(0xFF2C2E33)
val dark6 = Color(0xFF25262B)
val dark7 = Color(0xFF1A1B1E)
val dark8 = Color(0xFF141517)
val dark9 = Color(0xFF101113)
// --- Status colors (Mantine *.6) ---
val green6 = Color(0xFF40C057)
val yellow6 = Color(0xFFFAB005)
val red6 = Color(0xFFFA5252)
val blue6 = Color(0xFF228BE6)
// --- Pure ---
val white = Color(0xFFFFFFFF)
val black = Color(0xFF000000)
// --- Semantic aliases (match fn_tokens::colors:: in C++) ---
val primary = indigo6 // #4C6EF5
val primaryHover = indigo5 // #5C7CFA
val primaryLight = indigo4 // #748FFC
val primaryActive = indigo7 // #4263EB
val success = green6 // #40C057
val warning = yellow6 // #FAB005
val error = red6 // #FA5252
val info = blue6 // #228BE6
val bg = dark7 // #1A1B1E
val surface = dark6 // #25262B
val surfaceHover = dark5 // #2C2E33
val surfaceActive = dark4 // #373A40
val border = dark4 // #373A40
val borderStrong = dark3 // #5C5F66
val text = dark0 // #C1C2C5
val textMuted = dark2 // #909296
val textDim = dark3 // #5C5F66
// --- Light scheme companions (for FnTheme(darkMode = false)) ---
val lightBg = Color(0xFFFFFFFF)
val lightSurface = Color(0xFFF8F9FA) // gray.0
val lightBorder = Color(0xFFDEE2E6) // gray.3
val lightText = Color(0xFF212529) // gray.9
val lightTextMuted = Color(0xFF868E96) // gray.6
}
/**
* Spacing scale in [Dp]. Slightly denser than CSS Mantine, aligned to the
* value set documented in `kotlin/PATTERNS.md` section 2.
*/
object FnSpacing {
val xs: Dp = 8.dp
val sm: Dp = 12.dp
val md: Dp = 16.dp
val lg: Dp = 24.dp
val xl: Dp = 32.dp
val xxl: Dp = 48.dp
}
/** Corner radius scale in [Dp]. Identical to the C++ `fn_tokens::radius`. */
object FnRadius {
val none: Dp = 0.dp
val xs: Dp = 2.dp
val sm: Dp = 4.dp
val md: Dp = 8.dp // Mantine defaultRadius
val lg: Dp = 12.dp
val xl: Dp = 16.dp
val full: Dp = 9999.dp
}
/** Font sizes (sp) and weights mirroring the web/C++ typography scale. */
object FnTypography {
val xs: TextUnit = 11.sp
val sm: TextUnit = 13.sp
val md: TextUnit = 15.sp
val lg: TextUnit = 18.sp
val xl: TextUnit = 22.sp
// Heading sizes h1..h6
val h1: TextUnit = 32.sp
val h2: TextUnit = 26.sp
val h3: TextUnit = 22.sp
val h4: TextUnit = 18.sp
val h5: TextUnit = 15.sp
val h6: TextUnit = 13.sp
val normal: FontWeight = FontWeight.Normal
val medium: FontWeight = FontWeight.Medium
val semibold: FontWeight = FontWeight.SemiBold
val bold: FontWeight = FontWeight.Bold
}
/** Elevation tokens in [Dp]. No direct C++ analogue (ImGui is flat). */
object FnShadows {
val none: Dp = 0.dp
val xs: Dp = 1.dp
val sm: Dp = 2.dp
val md: Dp = 4.dp
val lg: Dp = 8.dp
val xl: Dp = 16.dp
}
/** Aggregator so a single import (`FnTokens`) exposes every token group. */
object FnTokens {
val colors = FnColors
val spacing = FnSpacing
val radius = FnRadius
val typography = FnTypography
val shadows = FnShadows
}
@@ -0,0 +1,48 @@
---
name: fn_tokens
kind: component
lang: kt
domain: ui
version: "0.1.0"
framework: compose
purity: pure
signature: "object FnTokens { colors; spacing; radius; typography; shadows }"
description: "Design tokens del design system Compose del registry (@fn_compose). Paleta heredada exacta (mismos hex) de cpp/DESIGN_SYSTEM.md / Mantine v9 dark + indigo: FnColors, FnSpacing (Dp), FnRadius (Dp), FnTypography (sp + weights), FnShadows (Dp). Fuente unica de valores visuales para apps Android del registry."
tags: [compose, android, ui, tokens, theme, design-system, mantine]
props: []
emits: []
uses_functions: []
uses_types: []
returns: []
returns_optional: false
error_type: ""
imports: []
output: "Objetos Kotlin de solo lectura: FnColors (Color), FnSpacing/FnRadius/FnShadows (Dp), FnTypography (TextUnit + FontWeight), y el agregador FnTokens."
file_path: "kotlin/functions/ui/src/main/kotlin/fn/compose/theme/FnTokens.kt"
---
## Ejemplo
```kotlin
import fn.compose.theme.FnColors
import fn.compose.theme.FnSpacing
import fn.compose.theme.FnRadius
Box(
modifier = Modifier
.background(FnColors.surface, RoundedCornerShape(FnRadius.md))
.padding(FnSpacing.md)
) {
Text("Hola", color = FnColors.primary) // #4C6EF5 — mismo indigo que web y C++
}
```
## Cuando usarla
Siempre que escribas un Composable en una app del registry y necesites un color, espaciado, radio, tamaño de fuente o elevación. NUNCA pongas `Color(0xFF...)` o `8.dp` literal en código de app — usa el token. Garantiza que la app se vea idéntica a su equivalente web (Mantine) y C++ (ImGui), porque los tres comparten los mismos valores.
## Gotchas
- **Paleta heredada, no inventada.** Los hex coinciden 1:1 con `cpp/DESIGN_SYSTEM.md §4.1` (Mantine v9 dark + indigo). Si cambias un color aquí, debe cambiarse también en web y C++ para no romper la coherencia cross-platform.
- **Spacing más denso que CSS.** `FnSpacing` usa la escala de `kotlin/PATTERNS.md` (8/12/16/24/32/48 dp), no los px de CSS Mantine. Es intencional (densidad de pantalla móvil).
- **Es `object`, no `@Composable`.** Se consume directo (`FnColors.primary`), no via `MaterialTheme.colorScheme`. Para colores semánticos theme-aware en componentes propios, prefiere `MaterialTheme.colorScheme.*` (lo aplica `fn_theme_kt_ui`).
@@ -0,0 +1,219 @@
package fn.compose.ui
import androidx.compose.foundation.Canvas
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.width
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.geometry.Offset
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.Path
import androidx.compose.ui.graphics.StrokeCap
import androidx.compose.ui.graphics.StrokeJoin
import androidx.compose.ui.graphics.drawscope.DrawScope
import androidx.compose.ui.graphics.drawscope.Fill
import androidx.compose.ui.graphics.drawscope.Stroke
import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import fn.compose.theme.FnColors
// ---------------------------------------------------------------------------
// Data class
// ---------------------------------------------------------------------------
/** A single bar in [FnBarChart]. */
data class FnBarItem(val label: String, val value: Float)
// ---------------------------------------------------------------------------
// FnLineChart
// ---------------------------------------------------------------------------
/**
* Canvas-based line chart. Draws a polyline connecting [data] points
* normalised to [min, max], plus a semi-transparent fill below the line.
*
* Handles empty lists and single-point lists without crashing.
*/
@Composable
fun FnLineChart(
data: List<Float>,
modifier: Modifier = Modifier,
) {
val primaryColor = FnColors.primary
val fillColor = FnColors.primary.copy(alpha = 0.15f)
Canvas(
modifier = Modifier
.fillMaxWidth()
.height(160.dp)
.then(modifier),
) {
if (data.size < 2) return@Canvas
val minVal = data.min()
val maxVal = data.max()
val range = (maxVal - minVal).takeIf { it > 0f } ?: 1f
val strokePx = 2.dp.toPx()
val points = buildOffsets(data, minVal, range, size.width, size.height)
// --- fill path ---
val fillPath = Path().apply {
moveTo(points.first().x, size.height)
points.forEach { lineTo(it.x, it.y) }
lineTo(points.last().x, size.height)
close()
}
drawPath(fillPath, color = fillColor, style = Fill)
// --- polyline ---
drawPolyline(points, primaryColor, strokePx)
}
}
// ---------------------------------------------------------------------------
// FnBarChart
// ---------------------------------------------------------------------------
/**
* Canvas-based vertical bar chart. Bars are drawn proportional to the
* maximum value in [data]. Labels are rendered below each bar using
* [Text] (no FnText dependency to keep Charts.kt standalone).
*
* Handles empty lists without crashing.
*/
@Composable
fun FnBarChart(
data: List<FnBarItem>,
modifier: Modifier = Modifier,
) {
if (data.isEmpty()) return
val primaryColor = FnColors.primary
val labelColor = FnColors.textMuted
val maxVal = data.maxOf { it.value }.takeIf { it > 0f } ?: 1f
val barGapDp: Dp = 4.dp
Column(modifier = modifier) {
// --- bar canvas ---
Canvas(
modifier = Modifier
.fillMaxWidth()
.height(160.dp),
) {
val count = data.size
val totalGapPx = barGapDp.toPx() * (count - 1)
val barWidth = (size.width - totalGapPx) / count
data.forEachIndexed { index, item ->
val barHeight = (item.value / maxVal) * size.height
val left = index * (barWidth + barGapDp.toPx())
val top = size.height - barHeight
drawRect(
color = primaryColor,
topLeft = Offset(left, top),
size = androidx.compose.ui.geometry.Size(barWidth, barHeight),
)
}
}
// --- labels row ---
Row(modifier = Modifier.fillMaxWidth()) {
data.forEach { item ->
Text(
text = item.label,
modifier = Modifier.weight(1f),
color = labelColor,
fontSize = 10.sp,
textAlign = androidx.compose.ui.text.style.TextAlign.Center,
maxLines = 1,
)
}
}
}
}
// ---------------------------------------------------------------------------
// FnSparkline
// ---------------------------------------------------------------------------
/**
* Compact mini line chart — no axes, no fill, no labels.
* Ideal for embedding in KPI cards or table cells.
*
* Handles empty lists and single-point lists without crashing.
*/
@Composable
fun FnSparkline(
data: List<Float>,
modifier: Modifier = Modifier,
) {
val primaryColor = FnColors.primary
Canvas(
modifier = Modifier
.width(80.dp)
.height(24.dp)
.then(modifier),
) {
if (data.size < 2) return@Canvas
val minVal = data.min()
val maxVal = data.max()
val range = (maxVal - minVal).takeIf { it > 0f } ?: 1f
val strokePx = 1.5.dp.toPx()
val points = buildOffsets(data, minVal, range, size.width, size.height)
drawPolyline(points, primaryColor, strokePx)
}
}
// ---------------------------------------------------------------------------
// Private helpers
// ---------------------------------------------------------------------------
/**
* Converts a list of raw values into [Offset] positions scaled to the
* given [canvasWidth] x [canvasHeight].
*/
private fun buildOffsets(
data: List<Float>,
minVal: Float,
range: Float,
canvasWidth: Float,
canvasHeight: Float,
): List<Offset> {
val count = data.size
return data.mapIndexed { index, value ->
val x = if (count == 1) 0f else index / (count - 1).toFloat() * canvasWidth
val y = canvasHeight - ((value - minVal) / range) * canvasHeight
Offset(x, y)
}
}
/**
* Draws connected line segments between consecutive [points] using the
* given [color] and [strokeWidthPx].
*/
private fun DrawScope.drawPolyline(
points: List<Offset>,
color: Color,
strokeWidthPx: Float,
) {
for (i in 0 until points.lastIndex) {
drawLine(
color = color,
start = points[i],
end = points[i + 1],
strokeWidth = strokeWidthPx,
cap = StrokeCap.Round,
)
}
}
@@ -0,0 +1,252 @@
package fn.compose.ui
import androidx.compose.foundation.Canvas
import androidx.compose.foundation.border
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material3.HorizontalDivider
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Surface
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.geometry.Offset
import androidx.compose.ui.graphics.StrokeCap
import androidx.compose.ui.graphics.StrokeJoin
import androidx.compose.ui.graphics.drawscope.Stroke
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.dp
import fn.compose.theme.FnColors
import fn.compose.theme.FnRadius
import fn.compose.theme.FnSpacing
import fn.compose.theme.FnTypography
/**
* Page-level header with title, optional subtitle and trailing action slot.
*
* Layout: SpaceBetween Row with CenterVertically alignment.
* Left side: Column with title + optional subtitle.
* Right side: optional actions lambda.
*/
@Composable
fun FnPageHeader(
title: String,
modifier: Modifier = Modifier,
subtitle: String? = null,
actions: (@Composable () -> Unit)? = null,
) {
Row(
modifier = modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically,
) {
Column(modifier = Modifier.weight(1f)) {
Text(
text = title,
fontSize = FnTypography.lg,
fontWeight = FnTypography.bold,
color = MaterialTheme.colorScheme.onSurface,
)
if (subtitle != null) {
Text(
text = subtitle,
fontSize = FnTypography.sm,
fontWeight = FnTypography.normal,
color = FnColors.textMuted,
)
}
}
if (actions != null) {
actions()
}
}
}
/**
* KPI metric card: label, large value, optional delta badge and optional
* inline sparkline drawn with Canvas (no external dependency).
*
* The [modifier] is forwarded to the outermost Surface so callers can pass
* `Modifier.weight(1f)` inside a Row (as the gallery does).
*/
@Composable
fun FnKpiCard(
label: String,
value: String,
modifier: Modifier = Modifier,
delta: String? = null,
deltaPositive: Boolean = true,
sparklineData: List<Float>? = null,
) {
Surface(
modifier = modifier
.border(
width = 1.dp,
color = FnColors.border,
shape = RoundedCornerShape(FnRadius.md),
),
shape = RoundedCornerShape(FnRadius.md),
color = MaterialTheme.colorScheme.surface,
tonalElevation = 0.dp,
shadowElevation = 0.dp,
) {
Column(
modifier = Modifier.padding(FnSpacing.md),
verticalArrangement = Arrangement.spacedBy(FnSpacing.xs / 2),
) {
// Label
Text(
text = label,
fontSize = FnTypography.sm,
fontWeight = FnTypography.normal,
color = FnColors.textMuted,
)
// Primary value
Text(
text = value,
fontSize = FnTypography.xl,
fontWeight = FnTypography.bold,
color = MaterialTheme.colorScheme.onSurface,
)
// Delta badge
if (delta != null) {
Text(
text = delta,
fontSize = FnTypography.xs,
fontWeight = FnTypography.medium,
color = if (deltaPositive) FnColors.success else FnColors.error,
)
}
// Inline sparkline — drawn with Canvas, no external component
if (!sparklineData.isNullOrEmpty()) {
val primaryColor = FnColors.primary
Canvas(
modifier = Modifier
.fillMaxWidth()
.height(32.dp),
) {
val data = sparklineData
if (data.size < 2) return@Canvas
val minVal = data.min()
val maxVal = data.max()
val range = (maxVal - minVal).let { if (it == 0f) 1f else it }
val w = size.width
val h = size.height
val stepX = w / (data.size - 1).toFloat()
// Build path points
val points = data.mapIndexed { index, v ->
val x = index * stepX
// Normalize: high value -> low y (top of canvas)
val y = h - ((v - minVal) / range) * h
Offset(x, y)
}
// Draw polyline segment by segment
for (i in 0 until points.size - 1) {
drawLine(
color = primaryColor,
start = points[i],
end = points[i + 1],
strokeWidth = 2.dp.toPx(),
cap = StrokeCap.Round,
)
}
}
}
}
}
}
/**
* Column descriptor for [FnDataTable].
*
* @param T row data type.
* @param header Column header label.
* @param weight Relative width weight passed to [Modifier.weight].
* @param cell Composable that renders a single cell for a given row value.
*/
data class FnTableColumn<T>(
val header: String,
val weight: Float,
val cell: @Composable (T) -> Unit,
)
/**
* Static data table.
*
* Uses a non-lazy [Column] so it can be embedded inside a parent
* `verticalScroll` (as the gallery does). Do not wrap in LazyColumn.
*
* Layout:
* - Header row: one Text per column, weighted, bold, muted color.
* - [HorizontalDivider] below the header.
* - One Row per data entry: one [Box] per column, weighted.
* - [HorizontalDivider] between data rows (not after the last).
*/
@Composable
fun <T> FnDataTable(
rows: List<T>,
columns: List<FnTableColumn<T>>,
modifier: Modifier = Modifier,
) {
Column(modifier = modifier) {
// Header row
Row(
modifier = Modifier
.fillMaxWidth()
.padding(
horizontal = FnSpacing.sm,
vertical = FnSpacing.xs,
),
verticalAlignment = Alignment.CenterVertically,
) {
columns.forEach { col ->
Text(
text = col.header,
modifier = Modifier.weight(col.weight),
fontSize = FnTypography.sm,
fontWeight = FontWeight.Bold,
color = FnColors.textMuted,
)
}
}
HorizontalDivider(color = FnColors.border, thickness = 1.dp)
// Data rows
rows.forEachIndexed { rowIndex, row ->
Row(
modifier = Modifier
.fillMaxWidth()
.padding(
horizontal = FnSpacing.sm,
vertical = FnSpacing.xs,
),
verticalAlignment = Alignment.CenterVertically,
) {
columns.forEach { col ->
Box(modifier = Modifier.weight(col.weight)) {
col.cell(row)
}
}
}
// Divider between rows, not after the last
if (rowIndex < rows.lastIndex) {
HorizontalDivider(color = FnColors.border, thickness = 1.dp)
}
}
}
}
@@ -0,0 +1,283 @@
package fn.compose.ui
import androidx.compose.foundation.background
import androidx.compose.foundation.border
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.ColumnScope
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Surface
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.unit.dp
import fn.compose.theme.FnColors
import fn.compose.theme.FnRadius
import fn.compose.theme.FnShadows
import fn.compose.theme.FnSpacing
import fn.compose.theme.FnTypography
// ---------------------------------------------------------------------------
// FnText
// ---------------------------------------------------------------------------
/** Body text size scale. Maps directly to [FnTypography] sp values. */
enum class FnTextSize { Xs, Sm, Md, Lg, Xl }
/**
* Stateless body text following the registry typography scale.
*
* @param text String to display.
* @param size One of [FnTextSize] — maps to FnTypography.xs … xl.
* @param color Text color. Defaults to [MaterialTheme.colorScheme.onSurface]
* when [Color.Unspecified] is passed.
*/
@Composable
fun FnText(
text: String,
modifier: Modifier = Modifier,
size: FnTextSize = FnTextSize.Md,
color: Color = Color.Unspecified,
) {
val fontSize = when (size) {
FnTextSize.Xs -> FnTypography.xs
FnTextSize.Sm -> FnTypography.sm
FnTextSize.Md -> FnTypography.md
FnTextSize.Lg -> FnTypography.lg
FnTextSize.Xl -> FnTypography.xl
}
val resolvedColor = if (color == Color.Unspecified) {
MaterialTheme.colorScheme.onSurface
} else {
color
}
Text(
text = text,
modifier = modifier,
color = resolvedColor,
fontSize = fontSize,
fontWeight = FnTypography.normal,
)
}
// ---------------------------------------------------------------------------
// FnTitle
// ---------------------------------------------------------------------------
/**
* Heading text from order 1 (largest) to order 6 (smallest).
*
* Orders 12 use [FontWeight.Bold]; orders 36 use [FontWeight.SemiBold].
* Font size maps to [FnTypography.h1] … [FnTypography.h6].
*/
@Composable
fun FnTitle(
text: String,
modifier: Modifier = Modifier,
order: Int = 1,
) {
val clamped = order.coerceIn(1, 6)
val fontSize = when (clamped) {
1 -> FnTypography.h1
2 -> FnTypography.h2
3 -> FnTypography.h3
4 -> FnTypography.h4
5 -> FnTypography.h5
else -> FnTypography.h6
}
val fontWeight = if (clamped <= 2) FnTypography.bold else FnTypography.semibold
Text(
text = text,
modifier = modifier,
color = MaterialTheme.colorScheme.onSurface,
fontSize = fontSize,
fontWeight = fontWeight,
)
}
// ---------------------------------------------------------------------------
// FnCard
// ---------------------------------------------------------------------------
/** Visual style variant for [FnCard]. */
enum class FnCardVariant { Default, Borderless, Ghost }
/**
* Container card with three visual variants.
*
* - **Default**: surface background, 1 dp border ([FnColors.border]),
* [FnRadius.md] corners, [FnShadows.sm] elevation, [FnSpacing.md] padding.
* - **Borderless**: surface background, no border, no shadow,
* [FnSpacing.md] padding.
* - **Ghost**: transparent background, no border, no shadow,
* [FnSpacing.md] padding.
*/
@Composable
fun FnCard(
modifier: Modifier = Modifier,
variant: FnCardVariant = FnCardVariant.Default,
content: @Composable ColumnScope.() -> Unit,
) {
val shape = RoundedCornerShape(FnRadius.md)
when (variant) {
FnCardVariant.Default -> {
Surface(
modifier = modifier,
shape = shape,
color = MaterialTheme.colorScheme.surface,
shadowElevation = FnShadows.sm,
tonalElevation = 0.dp,
) {
Column(
modifier = Modifier
.border(width = 1.dp, color = FnColors.border, shape = shape)
.padding(FnSpacing.md),
content = content,
)
}
}
FnCardVariant.Borderless -> {
Surface(
modifier = modifier,
shape = shape,
color = MaterialTheme.colorScheme.surface,
shadowElevation = 0.dp,
tonalElevation = 0.dp,
) {
Column(
modifier = Modifier.padding(FnSpacing.md),
content = content,
)
}
}
FnCardVariant.Ghost -> {
Surface(
modifier = modifier,
shape = shape,
color = Color.Transparent,
shadowElevation = 0.dp,
tonalElevation = 0.dp,
) {
Column(
modifier = Modifier.padding(FnSpacing.md),
content = content,
)
}
}
}
}
// ---------------------------------------------------------------------------
// FnBadge
// ---------------------------------------------------------------------------
/** Semantic color options for [FnBadge]. */
enum class FnBadgeColor { Brand, Gray, Green, Red, Yellow, Blue }
/**
* Pill-shaped label using a semitransparent background derived from the base
* color (alpha 0.18) and the base color as text color.
*
* Color mapping:
* - Brand → [FnColors.primary]
* - Gray → [FnColors.textMuted]
* - Green → [FnColors.success]
* - Red → [FnColors.error]
* - Yellow → [FnColors.warning]
* - Blue → [FnColors.info]
*/
@Composable
fun FnBadge(
text: String,
modifier: Modifier = Modifier,
color: FnBadgeColor = FnBadgeColor.Brand,
) {
val baseColor = when (color) {
FnBadgeColor.Brand -> FnColors.primary
FnBadgeColor.Gray -> FnColors.textMuted
FnBadgeColor.Green -> FnColors.success
FnBadgeColor.Red -> FnColors.error
FnBadgeColor.Yellow -> FnColors.warning
FnBadgeColor.Blue -> FnColors.info
}
val bgColor = baseColor.copy(alpha = 0.18f)
val shape = RoundedCornerShape(FnRadius.full)
Box(
modifier = modifier
.background(color = bgColor, shape = shape)
.padding(horizontal = 8.dp, vertical = 3.dp),
contentAlignment = Alignment.Center,
) {
Text(
text = text,
color = baseColor,
fontSize = FnTypography.xs,
fontWeight = FnTypography.medium,
)
}
}
// ---------------------------------------------------------------------------
// FnAvatar
// ---------------------------------------------------------------------------
/** Size variants for [FnAvatar]. */
enum class FnAvatarSize { Sm, Md, Lg }
/**
* Circular avatar displaying up to two initials on a [FnColors.primary]
* background.
*
* Size mapping:
* - Sm → 28 dp
* - Md → 40 dp
* - Lg → 56 dp
*
* Text size scales proportionally (xs / sm / md) so the initials always fit.
*/
@Composable
fun FnAvatar(
initials: String,
modifier: Modifier = Modifier,
size: FnAvatarSize = FnAvatarSize.Md,
) {
val sizeDp = when (size) {
FnAvatarSize.Sm -> 28.dp
FnAvatarSize.Md -> 40.dp
FnAvatarSize.Lg -> 56.dp
}
val fontSize = when (size) {
FnAvatarSize.Sm -> FnTypography.xs
FnAvatarSize.Md -> FnTypography.sm
FnAvatarSize.Lg -> FnTypography.md
}
// Truncate to at most two characters so the avatar never overflows.
val label = initials.take(2).uppercase()
Box(
modifier = modifier
.size(sizeDp)
.background(color = FnColors.primary, shape = CircleShape),
contentAlignment = Alignment.Center,
) {
Text(
text = label,
color = FnColors.white,
fontSize = fontSize,
fontWeight = FnTypography.semibold,
textAlign = TextAlign.Center,
)
}
}
@@ -0,0 +1,260 @@
package fn.compose.ui
import androidx.compose.animation.core.LinearEasing
import androidx.compose.animation.core.RepeatMode
import androidx.compose.animation.core.animateFloat
import androidx.compose.animation.core.infiniteRepeatable
import androidx.compose.animation.core.rememberInfiniteTransition
import androidx.compose.animation.core.tween
import androidx.compose.foundation.background
import androidx.compose.foundation.border
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.ScrollableTabRow
import androidx.compose.material3.Tab
import androidx.compose.material3.TabRow
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import fn.compose.theme.FnColors
import fn.compose.theme.FnRadius
import fn.compose.theme.FnSpacing
import fn.compose.theme.FnTypography
// ---------------------------------------------------------------------------
// FnTabs
// ---------------------------------------------------------------------------
/**
* Tab bar backed by [TabRow] or [ScrollableTabRow] depending on [scrollable].
*
* Each item in [tabs] becomes a [Tab] whose selected state is determined by
* comparing its index with [selectedIndex]. Selecting a tab triggers
* [onTabSelected] with the tapped index.
*/
@Composable
fun FnTabs(
tabs: List<String>,
selectedIndex: Int,
onTabSelected: (Int) -> Unit,
modifier: Modifier = Modifier,
scrollable: Boolean = false,
) {
val tabContent: @Composable () -> Unit = {
tabs.forEachIndexed { index, label ->
Tab(
selected = index == selectedIndex,
onClick = { onTabSelected(index) },
text = { Text(label) },
)
}
}
if (scrollable) {
ScrollableTabRow(
selectedTabIndex = selectedIndex,
modifier = modifier,
) { tabContent() }
} else {
TabRow(
selectedTabIndex = selectedIndex,
modifier = modifier,
) { tabContent() }
}
}
// ---------------------------------------------------------------------------
// FnAlert
// ---------------------------------------------------------------------------
/** Semantic intent of an [FnAlert]. Maps to a status color from [FnColors]. */
enum class FnAlertVariant { Info, Success, Warning, Error }
/**
* Inline alert banner.
*
* The background is the variant's status color at 15 % opacity; the border is
* the same color at full opacity (1 dp). When [title] is supplied it is
* rendered in bold above [text].
*/
@Composable
fun FnAlert(
text: String,
modifier: Modifier = Modifier,
title: String? = null,
variant: FnAlertVariant = FnAlertVariant.Info,
) {
val colorBase: Color = when (variant) {
FnAlertVariant.Info -> FnColors.info
FnAlertVariant.Success -> FnColors.success
FnAlertVariant.Warning -> FnColors.warning
FnAlertVariant.Error -> FnColors.error
}
val shape = RoundedCornerShape(FnRadius.sm)
val onSurface = MaterialTheme.colorScheme.onSurface
Box(
modifier = modifier
.fillMaxWidth()
.clip(shape)
.background(colorBase.copy(alpha = 0.15f))
.border(width = 1.dp, color = colorBase, shape = shape)
.padding(FnSpacing.md),
) {
Column(verticalArrangement = Arrangement.spacedBy(4.dp)) {
if (title != null) {
Text(
text = title,
color = colorBase,
fontWeight = FontWeight.Bold,
fontSize = FnTypography.sm,
)
}
Text(
text = text,
color = onSurface,
fontSize = FnTypography.sm,
)
}
}
}
// ---------------------------------------------------------------------------
// FnLoader
// ---------------------------------------------------------------------------
/** Visual size of an [FnLoader] spinner. */
enum class FnLoaderSize { Sm, Md, Lg }
/**
* Indeterminate circular progress indicator using [FnColors.primary].
*
* [size] maps to 16 dp (Sm), 32 dp (Md), or 56 dp (Lg). The stroke width
* scales proportionally (2 / 3 / 5 dp).
*/
@Composable
fun FnLoader(
modifier: Modifier = Modifier,
size: FnLoaderSize = FnLoaderSize.Md,
) {
val (sizeDp, strokeDp) = when (size) {
FnLoaderSize.Sm -> 16.dp to 2.dp
FnLoaderSize.Md -> 32.dp to 3.dp
FnLoaderSize.Lg -> 56.dp to 5.dp
}
CircularProgressIndicator(
modifier = modifier.size(sizeDp),
color = FnColors.primary,
strokeWidth = strokeDp,
)
}
// ---------------------------------------------------------------------------
// FnSkeleton
// ---------------------------------------------------------------------------
/**
* Shimmer placeholder rectangle.
*
* Fills the available width to [height] dp with a rounded shape and an
* animated alpha oscillating between [FnColors.surfaceHover] and
* [FnColors.surfaceActive], simulating a loading shimmer without external
* dependencies.
*/
@Composable
fun FnSkeleton(
modifier: Modifier = Modifier,
height: Dp = 16.dp,
) {
val infiniteTransition = rememberInfiniteTransition(label = "skeleton_shimmer")
val alpha by infiniteTransition.animateFloat(
initialValue = 0f,
targetValue = 1f,
animationSpec = infiniteRepeatable(
animation = tween(durationMillis = 800, easing = LinearEasing),
repeatMode = RepeatMode.Reverse,
),
label = "skeleton_alpha",
)
// Interpolate between surfaceHover and surfaceActive using the animated alpha.
val shimmerColor = androidx.compose.ui.graphics.lerp(
FnColors.surfaceHover,
FnColors.surfaceActive,
alpha,
)
Box(
modifier = modifier
.fillMaxWidth()
.height(height)
.clip(RoundedCornerShape(FnRadius.sm))
.background(shimmerColor),
)
}
// ---------------------------------------------------------------------------
// FnEmptyState
// ---------------------------------------------------------------------------
/**
* Centred empty-state panel with optional icon, title, description, and
* action slot.
*
* Layout (top to bottom, horizontally centred):
* 1. [icon] rendered as large emoji/text (40 sp) — only if non-null.
* 2. [title] in bold body size.
* 3. [description] in [FnColors.textMuted].
* 4. [action] composable slot — only if non-null.
*
* Padding is [FnSpacing.lg] on all sides; vertical gaps use [FnSpacing.sm].
*/
@Composable
fun FnEmptyState(
title: String,
description: String,
modifier: Modifier = Modifier,
icon: String? = null,
action: (@Composable () -> Unit)? = null,
) {
Column(
modifier = modifier.padding(FnSpacing.lg),
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.spacedBy(FnSpacing.sm),
) {
if (icon != null) {
Text(
text = icon,
fontSize = 40.sp,
)
}
Text(
text = title,
fontWeight = FontWeight.Bold,
fontSize = FnTypography.md,
)
Text(
text = description,
color = FnColors.textMuted,
fontSize = FnTypography.sm,
)
action?.invoke()
}
}
@@ -0,0 +1,318 @@
package fn.compose.ui
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material3.AlertDialog
import androidx.compose.material3.Button
import androidx.compose.material3.ButtonDefaults
import androidx.compose.material3.Checkbox
import androidx.compose.material3.DropdownMenuItem
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.ExposedDropdownMenuBox
import androidx.compose.material3.ExposedDropdownMenuDefaults
import androidx.compose.material3.OutlinedButton
import androidx.compose.material3.OutlinedTextField
import androidx.compose.material3.Switch
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.text.style.TextDecoration
import androidx.compose.ui.unit.dp
import fn.compose.theme.FnColors
import fn.compose.theme.FnRadius
import fn.compose.theme.FnSpacing
// ---------------------------------------------------------------------------
// Button
// ---------------------------------------------------------------------------
/** Variants for [FnButton]. Maps to Material3 button styles + custom colors. */
enum class FnButtonVariant {
Filled,
Outlined,
Secondary,
Ghost,
Destructive,
Link,
}
private val buttonShape = RoundedCornerShape(FnRadius.sm)
/**
* Design-system button with six visual variants. Stateless — the caller
* drives [onClick].
*
* - Filled: primary container color.
* - Outlined: border only, no fill.
* - Secondary: filled with [FnColors.surfaceActive].
* - Ghost: text-only, no chrome.
* - Destructive: filled with [FnColors.error].
* - Link: text-only with [FnColors.primary] color and underline decoration.
*/
@Composable
fun FnButton(
text: String,
onClick: () -> Unit,
modifier: Modifier = Modifier,
variant: FnButtonVariant = FnButtonVariant.Filled,
) {
val shape = buttonShape
when (variant) {
FnButtonVariant.Filled -> Button(
onClick = onClick,
modifier = modifier,
shape = shape,
) {
Text(text)
}
FnButtonVariant.Outlined -> OutlinedButton(
onClick = onClick,
modifier = modifier,
shape = shape,
) {
Text(text)
}
FnButtonVariant.Secondary -> Button(
onClick = onClick,
modifier = modifier,
shape = shape,
colors = ButtonDefaults.buttonColors(
containerColor = FnColors.surfaceActive,
contentColor = FnColors.text,
),
) {
Text(text)
}
FnButtonVariant.Ghost -> TextButton(
onClick = onClick,
modifier = modifier,
shape = shape,
) {
Text(text)
}
FnButtonVariant.Destructive -> Button(
onClick = onClick,
modifier = modifier,
shape = shape,
colors = ButtonDefaults.buttonColors(
containerColor = FnColors.error,
contentColor = FnColors.white,
),
) {
Text(text)
}
FnButtonVariant.Link -> TextButton(
onClick = onClick,
modifier = modifier,
shape = shape,
) {
Text(
text = text,
color = FnColors.primary,
textDecoration = TextDecoration.Underline,
)
}
}
}
// ---------------------------------------------------------------------------
// Text input
// ---------------------------------------------------------------------------
/**
* Single-line text field with optional label, placeholder and inline error.
* Stateless — the caller owns [value] / [onValueChange].
*
* When [error] is non-null, the field enters error state and the supporting
* text slot shows the error message in [FnColors.error].
*/
@Composable
fun FnTextInput(
value: String,
onValueChange: (String) -> Unit,
modifier: Modifier = Modifier,
label: String? = null,
placeholder: String? = null,
error: String? = null,
) {
OutlinedTextField(
value = value,
onValueChange = onValueChange,
modifier = modifier,
label = label?.let { { Text(it) } },
placeholder = placeholder?.let { { Text(it) } },
isError = error != null,
singleLine = true,
supportingText = error?.let {
{
Text(
text = it,
color = FnColors.error,
)
}
},
)
}
// ---------------------------------------------------------------------------
// Select (dropdown)
// ---------------------------------------------------------------------------
/**
* Exposed dropdown menu that lets the caller pick one item from [options].
* Stateless — the caller drives [selected] / [onSelected].
*
* Annotated with [ExperimentalMaterial3Api] because [ExposedDropdownMenuBox]
* is part of the experimental Material3 API surface in BOM 2024.02.00.
*/
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun FnSelect(
options: List<String>,
selected: String?,
onSelected: (String) -> Unit,
modifier: Modifier = Modifier,
label: String? = null,
) {
var expanded by remember { mutableStateOf(false) }
ExposedDropdownMenuBox(
expanded = expanded,
onExpandedChange = { expanded = it },
modifier = modifier,
) {
OutlinedTextField(
value = selected ?: "",
onValueChange = {},
readOnly = true,
label = label?.let { { Text(it) } },
trailingIcon = { ExposedDropdownMenuDefaults.TrailingIcon(expanded = expanded) },
modifier = Modifier
.menuAnchor()
.fillMaxWidth(),
)
ExposedDropdownMenu(
expanded = expanded,
onDismissRequest = { expanded = false },
) {
options.forEach { option ->
DropdownMenuItem(
text = { Text(option) },
onClick = {
onSelected(option)
expanded = false
},
)
}
}
}
}
// ---------------------------------------------------------------------------
// Switch
// ---------------------------------------------------------------------------
/**
* Labeled toggle. The [label] is rendered to the right of the [Switch] when
* non-null. Stateless — the caller owns [checked] / [onCheckedChange].
*/
@Composable
fun FnSwitch(
checked: Boolean,
onCheckedChange: (Boolean) -> Unit,
modifier: Modifier = Modifier,
label: String? = null,
) {
Row(
modifier = modifier,
horizontalArrangement = Arrangement.spacedBy(FnSpacing.sm),
verticalAlignment = Alignment.CenterVertically,
) {
Switch(
checked = checked,
onCheckedChange = onCheckedChange,
)
if (label != null) {
Text(text = label)
}
}
}
// ---------------------------------------------------------------------------
// Checkbox
// ---------------------------------------------------------------------------
/**
* Labeled checkbox. The [label] is rendered to the right of the [Checkbox]
* when non-null. Stateless — the caller owns [checked] / [onCheckedChange].
*/
@Composable
fun FnCheckbox(
checked: Boolean,
onCheckedChange: (Boolean) -> Unit,
modifier: Modifier = Modifier,
label: String? = null,
) {
Row(
modifier = modifier,
horizontalArrangement = Arrangement.spacedBy(FnSpacing.sm),
verticalAlignment = Alignment.CenterVertically,
) {
Checkbox(
checked = checked,
onCheckedChange = onCheckedChange,
)
if (label != null) {
Text(text = label)
}
}
}
// ---------------------------------------------------------------------------
// Dialog
// ---------------------------------------------------------------------------
/**
* Modal confirmation dialog.
*
* Rendered only when [open] is true. Provides "Confirmar" (calls [onConfirm])
* and "Cancelar" (calls [onDismiss]) text buttons. The caller is responsible
* for updating the [open] flag in response to those callbacks.
*/
@Composable
fun FnDialog(
open: Boolean,
onDismiss: () -> Unit,
title: String,
description: String,
onConfirm: () -> Unit,
modifier: Modifier = Modifier,
) {
if (open) {
AlertDialog(
onDismissRequest = onDismiss,
title = { Text(title) },
text = { Text(description) },
confirmButton = {
TextButton(onClick = onConfirm) { Text("Confirmar") }
},
dismissButton = {
TextButton(onClick = onDismiss) { Text("Cancelar") }
},
modifier = modifier,
)
}
}
@@ -0,0 +1,133 @@
package fn.compose.ui
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.ColumnScope
import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.RowScope
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.Scaffold
import androidx.compose.material3.Surface
import androidx.compose.material3.Text
import androidx.compose.material3.TopAppBar
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.unit.Dp
import fn.compose.theme.FnColors
import fn.compose.theme.FnRadius
import fn.compose.theme.FnShadows
import fn.compose.theme.FnSpacing
/**
* Vertical layout primitive. Wraps a [Column] with [Arrangement.spacedBy] using [gap].
*
* Equivalent to the web `<Stack>` component from `@fn_library` (Mantine Stack).
* Use to stack items with consistent vertical spacing without inline padding.
*
* @param modifier Modifier applied to the outer [Column].
* @param gap Space between children. Defaults to [FnSpacing.md] (16 dp).
* @param content Composable children provided in [ColumnScope].
*/
@Composable
fun FnStack(
modifier: Modifier = Modifier,
gap: Dp = FnSpacing.md,
content: @Composable ColumnScope.() -> Unit,
) {
Column(
modifier = modifier,
verticalArrangement = Arrangement.spacedBy(gap),
content = content,
)
}
/**
* Horizontal layout primitive. Wraps a [Row] with [Arrangement.spacedBy] using [gap]
* and vertically centers children via [Alignment.CenterVertically].
*
* Equivalent to the web `<Group>` component from `@fn_library` (Mantine Group).
* Use to place items side-by-side with consistent horizontal spacing.
*
* @param modifier Modifier applied to the outer [Row].
* @param gap Space between children. Defaults to [FnSpacing.sm] (12 dp).
* @param content Composable children provided in [RowScope].
*/
@Composable
fun FnGroup(
modifier: Modifier = Modifier,
gap: Dp = FnSpacing.sm,
content: @Composable RowScope.() -> Unit,
) {
Row(
modifier = modifier,
horizontalArrangement = Arrangement.spacedBy(gap),
verticalAlignment = Alignment.CenterVertically,
content = content,
)
}
/**
* Elevated container surface. Wraps a [Surface] with [FnColors.surface] background,
* rounded corners ([FnRadius.md]) and a subtle tonal elevation ([FnShadows.xs]).
*
* Equivalent to the web `<Paper>` component from `@fn_library` (Mantine Paper).
* Use when content needs a visual separation from the background without a heavy border.
*
* @param modifier Modifier applied to the [Surface].
* @param content Composable children provided in [ColumnScope] with [FnSpacing.md] padding.
*/
@Composable
fun FnPaper(
modifier: Modifier = Modifier,
content: @Composable ColumnScope.() -> Unit,
) {
Surface(
modifier = modifier,
color = FnColors.surface,
shape = RoundedCornerShape(FnRadius.md),
tonalElevation = FnShadows.xs,
) {
Column(
modifier = Modifier.padding(FnSpacing.md),
content = content,
)
}
}
/**
* Root application shell with a top app bar and a content slot that receives
* the inner padding from [Scaffold].
*
* Equivalent to the web `<AppShell>` component from `@fn_library`. Place this
* at the top of every screen composable so the system status bar and navigation
* insets are handled consistently.
*
* The [TopAppBar] is annotated with [ExperimentalMaterial3Api] as required by
* Compose Material 3 (BOM 2024.02.00). The opt-in is scoped to this function.
*
* @param title Text displayed in the top app bar.
* @param modifier Modifier applied to the [Scaffold].
* @param content Screen content. Receives [PaddingValues] from the scaffold and
* is responsible for applying them (e.g. via [Modifier.padding]).
*/
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun FnAppShell(
title: String,
modifier: Modifier = Modifier,
content: @Composable (PaddingValues) -> Unit,
) {
Scaffold(
modifier = modifier,
topBar = {
TopAppBar(
title = { Text(text = title) },
)
},
content = content,
)
}
@@ -0,0 +1,32 @@
---
name: fn_alert
kind: component
lang: kt
domain: ui
version: "0.1.0"
framework: compose
purity: impure
signature: "@Composable fun FnAlert(text: String, modifier: Modifier = Modifier, title: String? = null, variant: FnAlertVariant = FnAlertVariant.Info)"
description: "Bloque de aviso con 4 variantes semanticas (Info/Success/Warning/Error), fondo y borde del color."
tags: [compose, android, ui, feedback, design-system]
props: []
emits: []
uses_functions: []
uses_types: []
returns: []
returns_optional: false
error_type: ""
imports: []
output: "Composable del design system @fn_compose. Emite UI con la identidad del registry (paleta Mantine v9 dark + indigo via FnTheme/FnTokens)."
file_path: "kotlin/functions/ui/src/main/kotlin/fn/compose/ui/Feedback.kt"
---
## Ejemplo
```kotlin
FnAlert("Guardado", title = "Success", variant = FnAlertVariant.Success)
```
## Cuando usarla
Para mensajes contextuales de estado dentro de una pantalla. Importa desde `fn.compose.ui`; requiere estar dentro de un `FnTheme {}`.
@@ -0,0 +1,34 @@
---
name: fn_app_shell
kind: component
lang: kt
domain: ui
version: "0.1.0"
framework: compose
purity: impure
signature: "@Composable fun FnAppShell(title: String, modifier: Modifier = Modifier, content: @Composable (PaddingValues) -> Unit)"
description: "Scaffold con TopAppBar (titulo) que envuelve el contenido principal de una pantalla. Equivalente de <AppShell> de Mantine."
tags: [compose, android, ui, layout, design-system]
props: []
emits: []
uses_functions: []
uses_types: []
returns: []
returns_optional: false
error_type: ""
imports: []
output: "Composable del design system @fn_compose. Emite UI con la identidad del registry (paleta Mantine v9 dark + indigo via FnTheme/FnTokens)."
file_path: "kotlin/functions/ui/src/main/kotlin/fn/compose/ui/Layout.kt"
---
## Ejemplo
```kotlin
FnAppShell(title = "Mi App") { padding ->
FnStack(Modifier.padding(padding)) { /* ... */ }
}
```
## Cuando usarla
Como contenedor raiz de cada pantalla de la app, una vez dentro de FnTheme. Importa desde `fn.compose.ui`; requiere estar dentro de un `FnTheme {}`.
@@ -0,0 +1,32 @@
---
name: fn_avatar
kind: component
lang: kt
domain: ui
version: "0.1.0"
framework: compose
purity: impure
signature: "@Composable fun FnAvatar(initials: String, modifier: Modifier = Modifier, size: FnAvatarSize = FnAvatarSize.Md)"
description: "Circulo con iniciales sobre fondo primary, tamanos Sm (28dp), Md (40dp), Lg (56dp)."
tags: [compose, android, ui, display, design-system]
props: []
emits: []
uses_functions: []
uses_types: []
returns: []
returns_optional: false
error_type: ""
imports: []
output: "Composable del design system @fn_compose. Emite UI con la identidad del registry (paleta Mantine v9 dark + indigo via FnTheme/FnTokens)."
file_path: "kotlin/functions/ui/src/main/kotlin/fn/compose/ui/Display.kt"
---
## Ejemplo
```kotlin
FnAvatar("EG", size = FnAvatarSize.Md)
```
## Cuando usarla
Para representar usuarios o entidades por iniciales cuando no hay imagen. Importa desde `fn.compose.ui`; requiere estar dentro de un `FnTheme {}`.
@@ -0,0 +1,32 @@
---
name: fn_badge
kind: component
lang: kt
domain: ui
version: "0.1.0"
framework: compose
purity: impure
signature: "@Composable fun FnBadge(text: String, modifier: Modifier = Modifier, color: FnBadgeColor = FnBadgeColor.Brand)"
description: "Pill de estado con 6 colores semanticos (Brand/Gray/Green/Red/Yellow/Blue), fondo tenue + texto del color."
tags: [compose, android, ui, display, design-system]
props: []
emits: []
uses_functions: []
uses_types: []
returns: []
returns_optional: false
error_type: ""
imports: []
output: "Composable del design system @fn_compose. Emite UI con la identidad del registry (paleta Mantine v9 dark + indigo via FnTheme/FnTokens)."
file_path: "kotlin/functions/ui/src/main/kotlin/fn/compose/ui/Display.kt"
---
## Ejemplo
```kotlin
FnBadge("ACTIVE", color = FnBadgeColor.Green)
```
## Cuando usarla
Para etiquetar estados, categorias o tags cortos. Importa desde `fn.compose.ui`; requiere estar dentro de un `FnTheme {}`.
@@ -0,0 +1,32 @@
---
name: fn_bar_chart
kind: component
lang: kt
domain: ui
version: "0.1.0"
framework: compose
purity: impure
signature: "@Composable fun FnBarChart(data: List<FnBarItem>, modifier: Modifier = Modifier)"
description: "Grafico de barras verticales Canvas con labels. Define FnBarItem(label, value)."
tags: [compose, android, ui, charts, design-system]
props: []
emits: []
uses_functions: []
uses_types: []
returns: []
returns_optional: false
error_type: ""
imports: []
output: "Composable del design system @fn_compose. Emite UI con la identidad del registry (paleta Mantine v9 dark + indigo via FnTheme/FnTokens)."
file_path: "kotlin/functions/ui/src/main/kotlin/fn/compose/ui/Charts.kt"
---
## Ejemplo
```kotlin
FnBarChart(data = listOf(FnBarItem("Lun", 12f), FnBarItem("Mar", 19f)))
```
## Cuando usarla
Para comparar valores discretos por categoria. Importa desde `fn.compose.ui`; requiere estar dentro de un `FnTheme {}`.
@@ -0,0 +1,33 @@
---
name: fn_button
kind: component
lang: kt
domain: ui
version: "0.1.0"
framework: compose
purity: impure
signature: "@Composable fun FnButton(text: String, onClick: () -> Unit, modifier: Modifier = Modifier, variant: FnButtonVariant = FnButtonVariant.Filled)"
description: "Boton con 6 variantes: Filled, Outlined, Secondary, Ghost, Destructive, Link. Mirror de los variants de Mantine."
tags: [compose, android, ui, inputs, design-system]
props: []
emits: []
uses_functions: []
uses_types: []
returns: []
returns_optional: false
error_type: ""
imports: []
output: "Composable del design system @fn_compose. Emite UI con la identidad del registry (paleta Mantine v9 dark + indigo via FnTheme/FnTokens)."
file_path: "kotlin/functions/ui/src/main/kotlin/fn/compose/ui/Inputs.kt"
---
## Ejemplo
```kotlin
FnButton("Enviar", onClick = { /* ... */ })
FnButton("Borrar", onClick = {}, variant = FnButtonVariant.Destructive)
```
## Cuando usarla
Para cualquier accion; elige variant segun jerarquia/semantica. Importa desde `fn.compose.ui`; requiere estar dentro de un `FnTheme {}`.
@@ -0,0 +1,34 @@
---
name: fn_card
kind: component
lang: kt
domain: ui
version: "0.1.0"
framework: compose
purity: impure
signature: "@Composable fun FnCard(modifier: Modifier = Modifier, variant: FnCardVariant = FnCardVariant.Default, content: @Composable ColumnScope.() -> Unit)"
description: "Tarjeta contenedora con tres variantes: Default (borde + sombra), Borderless (solo fondo), Ghost (transparente)."
tags: [compose, android, ui, display, design-system]
props: []
emits: []
uses_functions: []
uses_types: []
returns: []
returns_optional: false
error_type: ""
imports: []
output: "Composable del design system @fn_compose. Emite UI con la identidad del registry (paleta Mantine v9 dark + indigo via FnTheme/FnTokens)."
file_path: "kotlin/functions/ui/src/main/kotlin/fn/compose/ui/Display.kt"
---
## Ejemplo
```kotlin
FnCard(variant = FnCardVariant.Default) {
FnText("Dentro de la card")
}
```
## Cuando usarla
Para agrupar contenido relacionado con enfasis visual. Importa desde `fn.compose.ui`; requiere estar dentro de un `FnTheme {}`.
@@ -0,0 +1,32 @@
---
name: fn_checkbox
kind: component
lang: kt
domain: ui
version: "0.1.0"
framework: compose
purity: impure
signature: "@Composable fun FnCheckbox(checked: Boolean, onCheckedChange: (Boolean) -> Unit, modifier: Modifier = Modifier, label: String? = null)"
description: "Checkbox con label opcional. Stateless."
tags: [compose, android, ui, inputs, design-system]
props: []
emits: []
uses_functions: []
uses_types: []
returns: []
returns_optional: false
error_type: ""
imports: []
output: "Composable del design system @fn_compose. Emite UI con la identidad del registry (paleta Mantine v9 dark + indigo via FnTheme/FnTokens)."
file_path: "kotlin/functions/ui/src/main/kotlin/fn/compose/ui/Inputs.kt"
---
## Ejemplo
```kotlin
FnCheckbox(checked = ok, onCheckedChange = { ok = it }, label = "Acepto")
```
## Cuando usarla
Para seleccion booleana en formularios y listas. Importa desde `fn.compose.ui`; requiere estar dentro de un `FnTheme {}`.
@@ -0,0 +1,35 @@
---
name: fn_data_table
kind: component
lang: kt
domain: ui
version: "0.1.0"
framework: compose
purity: impure
signature: "@Composable fun <T> FnDataTable(rows: List<T>, columns: List<FnTableColumn<T>>, modifier: Modifier = Modifier)"
description: "Tabla generica por columnas (FnTableColumn: header, weight, cell). Column estatica, cada celda es un Composable. Define FnTableColumn<T>."
tags: [compose, android, ui, data, design-system]
props: []
emits: []
uses_functions: []
uses_types: []
returns: []
returns_optional: false
error_type: ""
imports: []
output: "Composable del design system @fn_compose. Emite UI con la identidad del registry (paleta Mantine v9 dark + indigo via FnTheme/FnTokens)."
file_path: "kotlin/functions/ui/src/main/kotlin/fn/compose/ui/Data.kt"
---
## Ejemplo
```kotlin
FnDataTable(rows = users, columns = listOf(
FnTableColumn("Nombre", 2f) { FnText(it.name) },
FnTableColumn("Estado", 1f) { FnBadge(it.status) },
))
```
## Cuando usarla
Para mostrar datos tabulares con celdas personalizadas. No anidar en verticalScroll con muchas filas. Importa desde `fn.compose.ui`; requiere estar dentro de un `FnTheme {}`.
@@ -0,0 +1,32 @@
---
name: fn_dialog
kind: component
lang: kt
domain: ui
version: "0.1.0"
framework: compose
purity: impure
signature: "@Composable fun FnDialog(open: Boolean, onDismiss: () -> Unit, title: String, description: String, onConfirm: () -> Unit, modifier: Modifier = Modifier)"
description: "Modal AlertDialog con titulo, descripcion y botones Confirmar/Cancelar. Se renderiza solo si open."
tags: [compose, android, ui, inputs, design-system]
props: []
emits: []
uses_functions: []
uses_types: []
returns: []
returns_optional: false
error_type: ""
imports: []
output: "Composable del design system @fn_compose. Emite UI con la identidad del registry (paleta Mantine v9 dark + indigo via FnTheme/FnTokens)."
file_path: "kotlin/functions/ui/src/main/kotlin/fn/compose/ui/Inputs.kt"
---
## Ejemplo
```kotlin
FnDialog(open = show, onDismiss = { show = false }, title = "Confirmar", description = "Seguro?", onConfirm = { show = false })
```
## Cuando usarla
Para confirmaciones y dialogos modales bloqueantes. Importa desde `fn.compose.ui`; requiere estar dentro de un `FnTheme {}`.
@@ -0,0 +1,32 @@
---
name: fn_empty_state
kind: component
lang: kt
domain: ui
version: "0.1.0"
framework: compose
purity: impure
signature: "@Composable fun FnEmptyState(title: String, description: String, modifier: Modifier = Modifier, icon: String? = null, action: (@Composable () -> Unit)? = null)"
description: "Estado vacio centrado con icono (emoji), titulo, descripcion y accion opcional."
tags: [compose, android, ui, feedback, design-system]
props: []
emits: []
uses_functions: []
uses_types: []
returns: []
returns_optional: false
error_type: ""
imports: []
output: "Composable del design system @fn_compose. Emite UI con la identidad del registry (paleta Mantine v9 dark + indigo via FnTheme/FnTokens)."
file_path: "kotlin/functions/ui/src/main/kotlin/fn/compose/ui/Feedback.kt"
---
## Ejemplo
```kotlin
FnEmptyState(title = "Sin datos", description = "Anade el primero", icon = "📭")
```
## Cuando usarla
Para listas/pantallas sin contenido todavia. Importa desde `fn.compose.ui`; requiere estar dentro de un `FnTheme {}`.
@@ -0,0 +1,35 @@
---
name: fn_group
kind: component
lang: kt
domain: ui
version: "0.1.0"
framework: compose
purity: impure
signature: "@Composable fun FnGroup(modifier: Modifier = Modifier, gap: Dp = FnSpacing.sm, content: @Composable RowScope.() -> Unit)"
description: "Row con separacion uniforme entre hijos y alineacion vertical centrada. Equivalente Compose de <Group> de Mantine."
tags: [compose, android, ui, layout, design-system]
props: []
emits: []
uses_functions: []
uses_types: []
returns: []
returns_optional: false
error_type: ""
imports: []
output: "Composable del design system @fn_compose. Emite UI con la identidad del registry (paleta Mantine v9 dark + indigo via FnTheme/FnTokens)."
file_path: "kotlin/functions/ui/src/main/kotlin/fn/compose/ui/Layout.kt"
---
## Ejemplo
```kotlin
FnGroup {
FnButton("Guardar", onClick = {})
FnButton("Cancelar", onClick = {}, variant = FnButtonVariant.Ghost)
}
```
## Cuando usarla
Para alinear elementos en fila (botones, badges) con separacion uniforme. Importa desde `fn.compose.ui`; requiere estar dentro de un `FnTheme {}`.
@@ -0,0 +1,32 @@
---
name: fn_kpi_card
kind: component
lang: kt
domain: ui
version: "0.1.0"
framework: compose
purity: impure
signature: "@Composable fun FnKpiCard(label: String, value: String, modifier: Modifier = Modifier, delta: String? = null, deltaPositive: Boolean = true, sparklineData: List<Float>? = null)"
description: "Tarjeta de metrica con label, valor grande, delta coloreado y sparkline inline opcional."
tags: [compose, android, ui, data, design-system]
props: []
emits: []
uses_functions: []
uses_types: []
returns: []
returns_optional: false
error_type: ""
imports: []
output: "Composable del design system @fn_compose. Emite UI con la identidad del registry (paleta Mantine v9 dark + indigo via FnTheme/FnTokens)."
file_path: "kotlin/functions/ui/src/main/kotlin/fn/compose/ui/Data.kt"
---
## Ejemplo
```kotlin
FnKpiCard(label = "Revenue", value = "42k", delta = "+12%", sparklineData = listOf(1f,3f,2f,5f))
```
## Cuando usarla
Para dashboards: mostrar un KPI con tendencia. Importa desde `fn.compose.ui`; requiere estar dentro de un `FnTheme {}`.
@@ -0,0 +1,32 @@
---
name: fn_line_chart
kind: component
lang: kt
domain: ui
version: "0.1.0"
framework: compose
purity: impure
signature: "@Composable fun FnLineChart(data: List<Float>, modifier: Modifier = Modifier)"
description: "Grafico de linea Canvas con relleno de area, sin dependencias externas. Normaliza la serie automaticamente."
tags: [compose, android, ui, charts, design-system]
props: []
emits: []
uses_functions: []
uses_types: []
returns: []
returns_optional: false
error_type: ""
imports: []
output: "Composable del design system @fn_compose. Emite UI con la identidad del registry (paleta Mantine v9 dark + indigo via FnTheme/FnTokens)."
file_path: "kotlin/functions/ui/src/main/kotlin/fn/compose/ui/Charts.kt"
---
## Ejemplo
```kotlin
FnLineChart(data = listOf(10f,14f,12f,18f,22f), modifier = Modifier.fillMaxWidth())
```
## Cuando usarla
Para series temporales o tendencias continuas. Importa desde `fn.compose.ui`; requiere estar dentro de un `FnTheme {}`.
@@ -0,0 +1,32 @@
---
name: fn_loader
kind: component
lang: kt
domain: ui
version: "0.1.0"
framework: compose
purity: impure
signature: "@Composable fun FnLoader(modifier: Modifier = Modifier, size: FnLoaderSize = FnLoaderSize.Md)"
description: "Spinner CircularProgressIndicator en color primary, tamanos Sm/Md/Lg."
tags: [compose, android, ui, feedback, design-system]
props: []
emits: []
uses_functions: []
uses_types: []
returns: []
returns_optional: false
error_type: ""
imports: []
output: "Composable del design system @fn_compose. Emite UI con la identidad del registry (paleta Mantine v9 dark + indigo via FnTheme/FnTokens)."
file_path: "kotlin/functions/ui/src/main/kotlin/fn/compose/ui/Feedback.kt"
---
## Ejemplo
```kotlin
FnLoader(size = FnLoaderSize.Md)
```
## Cuando usarla
Para indicar carga indeterminada. Importa desde `fn.compose.ui`; requiere estar dentro de un `FnTheme {}`.
@@ -0,0 +1,32 @@
---
name: fn_page_header
kind: component
lang: kt
domain: ui
version: "0.1.0"
framework: compose
purity: impure
signature: "@Composable fun FnPageHeader(title: String, modifier: Modifier = Modifier, subtitle: String? = null, actions: (@Composable () -> Unit)? = null)"
description: "Cabecera de seccion con titulo, subtitulo opcional y slot de acciones a la derecha."
tags: [compose, android, ui, data, design-system]
props: []
emits: []
uses_functions: []
uses_types: []
returns: []
returns_optional: false
error_type: ""
imports: []
output: "Composable del design system @fn_compose. Emite UI con la identidad del registry (paleta Mantine v9 dark + indigo via FnTheme/FnTokens)."
file_path: "kotlin/functions/ui/src/main/kotlin/fn/compose/ui/Data.kt"
---
## Ejemplo
```kotlin
FnPageHeader("Usuarios", subtitle = "Listado activo", actions = { FnButton("Nuevo", onClick = {}) })
```
## Cuando usarla
Al inicio de cada seccion/pantalla para titular y exponer acciones. Importa desde `fn.compose.ui`; requiere estar dentro de un `FnTheme {}`.
@@ -0,0 +1,34 @@
---
name: fn_paper
kind: component
lang: kt
domain: ui
version: "0.1.0"
framework: compose
purity: impure
signature: "@Composable fun FnPaper(modifier: Modifier = Modifier, content: @Composable ColumnScope.() -> Unit)"
description: "Contenedor Surface minimal con radius y sombra leves, fondo surface del tema. Mas ligero que FnCard."
tags: [compose, android, ui, layout, design-system]
props: []
emits: []
uses_functions: []
uses_types: []
returns: []
returns_optional: false
error_type: ""
imports: []
output: "Composable del design system @fn_compose. Emite UI con la identidad del registry (paleta Mantine v9 dark + indigo via FnTheme/FnTokens)."
file_path: "kotlin/functions/ui/src/main/kotlin/fn/compose/ui/Layout.kt"
---
## Ejemplo
```kotlin
FnPaper(modifier = Modifier.fillMaxWidth()) {
FnText("Contenido")
}
```
## Cuando usarla
Para agrupar contenido en una superficie elevada sin el borde marcado de FnCard. Importa desde `fn.compose.ui`; requiere estar dentro de un `FnTheme {}`.
@@ -0,0 +1,32 @@
---
name: fn_select
kind: component
lang: kt
domain: ui
version: "0.1.0"
framework: compose
purity: impure
signature: "@Composable fun FnSelect(options: List<String>, selected: String?, onSelected: (String) -> Unit, modifier: Modifier = Modifier, label: String? = null)"
description: "Dropdown ExposedDropdownMenu sobre una lista de opciones string. Stateless respecto a la seleccion."
tags: [compose, android, ui, inputs, design-system]
props: []
emits: []
uses_functions: []
uses_types: []
returns: []
returns_optional: false
error_type: ""
imports: []
output: "Composable del design system @fn_compose. Emite UI con la identidad del registry (paleta Mantine v9 dark + indigo via FnTheme/FnTokens)."
file_path: "kotlin/functions/ui/src/main/kotlin/fn/compose/ui/Inputs.kt"
---
## Ejemplo
```kotlin
FnSelect(options = listOf("A","B"), selected = sel, onSelected = { sel = it }, label = "Tipo")
```
## Cuando usarla
Para elegir una opcion de una lista cerrada. Importa desde `fn.compose.ui`; requiere estar dentro de un `FnTheme {}`.
@@ -0,0 +1,32 @@
---
name: fn_skeleton
kind: component
lang: kt
domain: ui
version: "0.1.0"
framework: compose
purity: impure
signature: "@Composable fun FnSkeleton(modifier: Modifier = Modifier, height: Dp = 16.dp)"
description: "Placeholder animado (shimmer) para contenido en carga. Interpola entre surfaceHover y surfaceActive."
tags: [compose, android, ui, feedback, design-system]
props: []
emits: []
uses_functions: []
uses_types: []
returns: []
returns_optional: false
error_type: ""
imports: []
output: "Composable del design system @fn_compose. Emite UI con la identidad del registry (paleta Mantine v9 dark + indigo via FnTheme/FnTokens)."
file_path: "kotlin/functions/ui/src/main/kotlin/fn/compose/ui/Feedback.kt"
---
## Ejemplo
```kotlin
FnSkeleton(height = 20.dp)
```
## Cuando usarla
Para esqueletos de carga antes de que lleguen los datos. Importa desde `fn.compose.ui`; requiere estar dentro de un `FnTheme {}`.
@@ -0,0 +1,32 @@
---
name: fn_sparkline
kind: component
lang: kt
domain: ui
version: "0.1.0"
framework: compose
purity: impure
signature: "@Composable fun FnSparkline(data: List<Float>, modifier: Modifier = Modifier)"
description: "Mini grafico de linea inline (sin ejes ni labels) para incrustar en KPIs, tablas o filas."
tags: [compose, android, ui, charts, design-system]
props: []
emits: []
uses_functions: []
uses_types: []
returns: []
returns_optional: false
error_type: ""
imports: []
output: "Composable del design system @fn_compose. Emite UI con la identidad del registry (paleta Mantine v9 dark + indigo via FnTheme/FnTokens)."
file_path: "kotlin/functions/ui/src/main/kotlin/fn/compose/ui/Charts.kt"
---
## Ejemplo
```kotlin
FnSparkline(listOf(20f,35f,28f,42f,38f))
```
## Cuando usarla
Para tendencias compactas dentro de otra fila/tarjeta. Importa desde `fn.compose.ui`; requiere estar dentro de un `FnTheme {}`.
@@ -0,0 +1,35 @@
---
name: fn_stack
kind: component
lang: kt
domain: ui
version: "0.1.0"
framework: compose
purity: impure
signature: "@Composable fun FnStack(modifier: Modifier = Modifier, gap: Dp = FnSpacing.md, content: @Composable ColumnScope.() -> Unit)"
description: "Column con separacion uniforme entre hijos (Arrangement.spacedBy(gap)). Equivalente Compose de <Stack> de Mantine."
tags: [compose, android, ui, layout, design-system]
props: []
emits: []
uses_functions: []
uses_types: []
returns: []
returns_optional: false
error_type: ""
imports: []
output: "Composable del design system @fn_compose. Emite UI con la identidad del registry (paleta Mantine v9 dark + indigo via FnTheme/FnTokens)."
file_path: "kotlin/functions/ui/src/main/kotlin/fn/compose/ui/Layout.kt"
---
## Ejemplo
```kotlin
FnStack(gap = FnSpacing.md) {
FnText("Linea 1")
FnText("Linea 2")
}
```
## Cuando usarla
Para apilar elementos en vertical con separacion consistente sin repetir Spacers. Importa desde `fn.compose.ui`; requiere estar dentro de un `FnTheme {}`.
@@ -0,0 +1,32 @@
---
name: fn_switch
kind: component
lang: kt
domain: ui
version: "0.1.0"
framework: compose
purity: impure
signature: "@Composable fun FnSwitch(checked: Boolean, onCheckedChange: (Boolean) -> Unit, modifier: Modifier = Modifier, label: String? = null)"
description: "Toggle Switch con label opcional a la derecha. Stateless."
tags: [compose, android, ui, inputs, design-system]
props: []
emits: []
uses_functions: []
uses_types: []
returns: []
returns_optional: false
error_type: ""
imports: []
output: "Composable del design system @fn_compose. Emite UI con la identidad del registry (paleta Mantine v9 dark + indigo via FnTheme/FnTokens)."
file_path: "kotlin/functions/ui/src/main/kotlin/fn/compose/ui/Inputs.kt"
---
## Ejemplo
```kotlin
FnSwitch(checked = on, onCheckedChange = { on = it }, label = "Activado")
```
## Cuando usarla
Para opciones booleanas de activado/desactivado. Importa desde `fn.compose.ui`; requiere estar dentro de un `FnTheme {}`.
@@ -0,0 +1,32 @@
---
name: fn_tabs
kind: component
lang: kt
domain: ui
version: "0.1.0"
framework: compose
purity: impure
signature: "@Composable fun FnTabs(tabs: List<String>, selectedIndex: Int, onTabSelected: (Int) -> Unit, modifier: Modifier = Modifier, scrollable: Boolean = false)"
description: "Barra de pestanas TabRow (o ScrollableTabRow si scrollable). Stateless respecto al indice activo."
tags: [compose, android, ui, feedback, design-system]
props: []
emits: []
uses_functions: []
uses_types: []
returns: []
returns_optional: false
error_type: ""
imports: []
output: "Composable del design system @fn_compose. Emite UI con la identidad del registry (paleta Mantine v9 dark + indigo via FnTheme/FnTokens)."
file_path: "kotlin/functions/ui/src/main/kotlin/fn/compose/ui/Feedback.kt"
---
## Ejemplo
```kotlin
FnTabs(tabs = cats, selectedIndex = tab, onTabSelected = { tab = it }, scrollable = true)
```
## Cuando usarla
Para navegar entre secciones/categorias dentro de una pantalla. Importa desde `fn.compose.ui`; requiere estar dentro de un `FnTheme {}`.
@@ -0,0 +1,32 @@
---
name: fn_text
kind: component
lang: kt
domain: ui
version: "0.1.0"
framework: compose
purity: impure
signature: "@Composable fun FnText(text: String, modifier: Modifier = Modifier, size: FnTextSize = FnTextSize.Md, color: Color = Color.Unspecified)"
description: "Texto de cuerpo con escala de tamanos Xs/Sm/Md/Lg/Xl via FnTypography. Color onSurface por defecto."
tags: [compose, android, ui, display, design-system]
props: []
emits: []
uses_functions: []
uses_types: []
returns: []
returns_optional: false
error_type: ""
imports: []
output: "Composable del design system @fn_compose. Emite UI con la identidad del registry (paleta Mantine v9 dark + indigo via FnTheme/FnTokens)."
file_path: "kotlin/functions/ui/src/main/kotlin/fn/compose/ui/Display.kt"
---
## Ejemplo
```kotlin
FnText("Hola", size = FnTextSize.Lg)
```
## Cuando usarla
Para todo texto de cuerpo; no uses Text de Material directo. Importa desde `fn.compose.ui`; requiere estar dentro de un `FnTheme {}`.
@@ -0,0 +1,33 @@
---
name: fn_text_input
kind: component
lang: kt
domain: ui
version: "0.1.0"
framework: compose
purity: impure
signature: "@Composable fun FnTextInput(value: String, onValueChange: (String) -> Unit, modifier: Modifier = Modifier, label: String? = null, placeholder: String? = null, error: String? = null)"
description: "Campo de texto OutlinedTextField con label, placeholder y estado de error opcionales. Stateless."
tags: [compose, android, ui, inputs, design-system]
props: []
emits: []
uses_functions: []
uses_types: []
returns: []
returns_optional: false
error_type: ""
imports: []
output: "Composable del design system @fn_compose. Emite UI con la identidad del registry (paleta Mantine v9 dark + indigo via FnTheme/FnTokens)."
file_path: "kotlin/functions/ui/src/main/kotlin/fn/compose/ui/Inputs.kt"
---
## Ejemplo
```kotlin
var v by remember { mutableStateOf("") }
FnTextInput(value = v, onValueChange = { v = it }, label = "Nombre")
```
## Cuando usarla
Para entrada de texto de una linea con validacion visual. Importa desde `fn.compose.ui`; requiere estar dentro de un `FnTheme {}`.
@@ -0,0 +1,32 @@
---
name: fn_title
kind: component
lang: kt
domain: ui
version: "0.1.0"
framework: compose
purity: impure
signature: "@Composable fun FnTitle(text: String, modifier: Modifier = Modifier, order: Int = 1)"
description: "Encabezado con jerarquia order 1..6 (h1..h6 de FnTypography), peso bold/semibold."
tags: [compose, android, ui, display, design-system]
props: []
emits: []
uses_functions: []
uses_types: []
returns: []
returns_optional: false
error_type: ""
imports: []
output: "Composable del design system @fn_compose. Emite UI con la identidad del registry (paleta Mantine v9 dark + indigo via FnTheme/FnTokens)."
file_path: "kotlin/functions/ui/src/main/kotlin/fn/compose/ui/Display.kt"
---
## Ejemplo
```kotlin
FnTitle("Seccion", order = 2)
```
## Cuando usarla
Para titulos de seccion y headings jerarquicos. Importa desde `fn.compose.ui`; requiere estar dentro de un `FnTheme {}`.
@@ -0,0 +1,95 @@
---
name: tee_anthropic_sse
kind: function
lang: py
domain: cybersecurity
version: "1.0.0"
purity: impure
signature: "class AnthropicSSETee — mitmproxy addon loaded via mitmdump -s"
description: "Addon de mitmproxy que intercepta el stream SSE de la API de Anthropic (/v1/messages) y emite cada evento significativo a stdout como NDJSON en tiempo real. Cada interaccion de la CLI claude dispara una o varias llamadas a /v1/messages; el addon las etiqueta con stream_id, model y has_tools para que el consumidor pueda distinguir la respuesta principal (claude-opus-X con tools) de las auxiliares (titulo/clasificador en haiku sin tools). Las funciones puras split_sse_events y event_to_ndjson son testeables sin mitmproxy."
tags: [web-proxy, claude, mitmproxy, sse, streaming, anthropic, cybersecurity]
uses_functions: []
uses_types: []
returns: []
returns_optional: false
error_type: "error_go_core"
imports: ["json", "os", "sys", "mitmproxy"]
tested: true
tests:
- "split buffer completo devuelve 8 bloques"
- "split bloques contienen event y data"
- "split buffer cortado preserva incompleto"
- "split resto mas continuacion reconstruye evento"
- "split buffer vacio"
- "split evento unico sin separador final"
- "text delta p"
- "text delta ong"
- "message stop con stop holder previo"
- "ping devuelve lista vacia"
- "content block start text devuelve vacio"
- "content block start tool use"
- "tool json delta"
- "json invalido en data devuelve vacio"
- "bloque sin data devuelve vacio"
- "integracion secuencia completa produce pong y stop"
- "integracion stream id se propaga"
- "integracion determinismo"
test_file_path: "python/functions/cybersecurity/tests/test_tee_anthropic_sse.py"
file_path: "python/functions/cybersecurity/tee_anthropic_sse.py"
params:
- name: mitmdump_invocation
desc: "No recibe argumentos directos. Se carga con `mitmdump -s tee_anthropic_sse.py`. El puerto del proxy se controla con el flag -p de mitmdump (ej. -p 8901). La flag -q suprime el log de mitmdump en stderr dejando solo el NDJSON en stdout."
- name: FN_WIRE_ONLY_TOOLS
desc: "Variable de entorno opcional. Si vale '1', suprime los streams cuyo request body no incluye el array 'tools' (llamadas auxiliares de titulo/clasificador que usan haiku). Por defecto (sin la env) emite todos los streams etiquetados con stream_id, model y has_tools para que el consumidor filtre."
output: "NDJSON a stdout, un objeto JSON por linea (flush inmediato). Tipos de linea: message_start{stream_id,model,has_tools} al inicio de cada stream; text_delta{stream_id,text} por cada fragmento de texto del modelo; tool_use_start{stream_id,tool_name,tool_id} cuando el modelo inicia una herramienta; tool_json_delta{stream_id,partial_json} por cada fragmento de argumentos JSON de la herramienta; message_stop{stream_id,stop_reason} al finalizar el stream. stderr recibe solo mensajes de diagnóstico del addon (errores, warnings), nunca NDJSON."
---
## Ejemplo
```bash
# Terminal 1: lanzar mitmproxy como proxy de interceptacion
# -q suprime el log de mitmdump; solo se ve el NDJSON en stdout
mitmdump -p 8901 \
-s python/functions/cybersecurity/tee_anthropic_sse.py \
-q
# Terminal 2: lanzar claude por el proxy
# NODE_EXTRA_CA_CERTS hace que el runtime Node de claude confie en la CA de mitmproxy
export HTTPS_PROXY=http://127.0.0.1:8901
export NODE_EXTRA_CA_CERTS="$HOME/.mitmproxy/mitmproxy-ca-cert.pem"
claude -p "di hola"
# Salida en stdout de mitmdump (Terminal 1):
# {"type": "message_start", "stream_id": 1, "model": "claude-haiku-4-5", "has_tools": false}
# {"type": "text_delta", "stream_id": 1, "text": "H"}
# {"type": "text_delta", "stream_id": 1, "text": "ola"}
# {"type": "message_stop", "stream_id": 1, "stop_reason": "end_turn"}
# ...
# {"type": "message_start", "stream_id": 2, "model": "claude-opus-4-8", "has_tools": true}
# {"type": "text_delta", "stream_id": 2, "text": "Hola"}
# ...
# Filtrar solo la respuesta principal (has_tools=true) con jq:
mitmdump -p 8901 -s python/functions/cybersecurity/tee_anthropic_sse.py -q \
| jq -c 'select(.has_tools == true or .stream_id != null and (.type == "text_delta"))'
# O usar la variable de entorno para que el addon ya filtre en origen:
FN_WIRE_ONLY_TOOLS=1 mitmdump -p 8901 \
-s python/functions/cybersecurity/tee_anthropic_sse.py -q
```
## Cuando usarla
Cuando quieras el texto exacto que el modelo genera en tiempo real desde una sesion claude (TUI interactiva o `claude -p`), interceptando la red, sin parsear el render de la terminal ni depender de warmup/idle de la TUI. Util para: capturar la salida del modelo para procesado downstream (logging estructurado, metricas de tokens, replay), observar tool_use en construccion (argumentos parciales), o depurar la diferencia entre streams principales y auxiliares en una misma sesion TUI.
## Gotchas
- **Descompresion via strip de Accept-Encoding**: el hook `request` elimina el header `Accept-Encoding` de las llamadas a `/v1/messages` para que la API responda con el SSE SIN comprimir. Esto es obligatorio: el modo streaming de mitmproxy (`flow.response.stream`) entrega al tee los bytes CRUDOS del cuerpo, que si vinieran con `Content-Encoding: gzip`/`br` nunca contendrian el delimitador `\n\n` de eventos SSE (se veria binario) y no se emitiria ningun delta. Verificado empiricamente el 2026-06-04: sin el strip, solo se emitia `message_start`; con el strip, los `text_delta` salen correctamente. La alternativa (un decompresor de streaming con estado por flujo) es mas fragil. El coste es unos bytes extra en el salto local, irrelevante.
- **NO usar `--set stream_large_bodies`**: el modo streaming se activa con `flow.response.stream = func` en `responseheaders`, sin necesidad de ese flag. Ademas `stream_large_bodies=N` bajo rompe el acceso a `flow.request.content` (necesario para `has_tools`), porque tambien streamea el cuerpo del request y deja de bufferearlo.
- **Requiere mitmproxy + CA confiada por claude**: la CA de mitmproxy (`~/.mitmproxy/mitmproxy-ca-cert.pem`) debe estar configurada en `NODE_EXTRA_CA_CERTS` para que el runtime Node de la CLI claude acepte el certificado MITM. Sin esto, claude rechaza la conexion con error de TLS. Instalar mitmproxy: `uv tool install mitmproxy` o `pip install mitmproxy`. claude tambien respeta `HTTPS_PROXY` para enrutar su trafico por el proxy.
- **Una interaccion TUI dispara varias /v1/messages**: la respuesta real del usuario usa el modelo principal (p.ej. claude-opus-4-8) y su request body incluye el array `tools` con las herramientas de Claude Code. Las llamadas auxiliares (generador de titulo, clasificador) usan claude-haiku y su request NO lleva `tools`. Usa `has_tools=true` o `FN_WIRE_ONLY_TOOLS=1` para aislar la respuesta principal y no mezclar streams.
- **Solo funciona mientras claude no haga TLS pinning**: hoy (2026-06-04) la CLI claude no hace certificate pinning, por lo que el MITM funciona con `NODE_EXTRA_CA_CERTS`. Si una version futura de claude añade pinning, el addon dejara de interceptar.
- **Es trafico de tu propia cuenta y maquina**: el addon captura unicamente el trafico local que tu proxy intercepta. No hay acceso a otras cuentas ni sesiones remotas. El NDJSON se emite solo a stdout local.
- **El endpoint puede cambiar**: la CLI claude hoy usa `POST /v1/messages?beta=true`. El addon filtra por prefix `/v1/messages` para tolerar variantes de query string, pero si Anthropic cambia la ruta base en versiones futuras del protocolo, actualizar el check en `responseheaders`.
- **Chunks parciales**: el addon mantiene un buffer por stream para manejar eventos SSE partidos entre chunks TCP. Si mitmdump se mata con SIGKILL durante un stream activo, el ultimo bloque incompleto del buffer se descarta (no se emite un message_stop artificial).
- **stdout debe ser exclusivamente NDJSON**: no añadir prints de debug a stdout; redirigir diagnosticos a stderr. Si se canaliza la salida a `jq` u otro parser, cualquier linea no-JSON rompe el pipeline.

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