tawasuyu

Consola: nuevo módulo Android :consola — cliente del daemon shuma

App (com.rizoma.consola) para administrar y operar las sesiones PTY
persistentes del shuma-daemon a través del shuma-gateway. Tercera app del
rizoma, misma estética neón.

- Transporte (data/): GatewayClient sobre OkHttp — POST /rpc (JSON 1:1 con
  org.json, serde externally-tagged) y GET /ws/pty (WebSocket full-duplex).
  Protocol.kt: modelo PtySession + builders/parsers de PtyList/PtySpawn/
  PtyKill. Settings.kt: URL del gateway + token en DataStore.
- Emulador (term/TerminalEmulator.kt): vt100/xterm propio sin dependencias
  — UTF-8, CR/LF/BS/TAB, cursor (CUP/CUU/CUD/CUF/CUB/CHA/VPA), borrado
  (ED/EL/ECH), insert/delete de líneas y caracteres, región de scroll
  (DECSTBM), índice/índice inverso, SGR 16/256/truecolor con negrita e
  inverso, pantalla alterna (?1049/?47) y visibilidad de cursor (?25).
  Produce TerminalFrame inmutable para renderizar.
- UI (ui/): SessionListScreen (lista viva/muerta + crear/matar + diálogo de
  spawn), TerminalScreen (render en Canvas con runs por estilo + cursor,
  entrada por campo oculto + onPreviewKeyEvent + fila de teclas especiales),
  SettingsScreen, ConsolaApp (navegación) y Theme.
- ViewModel: lista vía /rpc; terminal por canal de bytes → emulador →
  frame Compose; reescalado del PTY al medir la rejilla; detach al salir.
- Gradle: :consola en settings, OkHttp 4.12 en el version catalog,
  minSdk 26 / targetSdk 36 / JVM 17, Compose BOM. README con cómo conectar.

No compilado aquí (sin Android SDK en este equipo); el build/run va en la
máquina con la toolchain.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

8ed22f8ad9c8dd0eafa48327dcaf4e12babcf7ae · por sergio · (hace 4 meses) · padre b021d6829 · ver el árbol en este commit

Delta semántico

Ninguna definición cambió: el cambio es de texto, documentación o datos.

21 archivos · +1957 −0

diff --git a/consola/README.md b/consola/README.md
new file mode 100644
index 0000000..12c2bba
--- /dev/null
+++ b/consola/README.md
@@ -0,0 +1,43 @@
+# consola — cliente Android del daemon shuma
+
+App del rizoma (`com.rizoma.consola`) para administrar y operar **sesiones
+PTY persistentes** del `shuma-daemon` (de `~/tawasuyu`) a través del
+`shuma-gateway`. Cada sesión es típicamente un `claude code` (o un shell)
+que vive en el daemon desacoplado de la app: cerrar el terminal = DETACH,
+la sesión sigue corriendo y puedes volver a adjuntarte.
+
+## Qué hace
+
+- **Lista de sesiones** (`POST /rpc` → `PtyList`): vivas/muertas, comando,
+  tamaño, adjuntos. Crear (`PtySpawn`) y matar/reapear (`PtyKill`).
+- **Terminal** (`GET /ws/pty`): emulador vt100/xterm propio (colores 16/256/
+  truecolor, negrita/inverso, pantalla alterna, región de scroll, cursor).
+  Entrada por teclado virtual + fila de teclas especiales (esc/tab/^C/^D/^L/
+  flechas/⌫/↩). Al crear, el WS hace spawn+attach y devuelve el id.
+- **Ajustes**: URL del gateway + token (DataStore).
+
+## Conectar
+
+1. En la máquina del daemon, levanta el gateway apuntando al socket del
+   daemon:
+
+   ```sh
+   # opcional: token
+   export SHIPOTE_GATEWAY_TOKEN=...           # si quieres auth
+   export SHIPOTE_GATEWAY_LISTEN=0.0.0.0:7378 # para alcanzarlo desde el móvil
+   cargo run -p shuma-gateway
+   ```
+
+2. En la app → ⚙ ajustes → URL:
+   - Emulador de Android: `http://10.0.2.2:7378` (loopback del host).
+   - Dispositivo real: `http://<ip-del-host>:7378` (misma LAN o túnel).
+   - Token: el mismo de `SHIPOTE_GATEWAY_TOKEN`, o vacío si no se usó.
+
+## Notas de la v0.1
+
+- Sin scrollback propio en el cliente: el daemon reenvía su scrollback al
+  adjuntarse y el emulador reconstruye la pantalla visible.
+- Texto plano (`usesCleartextTraffic`) para LAN/túnel; para internet, poner
+  el gateway detrás de TLS y usar `https://`.
+- Transporte: OkHttp (WS + HTTP); JSON con `org.json`. Sin postcard/Noise
+  directos (eso lo cubre el gateway).
diff --git a/consola/build.gradle.kts b/consola/build.gradle.kts
new file mode 100644
index 0000000..7384d6d
--- /dev/null
+++ b/consola/build.gradle.kts
@@ -0,0 +1,53 @@
+plugins {
+    alias(libs.plugins.android.application)
+    alias(libs.plugins.kotlin.android)
+    alias(libs.plugins.kotlin.compose)
+}
+
+android {
+    namespace = "com.rizoma.consola"
+    compileSdk = 36
+
+    defaultConfig {
+        applicationId = "com.rizoma.consola"
+        minSdk = 26
+        targetSdk = 36
+        versionCode = 1
+        versionName = "0.1.0"
+    }
+
+    buildTypes {
+        release {
+            // Sin minify en v0.1: OkHttp + reflexión del parser sin sorpresas
+            // de R8 mientras el cliente madura.
+            isMinifyEnabled = false
+        }
+    }
+    compileOptions {
+        sourceCompatibility = JavaVersion.VERSION_17
+        targetCompatibility = JavaVersion.VERSION_17
+    }
+    kotlinOptions {
+        jvmTarget = "17"
+    }
+    buildFeatures {
+        compose = true
+    }
+}
+
+dependencies {
+    implementation(libs.androidx.core.ktx)
+    implementation(libs.androidx.lifecycle.runtime.ktx)
+    implementation(libs.androidx.lifecycle.viewmodel.compose)
+    implementation(libs.androidx.activity.compose)
+    implementation(platform(libs.androidx.compose.bom))
+    implementation(libs.androidx.compose.ui)
+    implementation(libs.androidx.compose.ui.graphics)
+    implementation(libs.androidx.compose.ui.util)
+    implementation(libs.androidx.compose.foundation)
+    implementation(libs.androidx.compose.animation)
+    implementation(libs.androidx.compose.material3)
+    implementation(libs.androidx.datastore.preferences)
+    implementation(libs.kotlinx.coroutines.android)
+    implementation(libs.okhttp)
+}
diff --git a/consola/src/main/AndroidManifest.xml b/consola/src/main/AndroidManifest.xml
new file mode 100644
index 0000000..7330f74
--- /dev/null
+++ b/consola/src/main/AndroidManifest.xml
@@ -0,0 +1,27 @@
+<?xml version="1.0" encoding="utf-8"?>
+<manifest xmlns:android="http://schemas.android.com/apk/res/android">
+
+    <!-- Habla con el shuma-gateway por HTTP/WebSocket. -->
+    <uses-permission android:name="android.permission.INTERNET" />
+    <uses-permission android:name="android.permission.ACCESS_NETWORK_STATE" />
+
+    <application
+        android:label="@string/app_name"
+        android:icon="@mipmap/ic_launcher"
+        android:theme="@style/Theme.Consola"
+        android:supportsRtl="true"
+        android:usesCleartextTraffic="true">
+
+        <activity
+            android:name=".MainActivity"
+            android:exported="true"
+            android:launchMode="singleTask"
+            android:configChanges="orientation|screenSize|keyboard|keyboardHidden|uiMode"
+            android:windowSoftInputMode="adjustResize">
+            <intent-filter>
+                <action android:name="android.intent.action.MAIN" />
+                <category android:name="android.intent.category.LAUNCHER" />
+            </intent-filter>
+        </activity>
+    </application>
+</manifest>
diff --git a/consola/src/main/java/com/rizoma/consola/ConsolaViewModel.kt b/consola/src/main/java/com/rizoma/consola/ConsolaViewModel.kt
new file mode 100644
index 0000000..d45c3bc
--- /dev/null
+++ b/consola/src/main/java/com/rizoma/consola/ConsolaViewModel.kt
@@ -0,0 +1,250 @@
+package com.rizoma.consola
+
+import android.app.Application
+import androidx.compose.runtime.getValue
+import androidx.compose.runtime.mutableStateOf
+import androidx.compose.runtime.setValue
+import androidx.lifecycle.AndroidViewModel
+import androidx.lifecycle.viewModelScope
+import com.rizoma.consola.data.GatewayClient
+import com.rizoma.consola.data.GatewayConfig
+import com.rizoma.consola.data.PtyConnection
+import com.rizoma.consola.data.PtyListener
+import com.rizoma.consola.data.PtySession
+import com.rizoma.consola.data.Rpc
+import com.rizoma.consola.data.RpcResponse
+import com.rizoma.consola.data.Settings
+import com.rizoma.consola.term.TerminalEmulator
+import com.rizoma.consola.term.TerminalFrame
+import kotlinx.coroutines.Dispatchers
+import kotlinx.coroutines.Job
+import kotlinx.coroutines.channels.Channel
+import kotlinx.coroutines.launch
+import kotlinx.coroutines.withContext
+
+enum class Screen { List, Terminal, Settings }
+enum class TermStatus { Connecting, Live, Exited, Error, Closed }
+
+private sealed interface TermEvent {
+    class Bytes(val data: ByteArray) : TermEvent
+    class Resize(val rows: Int, val cols: Int) : TermEvent
+    data object Opened : TermEvent
+    class SessionId(val id: String) : TermEvent
+    class Exit(val code: Int) : TermEvent
+    class ErrorMsg(val message: String) : TermEvent
+    class Closed(val reason: String?) : TermEvent
+}
+
+/**
+ * Orquesta la consola: lista de sesiones del daemon (vía `POST /rpc`) y el
+ * panel de terminal (vía WebSocket). Los bytes del PTY entran por un canal
+ * y un único consumidor los alimenta al [TerminalEmulator], produce un
+ * [TerminalFrame] inmutable y lo publica como estado Compose.
+ */
+class ConsolaViewModel(app: Application) : AndroidViewModel(app) {
+
+    private val settings = Settings(app)
+    private val client = GatewayClient(GatewayConfig())
+
+    var config by mutableStateOf(GatewayConfig()); private set
+    var screen by mutableStateOf(Screen.List); private set
+
+    // Lista de sesiones.
+    var sessions by mutableStateOf<List<PtySession>>(emptyList()); private set
+    var listLoading by mutableStateOf(false); private set
+    var listError by mutableStateOf<String?>(null); private set
+
+    // Terminal activo.
+    var frame by mutableStateOf<TerminalFrame?>(null); private set
+    var termStatus by mutableStateOf(TermStatus.Connecting); private set
+    var termMessage by mutableStateOf<String?>(null); private set
+    var termTitle by mutableStateOf(""); private set
+    var activeSessionId by mutableStateOf<String?>(null); private set
+
+    private var emulator = TerminalEmulator(24, 80)
+    private var connection: PtyConnection? = null
+    private var events: Channel<TermEvent>? = null
+    private var consumer: Job? = null
+    private var sizedRows = 24
+    private var sizedCols = 80
+
+    init {
+        viewModelScope.launch {
+            settings.config.collect {
+                config = it
+                client.config = it
+            }
+        }
+        refresh()
+    }
+
+    // ---- Lista --------------------------------------------------------
+
+    fun refresh() {
+        if (listLoading) return
+        listLoading = true
+        viewModelScope.launch {
+            when (val r = client.rpc(Rpc.ptyList())) {
+                is RpcResponse.Sessions -> { sessions = r.items.sortedByDescending { it.createdUnixMs }; listError = null }
+                is RpcResponse.Failure -> listError = r.message
+                else -> listError = "respuesta inesperada del gateway"
+            }
+            listLoading = false
+        }
+    }
+
+    fun kill(id: String) {
+        viewModelScope.launch {
+            client.rpc(Rpc.ptyKill(id))
+            refresh()
+        }
+    }
+
+    // ---- Navegación ---------------------------------------------------
+
+    fun goList() {
+        teardownTerminal()
+        screen = Screen.List
+        refresh()
+    }
+
+    fun goSettings() { screen = Screen.Settings }
+
+    fun saveSettings(url: String, token: String) {
+        viewModelScope.launch { settings.setConfig(url, token) }
+        screen = Screen.List
+    }
+
+    // ---- Terminal -----------------------------------------------------
+
+    /** Crea una sesión nueva (spawn) y se adjunta. */
+    fun spawn(command: String, cwd: String, label: String) {
+        val parts = command.trim().split(Regex("\\s+")).filter { it.isNotEmpty() }
+        if (parts.isEmpty()) return
+        openTerminal(
+            session = null,
+            program = parts.first(),
+            args = parts.drop(1),
+            cwd = cwd.ifBlank { "." },
+            label = label,
+            title = label.ifBlank { command },
+        )
+    }
+
+    /** Se adjunta a una sesión existente. */
+    fun attach(session: PtySession) {
+        openTerminal(
+            session = session.id,
+            program = session.program,
+            args = session.args,
+            cwd = session.cwd,
+            label = session.label,
+            title = session.label.ifBlank { session.commandLine },
+        )
+    }
+
+    private fun openTerminal(
+        session: String?,
+        program: String,
+        args: List<String>,
+        cwd: String,
+        label: String,
+        title: String,
+    ) {
+        teardownTerminal()
+        emulator = TerminalEmulator(sizedRows, sizedCols)
+        frame = emulator.snapshot()
+        termStatus = TermStatus.Connecting
+        termMessage = null
+        termTitle = title
+        activeSessionId = session
+        screen = Screen.Terminal
+
+        val ch = Channel<TermEvent>(Channel.UNLIMITED)
+        events = ch
+        consumer = viewModelScope.launch(Dispatchers.Default) { consume(ch) }
+
+        connection = client.openPty(
+            session = session,
+            program = program,
+            args = args,
+            cwd = cwd,
+            label = label,
+            rows = sizedRows,
+            cols = sizedCols,
+            listener = object : PtyListener {
+                override fun onOpen() { ch.trySend(TermEvent.Opened) }
+                override fun onSessionId(id: String) { ch.trySend(TermEvent.SessionId(id)) }
+                override fun onBytes(data: ByteArray) { ch.trySend(TermEvent.Bytes(data)) }
+                override fun onExit(code: Int) { ch.trySend(TermEvent.Exit(code)) }
+                override fun onError(message: String) { ch.trySend(TermEvent.ErrorMsg(message)) }
+                override fun onClosed(reason: String?) { ch.trySend(TermEvent.Closed(reason)) }
+            },
+        )
+    }
+
+    private suspend fun consume(ch: Channel<TermEvent>) {
+        for (first in ch) {
+            var ev: TermEvent? = first
+            var changed = false
+            while (ev != null) {
+                when (val e = ev) {
+                    is TermEvent.Bytes -> { emulator.feed(e.data); changed = true }
+                    is TermEvent.Resize -> {
+                        emulator.resize(e.rows, e.cols)
+                        connection?.resize(e.rows, e.cols)
+                        changed = true
+                    }
+                    TermEvent.Opened -> setStatus(TermStatus.Live, null)
+                    is TermEvent.SessionId -> withContext(Dispatchers.Main) { activeSessionId = e.id }
+                    is TermEvent.Exit -> setStatus(TermStatus.Exited, "la sesión terminó (código ${e.code})")
+                    is TermEvent.ErrorMsg -> setStatus(TermStatus.Error, e.message)
+                    is TermEvent.Closed -> withContext(Dispatchers.Main) {
+                        if (termStatus == TermStatus.Live) {
+                            termStatus = TermStatus.Closed
+                            termMessage = "desconectado"
+                        }
+                    }
+                }
+                ev = ch.tryReceive().getOrNull()
+            }
+            if (changed) {
+                val snap = emulator.snapshot()
+                withContext(Dispatchers.Main) { frame = snap }
+            }
+        }
+    }
+
+    private suspend fun setStatus(status: TermStatus, message: String?) {
+        withContext(Dispatchers.Main) {
+            termStatus = status
+            termMessage = message
+        }
+    }
+
+    /** El render midió la rejilla disponible: reescala emulador + PTY. */
+    fun onTerminalSized(rows: Int, cols: Int) {
+        if (rows < 1 || cols < 1) return
+        sizedRows = rows
+        sizedCols = cols
+        events?.trySend(TermEvent.Resize(rows, cols))
+    }
+
+    fun sendText(text: String) { connection?.sendInput(text) }
+
+    fun sendBytes(bytes: ByteArray) { connection?.sendInput(bytes) }
+
+    private fun teardownTerminal() {
+        connection?.detach()
+        connection = null
+        consumer?.cancel()
+        consumer = null
+        events?.close()
+        events = null
+    }
+
+    override fun onCleared() {
+        teardownTerminal()
+        super.onCleared()
+    }
+}
diff --git a/consola/src/main/java/com/rizoma/consola/MainActivity.kt b/consola/src/main/java/com/rizoma/consola/MainActivity.kt
new file mode 100644
index 0000000..a3138f1
--- /dev/null
+++ b/consola/src/main/java/com/rizoma/consola/MainActivity.kt
@@ -0,0 +1,20 @@
+package com.rizoma.consola
+
+import android.os.Bundle
+import androidx.activity.ComponentActivity
+import androidx.activity.compose.setContent
+import androidx.core.view.WindowCompat
+import com.rizoma.consola.ui.ConsolaApp
+import com.rizoma.consola.ui.theme.ConsolaTheme
+
+class MainActivity : ComponentActivity() {
+    override fun onCreate(savedInstanceState: Bundle?) {
+        super.onCreate(savedInstanceState)
+        WindowCompat.setDecorFitsSystemWindows(window, false)
+        setContent {
+            ConsolaTheme {
+                ConsolaApp()
+            }
+        }
+    }
+}
diff --git a/consola/src/main/java/com/rizoma/consola/data/GatewayClient.kt b/consola/src/main/java/com/rizoma/consola/data/GatewayClient.kt
new file mode 100644
index 0000000..e3edeab
--- /dev/null
+++ b/consola/src/main/java/com/rizoma/consola/data/GatewayClient.kt
@@ -0,0 +1,159 @@
+package com.rizoma.consola.data
+
+import kotlinx.coroutines.Dispatchers
+import kotlinx.coroutines.withContext
+import okhttp3.MediaType.Companion.toMediaType
+import okhttp3.OkHttpClient
+import okhttp3.Request
+import okhttp3.RequestBody.Companion.toRequestBody
+import okhttp3.Response
+import okhttp3.WebSocket
+import okhttp3.WebSocketListener
+import okio.ByteString
+import okio.ByteString.Companion.toByteString
+import org.json.JSONObject
+import java.util.concurrent.TimeUnit
+
+/**
+ * Cliente del shuma-gateway: `POST /rpc` (JSON 1:1) y `GET /ws/pty`
+ * (WebSocket full-duplex hacia una sesión PTY). Sin estado de UI; lo
+ * orquesta el ViewModel.
+ */
+class GatewayClient(@Volatile var config: GatewayConfig) {
+
+    private val json = "application/json; charset=utf-8".toMediaType()
+
+    // El WS de un terminal puede estar minutos en silencio; sin timeout de
+    // lectura pero con ping para mantener vivo el NAT/proxy.
+    private val client = OkHttpClient.Builder()
+        .connectTimeout(10, TimeUnit.SECONDS)
+        .readTimeout(0, TimeUnit.MILLISECONDS)
+        .pingInterval(20, TimeUnit.SECONDS)
+        .build()
+
+    /** Round-trip de una Request JSON (cuerpos de [Rpc]) → [RpcResponse]. */
+    suspend fun rpc(body: String): RpcResponse = withContext(Dispatchers.IO) {
+        val req = Request.Builder()
+            .url(config.baseUrl.trimEnd('/') + "/rpc")
+            .post(body.toRequestBody(json))
+            .apply { if (config.token.isNotBlank()) header("Authorization", "Bearer ${config.token}") }
+            .build()
+        runCatching {
+            client.newCall(req).execute().use { resp: Response ->
+                val text = resp.body?.string().orEmpty()
+                if (text.isEmpty()) {
+                    RpcResponse.Failure("respuesta vacía (HTTP ${resp.code})")
+                } else {
+                    RpcParser.parse(text)
+                }
+            }
+        }.getOrElse { e ->
+            RpcResponse.Failure(e.message ?: "fallo de red")
+        }
+    }
+
+    /**
+     * Abre el WS de un PTY. Si [session] es null, **crea** una sesión nueva
+     * (spawn) con [program]/[args]/[cwd]/[label]; si no, se **adjunta** a la
+     * existente. Devuelve el handle para mandar teclas/resize y cerrar.
+     */
+    fun openPty(
+        session: String?,
+        program: String,
+        args: List<String>,
+        cwd: String,
+        label: String,
+        rows: Int,
+        cols: Int,
+        listener: PtyListener,
+    ): PtyConnection {
+        var url = config.baseUrl.trimEnd('/') + "/ws/pty"
+        url = when {
+            url.startsWith("https://") -> "wss://" + url.removePrefix("https://")
+            url.startsWith("http://") -> "ws://" + url.removePrefix("http://")
+            else -> url
+        }
+        if (config.token.isNotBlank()) {
+            url += "?token=" + config.token
+        }
+        val req = Request.Builder()
+            .url(url)
+            .apply { if (config.token.isNotBlank()) header("Authorization", "Bearer ${config.token}") }
+            .build()
+
+        // Primer mensaje (texto): adjuntar o crear.
+        val open = JSONObject().put("rows", rows).put("cols", cols)
+        if (session != null) {
+            open.put("session", session)
+        } else {
+            open.put("program", program)
+            open.put("args", org.json.JSONArray(args))
+            open.put("cwd", cwd)
+            open.put("label", label)
+        }
+
+        val ws = client.newWebSocket(req, object : WebSocketListener() {
+            override fun onOpen(webSocket: WebSocket, response: Response) {
+                webSocket.send(open.toString())
+                listener.onOpen()
+            }
+
+            override fun onMessage(webSocket: WebSocket, bytes: ByteString) {
+                listener.onBytes(bytes.toByteArray())
+            }
+
+            override fun onMessage(webSocket: WebSocket, text: String) {
+                val o = runCatching { JSONObject(text) }.getOrNull() ?: return
+                when (o.optString("t")) {
+                    "session" -> listener.onSessionId(o.optString("id"))
+                    "exit" -> listener.onExit(o.optInt("code", -1))
+                    "error" -> listener.onError(o.optString("msg", "error"))
+                }
+            }
+
+            override fun onClosing(webSocket: WebSocket, code: Int, reason: String) {
+                webSocket.close(1000, null)
+            }
+
+            override fun onClosed(webSocket: WebSocket, code: Int, reason: String) {
+                listener.onClosed(reason.ifBlank { null })
+            }
+
+            override fun onFailure(webSocket: WebSocket, t: Throwable, response: Response?) {
+                listener.onError(t.message ?: "fallo del WebSocket")
+                listener.onClosed(t.message)
+            }
+        })
+        return PtyConnection(ws)
+    }
+}
+
+/** Eventos de una conexión PTY (callbacks desde el hilo de OkHttp). */
+interface PtyListener {
+    fun onOpen() {}
+    fun onSessionId(id: String) {}
+    fun onBytes(data: ByteArray) {}
+    fun onExit(code: Int) {}
+    fun onError(message: String) {}
+    fun onClosed(reason: String?) {}
+}
+
+/** Handle de un PTY adjunto: manda teclas/resize y cierra (DETACH). */
+class PtyConnection(private val ws: WebSocket) {
+    fun sendInput(bytes: ByteArray) {
+        ws.send(bytes.toByteString())
+    }
+
+    fun sendInput(text: String) {
+        ws.send(text.toByteArray(Charsets.UTF_8).toByteString())
+    }
+
+    fun resize(rows: Int, cols: Int) {
+        ws.send(JSONObject().put("t", "resize").put("rows", rows).put("cols", cols).toString())
+    }
+
+    /** Cierra el WS → DETACH en el daemon (la sesión sigue viva). */
+    fun detach() {
+        ws.close(1000, null)
+    }
+}
diff --git a/consola/src/main/java/com/rizoma/consola/data/Protocol.kt b/consola/src/main/java/com/rizoma/consola/data/Protocol.kt
new file mode 100644
index 0000000..b7eeea9
--- /dev/null
+++ b/consola/src/main/java/com/rizoma/consola/data/Protocol.kt
@@ -0,0 +1,128 @@
+package com.rizoma.consola.data
+
+import org.json.JSONArray
+import org.json.JSONObject
+
+/**
+ * Espejo en Kotlin del wire JSON del shuma-gateway (`POST /rpc`).
+ *
+ * El daemon serializa sus enums con serde **externally tagged**: una
+ * variante unitaria (`Request::PtyList`) viaja como el string `"PtyList"`;
+ * una variante con campos (`Request::PtySpawn { .. }`) como
+ * `{"PtySpawn":{ ..campos.. }}`. Aquí construimos esas formas a mano con
+ * `org.json` (sin dependencias de serialización), y parseamos las
+ * respuestas igual.
+ */
+
+/** Una sesión PTY del daemon — espejo de `shuma_protocol::PtySessionInfo`. */
+data class PtySession(
+    val id: String,
+    val label: String,
+    val program: String,
+    val args: List<String>,
+    val cwd: String,
+    val rows: Int,
+    val cols: Int,
+    val alive: Boolean,
+    val exitCode: Int?,
+    val createdUnixMs: Long,
+    val attached: Int,
+) {
+    /** Texto de la línea de comando, para mostrar. */
+    val commandLine: String
+        get() = (listOf(program) + args).joinToString(" ")
+}
+
+/** Construye los cuerpos JSON de las `Request` que usa la app. */
+object Rpc {
+    /** `Request::PtyList` → string desnudo `"PtyList"`. */
+    fun ptyList(): String = JSONObject.quote("PtyList")
+
+    /** `Request::Ping` → `"Ping"`. */
+    fun ping(): String = JSONObject.quote("Ping")
+
+    fun ptySpawn(
+        cwd: String,
+        program: String,
+        args: List<String>,
+        rows: Int,
+        cols: Int,
+        label: String,
+    ): String {
+        val inner = JSONObject()
+            .put("cwd", cwd)
+            .put("program", program)
+            .put("args", JSONArray(args))
+            .put("rows", rows)
+            .put("cols", cols)
+            .put("label", label)
+        return JSONObject().put("PtySpawn", inner).toString()
+    }
+
+    fun ptyKill(session: String): String =
+        JSONObject().put("PtyKill", JSONObject().put("session", session)).toString()
+}
+
+/** Resultado de una respuesta `Response` ya parseada. */
+sealed interface RpcResponse {
+    data class Sessions(val items: List<PtySession>) : RpcResponse
+    data class Spawned(val session: String) : RpcResponse
+    data class Killed(val session: String, val existed: Boolean) : RpcResponse
+    data object Pong : RpcResponse
+    data class Failure(val message: String) : RpcResponse
+}
+
+object RpcParser {
+    fun parse(body: String): RpcResponse {
+        // Respuesta unitaria (`"Pong"`) llega como string JSON desnudo.
+        val trimmed = body.trim()
+        if (trimmed.startsWith("\"")) {
+            return when (val s = JSONObject("{\"v\":$trimmed}").getString("v")) {
+                "Pong" -> RpcResponse.Pong
+                else -> RpcResponse.Failure("respuesta inesperada: $s")
+            }
+        }
+        val obj = runCatching { JSONObject(trimmed) }.getOrNull()
+            ?: return RpcResponse.Failure("respuesta no-JSON del gateway")
+
+        // Error de transporte del gateway: {"error":"..."}.
+        obj.optString("error", "").takeIf { it.isNotEmpty() }?.let {
+            return RpcResponse.Failure(it)
+        }
+
+        return when {
+            obj.has("PtyList") -> RpcResponse.Sessions(
+                parseSessions(obj.getJSONObject("PtyList").getJSONArray("sessions")),
+            )
+            obj.has("PtySpawned") ->
+                RpcResponse.Spawned(obj.getJSONObject("PtySpawned").getString("session"))
+            obj.has("PtyKilled") -> {
+                val k = obj.getJSONObject("PtyKilled")
+                RpcResponse.Killed(k.getString("session"), k.getBoolean("existed"))
+            }
+            obj.has("Error") ->
+                RpcResponse.Failure(obj.getJSONObject("Error").getString("message"))
+            else -> RpcResponse.Failure("respuesta desconocida: ${trimmed.take(120)}")
+        }
+    }
+
+    private fun parseSessions(arr: JSONArray): List<PtySession> =
+        (0 until arr.length()).map { i ->
+            val o = arr.getJSONObject(i)
+            PtySession(
+                id = o.getString("session"),
+                label = o.optString("label", ""),
+                program = o.optString("program", ""),
+                args = o.optJSONArray("args")?.let { a ->
+                    (0 until a.length()).map { a.getString(it) }
+                } ?: emptyList(),
+                cwd = o.optString("cwd", ""),
+                rows = o.optInt("rows", 0),
+                cols = o.optInt("cols", 0),
+                alive = o.optBoolean("alive", false),
+                exitCode = if (o.isNull("exit_code")) null else o.optInt("exit_code"),
+                createdUnixMs = o.optLong("created_unix_ms", 0L),
+                attached = o.optInt("attached", 0),
+            )
+        }
+}
diff --git a/consola/src/main/java/com/rizoma/consola/data/Settings.kt b/consola/src/main/java/com/rizoma/consola/data/Settings.kt
new file mode 100644
index 0000000..f81ecde
--- /dev/null
+++ b/consola/src/main/java/com/rizoma/consola/data/Settings.kt
@@ -0,0 +1,47 @@
+package com.rizoma.consola.data
+
+import android.content.Context
+import androidx.datastore.core.DataStore
+import androidx.datastore.preferences.core.Preferences
+import androidx.datastore.preferences.core.edit
+import androidx.datastore.preferences.core.stringPreferencesKey
+import androidx.datastore.preferences.preferencesDataStore
+import kotlinx.coroutines.flow.Flow
+import kotlinx.coroutines.flow.map
+
+private val Context.dataStore: DataStore<Preferences> by preferencesDataStore(name = "consola")
+
+/** Configuración de conexión al shuma-gateway, persistida en DataStore. */
+data class GatewayConfig(
+    val baseUrl: String = DEFAULT_URL,
+    val token: String = "",
+) {
+    companion object {
+        // 10.0.2.2 = loopback del host desde el emulador de Android. En un
+        // dispositivo real se cambia por la IP/host del gateway (o el túnel).
+        const val DEFAULT_URL = "http://10.0.2.2:7378"
+    }
+}
+
+class Settings(context: Context) {
+    private val store = context.applicationContext.dataStore
+
+    val config: Flow<GatewayConfig> = store.data.map { p ->
+        GatewayConfig(
+            baseUrl = p[KEY_URL]?.ifBlank { GatewayConfig.DEFAULT_URL } ?: GatewayConfig.DEFAULT_URL,
+            token = p[KEY_TOKEN].orEmpty(),
+        )
+    }
+
+    suspend fun setConfig(baseUrl: String, token: String) {
+        store.edit { p ->
+            p[KEY_URL] = baseUrl.trim().ifBlank { GatewayConfig.DEFAULT_URL }
+            p[KEY_TOKEN] = token.trim()
+        }
+    }
+
+    private companion object {
+        val KEY_URL = stringPreferencesKey("base_url")
+        val KEY_TOKEN = stringPreferencesKey("token")
+    }
+}
diff --git a/consola/src/main/java/com/rizoma/consola/term/TerminalEmulator.kt b/consola/src/main/java/com/rizoma/consola/term/TerminalEmulator.kt
new file mode 100644
index 0000000..14c6840
--- /dev/null
+++ b/consola/src/main/java/com/rizoma/consola/term/TerminalEmulator.kt
@@ -0,0 +1,526 @@
+package com.rizoma.consola.term
+
+/**
+ * Emulador de terminal: un subconjunto práctico de vt100/xterm suficiente
+ * para TUIs como `claude code`, shells y editores. Mantiene una rejilla
+ * `rows × cols` de celdas y la actualiza alimentándole los bytes crudos
+ * que llegan del PTY.
+ *
+ * Cubre: texto UTF-8, CR/LF/BS/TAB, cursor (CUP/CUU/CUD/CUF/CUB/CHA/VPA),
+ * borrado (ED/EL/ECH), inserción/borrado de líneas y caracteres, región
+ * de scroll (DECSTBM), índice/índice inverso, colores SGR (16 + 256 +
+ * truecolor) con negrita/inverso, pantalla alterna (?1049/?47) y
+ * visibilidad del cursor (?25). Lo no soportado se ignora sin romper.
+ *
+ * NO mantiene scrollback propio: el daemon ya guarda el scrollback y lo
+ * reenvía al adjuntarse; al reproducirlo se reconstruye la pantalla.
+ */
+class TerminalEmulator(rows: Int, cols: Int) {
+
+    var rows = rows.coerceAtLeast(1); private set
+    var cols = cols.coerceAtLeast(1); private set
+
+    private var grid = blankGrid(this.rows, this.cols)
+
+    private var cursorRow = 0
+    private var cursorCol = 0
+    private var wrapPending = false
+    var cursorVisible = true; private set
+
+    // Atributos actuales (SGR).
+    private var curFg = DEFAULT
+    private var curBg = DEFAULT
+    private var curBold = false
+    private var curInverse = false
+
+    // Región de scroll [top, bottom] inclusive.
+    private var scrollTop = 0
+    private var scrollBottom = this.rows - 1
+
+    // Cursor guardado (ESC 7 / DECSC / CSI s).
+    private var savedRow = 0
+    private var savedCol = 0
+
+    // Pantalla alterna.
+    private var altGrid: Array<Array<Cell>>? = null
+    private var altSavedRow = 0
+    private var altSavedCol = 0
+
+    // Estado del parser.
+    private enum class State { GROUND, ESC, CSI, OSC, CHARSET }
+    private var state = State.GROUND
+    private val params = StringBuilder()
+    private var csiPrivate = false
+
+    // Decodificador UTF-8 incremental.
+    private var utf8Rem = 0
+    private var utf8Acc = 0
+
+    // ---- API ----------------------------------------------------------
+
+    fun feed(data: ByteArray) {
+        for (b in data) handleByte(b.toInt() and 0xFF)
+    }
+
+    /** Redimensiona conservando el contenido superior-izquierdo. */
+    fun resize(newRows: Int, newCols: Int) {
+        val r = newRows.coerceAtLeast(1)
+        val c = newCols.coerceAtLeast(1)
+        if (r == rows && c == cols) return
+        grid = copyResized(grid, rows, cols, r, c)
+        altGrid = altGrid?.let { copyResized(it, rows, cols, r, c) }
+        rows = r
+        cols = c
+        scrollTop = 0
+        scrollBottom = r - 1
+        cursorRow = cursorRow.coerceIn(0, r - 1)
+        cursorCol = cursorCol.coerceIn(0, c - 1)
+        wrapPending = false
+    }
+
+    /** Copia inmutable del estado actual para renderizar. */
+    fun snapshot(): TerminalFrame {
+        val ch = Array(rows) { CharArray(cols) }
+        val fg = Array(rows) { IntArray(cols) }
+        val bg = Array(rows) { IntArray(cols) }
+        val st = Array(rows) { IntArray(cols) }
+        for (y in 0 until rows) {
+            for (x in 0 until cols) {
+                val cell = grid[y][x]
+                ch[y][x] = cell.ch
+                fg[y][x] = cell.fg
+                bg[y][x] = cell.bg
+                st[y][x] = (if (cell.bold) 1 else 0) or (if (cell.inverse) 2 else 0)
+            }
+        }
+        return TerminalFrame(rows, cols, ch, fg, bg, st, cursorRow, cursorCol, cursorVisible)
+    }
+
+    // ---- Parser -------------------------------------------------------
+
+    private fun handleByte(b: Int) {
+        when (state) {
+            State.GROUND -> ground(b)
+            State.ESC -> esc(b)
+            State.CSI -> csi(b)
+            State.OSC -> osc(b)
+            State.CHARSET -> state = State.GROUND // designación de charset: ignorar 1 byte
+        }
+    }
+
+    private fun ground(b: Int) {
+        when {
+            b == 0x1B -> state = State.ESC
+            b == 0x0A || b == 0x0B || b == 0x0C -> lineFeed()
+            b == 0x0D -> { cursorCol = 0; wrapPending = false }
+            b == 0x08 -> { if (cursorCol > 0) cursorCol--; wrapPending = false }
+            b == 0x09 -> { cursorCol = ((cursorCol / 8) + 1) * 8; if (cursorCol >= cols) cursorCol = cols - 1 }
+            b == 0x07 -> {} // BEL
+            b < 0x20 -> {}  // otros controles: ignorar
+            else -> utf8(b)
+        }
+    }
+
+    private fun utf8(b: Int) {
+        if (utf8Rem > 0) {
+            if (b and 0xC0 == 0x80) {
+                utf8Acc = (utf8Acc shl 6) or (b and 0x3F)
+                if (--utf8Rem == 0) putCodePoint(utf8Acc)
+            } else {
+                // secuencia rota: reiniciar y reintentar este byte
+                utf8Rem = 0
+                if (b < 0x80) putCodePoint(b)
+            }
+            return
+        }
+        when {
+            b < 0x80 -> putCodePoint(b)
+            b and 0xE0 == 0xC0 -> { utf8Acc = b and 0x1F; utf8Rem = 1 }
+            b and 0xF0 == 0xE0 -> { utf8Acc = b and 0x0F; utf8Rem = 2 }
+            b and 0xF8 == 0xF0 -> { utf8Acc = b and 0x07; utf8Rem = 3 }
+            else -> putCodePoint('?'.code)
+        }
+    }
+
+    private fun esc(b: Int) {
+        when (b.toChar()) {
+            '[' -> { state = State.CSI; params.setLength(0); csiPrivate = false }
+            ']' -> state = State.OSC
+            '(', ')', '*', '+' -> state = State.CHARSET
+            '7' -> { savedRow = cursorRow; savedCol = cursorCol; state = State.GROUND }
+            '8' -> { cursorRow = savedRow.coerceIn(0, rows - 1); cursorCol = savedCol.coerceIn(0, cols - 1); state = State.GROUND }
+            'M' -> { reverseIndex(); state = State.GROUND }
+            'D' -> { lineFeed(); state = State.GROUND }
+            'E' -> { cursorCol = 0; lineFeed(); state = State.GROUND }
+            'c' -> { fullReset(); state = State.GROUND }
+            else -> state = State.GROUND
+        }
+    }
+
+    private fun osc(b: Int) {
+        // Cadena de comando del SO (títulos, etc.): la consumimos hasta BEL
+        // o ST (ESC \). No la usamos.
+        if (b == 0x07) {
+            state = State.GROUND
+        } else if (b == 0x1B) {
+            state = State.ESC // un ESC siguiente probablemente sea el '\' de ST
+        }
+    }
+
+    private fun csi(b: Int) {
+        val c = b.toChar()
+        when {
+            c == '?' || c == '>' || c == '!' -> csiPrivate = csiPrivate || c == '?'
+            c in '0'..'9' || c == ';' -> params.append(c)
+            b in 0x20..0x2F -> {} // intermedios: ignorar
+            b in 0x40..0x7E -> { dispatchCsi(c); state = State.GROUND }
+            else -> state = State.GROUND
+        }
+    }
+
+    private fun csiParams(): IntArray {
+        if (params.isEmpty()) return IntArray(0)
+        return params.split(';').map { it.toIntOrNull() ?: 0 }.toIntArray()
+    }
+
+    private fun param(p: IntArray, i: Int, default: Int): Int =
+        if (i < p.size && p[i] != 0) p[i] else default
+
+    private fun dispatchCsi(c: Char) {
+        val p = csiParams()
+        when (c) {
+            'A' -> cursorRow = (cursorRow - param(p, 0, 1)).coerceAtLeast(0)
+            'B' -> cursorRow = (cursorRow + param(p, 0, 1)).coerceAtMost(rows - 1)
+            'C' -> { cursorCol = (cursorCol + param(p, 0, 1)).coerceAtMost(cols - 1); wrapPending = false }
+            'D' -> { cursorCol = (cursorCol - param(p, 0, 1)).coerceAtLeast(0); wrapPending = false }
+            'E' -> { cursorRow = (cursorRow + param(p, 0, 1)).coerceAtMost(rows - 1); cursorCol = 0 }
+            'F' -> { cursorRow = (cursorRow - param(p, 0, 1)).coerceAtLeast(0); cursorCol = 0 }
+            'G', '`' -> { cursorCol = (param(p, 0, 1) - 1).coerceIn(0, cols - 1); wrapPending = false }
+            'd' -> cursorRow = (param(p, 0, 1) - 1).coerceIn(0, rows - 1)
+            'H', 'f' -> {
+                cursorRow = (param(p, 0, 1) - 1).coerceIn(0, rows - 1)
+                cursorCol = (param(p, 1, 1) - 1).coerceIn(0, cols - 1)
+                wrapPending = false
+            }
+            'J' -> eraseDisplay(if (p.isEmpty()) 0 else p[0])
+            'K' -> eraseLine(if (p.isEmpty()) 0 else p[0])
+            'L' -> insertLines(param(p, 0, 1))
+            'M' -> deleteLines(param(p, 0, 1))
+            'P' -> deleteChars(param(p, 0, 1))
+            '@' -> insertChars(param(p, 0, 1))
+            'X' -> eraseChars(param(p, 0, 1))
+            'S' -> scrollUp(param(p, 0, 1))
+            'T' -> scrollDown(param(p, 0, 1))
+            'r' -> {
+                scrollTop = (param(p, 0, 1) - 1).coerceIn(0, rows - 1)
+                scrollBottom = (param(p, 1, rows) - 1).coerceIn(scrollTop, rows - 1)
+                cursorRow = 0; cursorCol = 0
+            }
+            's' -> { savedRow = cursorRow; savedCol = cursorCol }
+            'u' -> { cursorRow = savedRow.coerceIn(0, rows - 1); cursorCol = savedCol.coerceIn(0, cols - 1) }
+            'm' -> applySgr(p)
+            'h' -> setMode(p, true)
+            'l' -> setMode(p, false)
+            else -> {}
+        }
+    }
+
+    // ---- Modos (h/l) --------------------------------------------------
+
+    private fun setMode(p: IntArray, enable: Boolean) {
+        if (!csiPrivate) return
+        for (mode in p) {
+            when (mode) {
+                25 -> cursorVisible = enable
+                47, 1047, 1049 -> if (enable) enterAltScreen() else leaveAltScreen()
+                else -> {}
+            }
+        }
+    }
+
+    private fun enterAltScreen() {
+        if (altGrid != null) return
+        altGrid = grid
+        altSavedRow = cursorRow
+        altSavedCol = cursorCol
+        grid = blankGrid(rows, cols)
+        cursorRow = 0; cursorCol = 0; wrapPending = false
+    }
+
+    private fun leaveAltScreen() {
+        val main = altGrid ?: return
+        grid = if (main.size == rows && main[0].size == cols) main else copyResized(main, main.size, main[0].size, rows, cols)
+        altGrid = null
+        cursorRow = altSavedRow.coerceIn(0, rows - 1)
+        cursorCol = altSavedCol.coerceIn(0, cols - 1)
+        wrapPending = false
+    }
+
+    // ---- Escritura / movimiento --------------------------------------
+
+    private fun putCodePoint(cp: Int) {
+        if (wrapPending) {
+            wrapPending = false
+            cursorCol = 0
+            lineFeed()
+        }
+        val ch = if (cp in 0..0xFFFF) cp.toChar() else '?'
+        val cell = grid[cursorRow][cursorCol]
+        cell.ch = ch
+        cell.fg = curFg
+        cell.bg = curBg
+        cell.bold = curBold
+        cell.inverse = curInverse
+        if (cursorCol == cols - 1) wrapPending = true else cursorCol++
+    }
+
+    private fun lineFeed() {
+        wrapPending = false
+        if (cursorRow == scrollBottom) {
+            scrollUp(1)
+        } else if (cursorRow < rows - 1) {
+            cursorRow++
+        }
+    }
+
+    private fun reverseIndex() {
+        if (cursorRow == scrollTop) scrollDown(1) else if (cursorRow > 0) cursorRow--
+    }
+
+    // ---- Scroll / inserción ------------------------------------------
+
+    private fun scrollUp(n: Int) {
+        val count = n.coerceIn(1, scrollBottom - scrollTop + 1)
+        for (y in scrollTop until scrollBottom - count + 1) {
+            grid[y] = grid[y + count]
+        }
+        for (y in scrollBottom - count + 1..scrollBottom) {
+            grid[y] = blankRow(cols)
+        }
+    }
+
+    private fun scrollDown(n: Int) {
+        val count = n.coerceIn(1, scrollBottom - scrollTop + 1)
+        for (y in scrollBottom downTo scrollTop + count) {
+            grid[y] = grid[y - count]
+        }
+        for (y in scrollTop until scrollTop + count) {
+            grid[y] = blankRow(cols)
+        }
+    }
+
+    private fun insertLines(n: Int) {
+        if (cursorRow < scrollTop || cursorRow > scrollBottom) return
+        val count = n.coerceIn(1, scrollBottom - cursorRow + 1)
+        for (y in scrollBottom downTo cursorRow + count) {
+            grid[y] = grid[y - count]
+        }
+        for (y in cursorRow until cursorRow + count) {
+            grid[y] = blankRow(cols)
+        }
+    }
+
+    private fun deleteLines(n: Int) {
+        if (cursorRow < scrollTop || cursorRow > scrollBottom) return
+        val count = n.coerceIn(1, scrollBottom - cursorRow + 1)
+        for (y in cursorRow until scrollBottom - count + 1) {
+            grid[y] = grid[y + count]
+        }
+        for (y in scrollBottom - count + 1..scrollBottom) {
+            grid[y] = blankRow(cols)
+        }
+    }
+
+    private fun insertChars(n: Int) {
+        val count = n.coerceIn(1, cols - cursorCol)
+        val row = grid[cursorRow]
+        for (x in cols - 1 downTo cursorCol + count) {
+            row[x] = row[x - count]
+        }
+        for (x in cursorCol until cursorCol + count) {
+            row[x] = blankCell()
+        }
+    }
+
+    private fun deleteChars(n: Int) {
+        val count = n.coerceIn(1, cols - cursorCol)
+        val row = grid[cursorRow]
+        for (x in cursorCol until cols - count) {
+            row[x] = row[x + count]
+        }
+        for (x in cols - count until cols) {
+            row[x] = blankCell()
+        }
+    }
+
+    private fun eraseChars(n: Int) {
+        val count = n.coerceIn(1, cols - cursorCol)
+        for (x in cursorCol until cursorCol + count) clearCell(grid[cursorRow][x])
+    }
+
+    // ---- Borrado ------------------------------------------------------
+
+    private fun eraseDisplay(mode: Int) {
+        when (mode) {
+            0 -> {
+                for (x in cursorCol until cols) clearCell(grid[cursorRow][x])
+                for (y in cursorRow + 1 until rows) for (x in 0 until cols) clearCell(grid[y][x])
+            }
+            1 -> {
+                for (y in 0 until cursorRow) for (x in 0 until cols) clearCell(grid[y][x])
+                for (x in 0..cursorCol.coerceAtMost(cols - 1)) clearCell(grid[cursorRow][x])
+            }
+            else -> for (y in 0 until rows) for (x in 0 until cols) clearCell(grid[y][x])
+        }
+    }
+
+    private fun eraseLine(mode: Int) {
+        val row = grid[cursorRow]
+        when (mode) {
+            0 -> for (x in cursorCol until cols) clearCell(row[x])
+            1 -> for (x in 0..cursorCol.coerceAtMost(cols - 1)) clearCell(row[x])
+            else -> for (x in 0 until cols) clearCell(row[x])
+        }
+    }
+
+    // ---- SGR (colores y atributos) -----------------------------------
+
+    private fun applySgr(p: IntArray) {
+        if (p.isEmpty()) { sgrReset(); return }
+        var i = 0
+        while (i < p.size) {
+            when (val code = p[i]) {
+                0 -> sgrReset()
+                1 -> curBold = true
+                22 -> curBold = false
+                7 -> curInverse = true
+                27 -> curInverse = false
+                in 30..37 -> curFg = ANSI16[code - 30]
+                in 90..97 -> curFg = ANSI16[8 + code - 90]
+                39 -> curFg = DEFAULT
+                in 40..47 -> curBg = ANSI16[code - 40]
+                in 100..107 -> curBg = ANSI16[8 + code - 100]
+                49 -> curBg = DEFAULT
+                38 -> { val r = readExtColor(p, i); curFg = r.first; i = r.second; continue }
+                48 -> { val r = readExtColor(p, i); curBg = r.first; i = r.second; continue }
+                else -> {}
+            }
+            i++
+        }
+    }
+
+    /** Lee 38/48 ;5;n (256) o ;2;r;g;b (truecolor). Devuelve color + índice
+     *  del último parámetro consumido + 1. */
+    private fun readExtColor(p: IntArray, start: Int): Pair<Int, Int> {
+        if (start + 1 >= p.size) return DEFAULT to (start + 1)
+        return when (p[start + 1]) {
+            5 -> {
+                val n = if (start + 2 < p.size) p[start + 2] else 0
+                xterm256(n) to (start + 3)
+            }
+            2 -> {
+                val r = if (start + 2 < p.size) p[start + 2] else 0
+                val g = if (start + 3 < p.size) p[start + 3] else 0
+                val b = if (start + 4 < p.size) p[start + 4] else 0
+                argb(r, g, b) to (start + 5)
+            }
+            else -> DEFAULT to (start + 2)
+        }
+    }
+
+    private fun sgrReset() {
+        curFg = DEFAULT; curBg = DEFAULT; curBold = false; curInverse = false
+    }
+
+    // ---- Reset --------------------------------------------------------
+
+    private fun fullReset() {
+        grid = blankGrid(rows, cols)
+        altGrid = null
+        cursorRow = 0; cursorCol = 0; wrapPending = false
+        scrollTop = 0; scrollBottom = rows - 1
+        cursorVisible = true
+        sgrReset()
+    }
+
+    // ---- Celdas -------------------------------------------------------
+
+    private fun clearCell(cell: Cell) {
+        cell.ch = ' '
+        cell.fg = DEFAULT
+        cell.bg = curBg // el borrado toma el fondo SGR actual (xterm)
+        cell.bold = false
+        cell.inverse = false
+    }
+
+    private fun blankCell(): Cell = Cell(' ', DEFAULT, curBg, false, false)
+
+    private fun blankRow(c: Int): Array<Cell> = Array(c) { Cell() }
+
+    private fun blankGrid(r: Int, c: Int): Array<Array<Cell>> = Array(r) { blankRow(c) }
+
+    private fun copyResized(
+        src: Array<Array<Cell>>,
+        srcRows: Int,
+        srcCols: Int,
+        r: Int,
+        c: Int,
+    ): Array<Array<Cell>> {
+        val out = blankGrid(r, c)
+        val copyRows = minOf(srcRows, r)
+        val copyCols = minOf(srcCols, c)
+        for (y in 0 until copyRows) for (x in 0 until copyCols) out[y][x] = src[y][x]
+        return out
+    }
+
+    companion object {
+        /** Color "por defecto" (resuelto por el render al fg/bg del tema). */
+        const val DEFAULT = Int.MIN_VALUE
+
+        private fun argb(r: Int, g: Int, b: Int): Int =
+            (0xFF shl 24) or ((r and 0xFF) shl 16) or ((g and 0xFF) shl 8) or (b and 0xFF)
+
+        /** Paleta ANSI estándar (xterm), 0..15. */
+        private val ANSI16 = intArrayOf(
+            argb(0, 0, 0), argb(205, 0, 0), argb(0, 205, 0), argb(205, 205, 0),
+            argb(0, 0, 238), argb(205, 0, 205), argb(0, 205, 205), argb(229, 229, 229),
+            argb(127, 127, 127), argb(255, 0, 0), argb(0, 255, 0), argb(255, 255, 0),
+            argb(92, 92, 255), argb(255, 0, 255), argb(0, 255, 255), argb(255, 255, 255),
+        )
+
+        private fun xterm256(n: Int): Int = when {
+            n in 0..15 -> ANSI16[n]
+            n in 16..231 -> {
+                val i = n - 16
+                val r = i / 36; val g = (i / 6) % 6; val b = i % 6
+                fun ch(v: Int) = if (v == 0) 0 else 55 + 40 * v
+                argb(ch(r), ch(g), ch(b))
+            }
+            n in 232..255 -> { val v = 8 + 10 * (n - 232); argb(v, v, v) }
+            else -> argb(229, 229, 229)
+        }
+    }
+}
+
+/** Una celda mutable de la rejilla. */
+class Cell(
+    var ch: Char = ' ',
+    var fg: Int = TerminalEmulator.DEFAULT,
+    var bg: Int = TerminalEmulator.DEFAULT,
+    var bold: Boolean = false,
+    var inverse: Boolean = false,
+)
+
+/** Copia inmutable de la pantalla, lista para renderizar. */
+class TerminalFrame(
+    val rows: Int,
+    val cols: Int,
+    val chars: Array<CharArray>,
+    val fg: Array<IntArray>,
+    val bg: Array<IntArray>,
+    /** bit0 = negrita, bit1 = inverso. */
+    val style: Array<IntArray>,
+    val cursorRow: Int,
+    val cursorCol: Int,
+    val cursorVisible: Boolean,
+)
diff --git a/consola/src/main/java/com/rizoma/consola/ui/ConsolaApp.kt b/consola/src/main/java/com/rizoma/consola/ui/ConsolaApp.kt
new file mode 100644
index 0000000..4d0d761
--- /dev/null
+++ b/consola/src/main/java/com/rizoma/consola/ui/ConsolaApp.kt
@@ -0,0 +1,47 @@
+package com.rizoma.consola.ui
+
+import androidx.activity.compose.BackHandler
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.getValue
+import androidx.lifecycle.viewmodel.compose.viewModel
+import com.rizoma.consola.ConsolaViewModel
+import com.rizoma.consola.Screen
+
+@Composable
+fun ConsolaApp(vm: ConsolaViewModel = viewModel()) {
+    when (vm.screen) {
+        Screen.List -> SessionListScreen(
+            sessions = vm.sessions,
+            loading = vm.listLoading,
+            error = vm.listError,
+            onRefresh = vm::refresh,
+            onAttach = vm::attach,
+            onKill = vm::kill,
+            onSpawn = vm::spawn,
+            onSettings = vm::goSettings,
+        )
+
+        Screen.Terminal -> {
+            BackHandler { vm.goList() }
+            TerminalScreen(
+                title = vm.termTitle,
+                status = vm.termStatus,
+                message = vm.termMessage,
+                frame = vm.frame,
+                onSized = vm::onTerminalSized,
+                onText = vm::sendText,
+                onBytes = vm::sendBytes,
+                onBack = vm::goList,
+            )
+        }
+
+        Screen.Settings -> {
+            BackHandler { vm.goList() }
+            SettingsScreen(
+                config = vm.config,
+                onSave = vm::saveSettings,
+                onBack = vm::goList,
+            )
+        }
+    }
+}
diff --git a/consola/src/main/java/com/rizoma/consola/ui/SessionListScreen.kt b/consola/src/main/java/com/rizoma/consola/ui/SessionListScreen.kt
new file mode 100644
index 0000000..edfe71e
--- /dev/null
+++ b/consola/src/main/java/com/rizoma/consola/ui/SessionListScreen.kt
@@ -0,0 +1,203 @@
+package com.rizoma.consola.ui
+
+import androidx.compose.foundation.background
+import androidx.compose.foundation.clickable
+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.fillMaxSize
+import androidx.compose.foundation.layout.fillMaxWidth
+import androidx.compose.foundation.layout.padding
+import androidx.compose.foundation.layout.weight
+import androidx.compose.foundation.lazy.LazyColumn
+import androidx.compose.foundation.lazy.items
+import androidx.compose.foundation.shape.RoundedCornerShape
+import androidx.compose.material3.AlertDialog
+import androidx.compose.material3.OutlinedTextField
+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.draw.clip
+import androidx.compose.ui.text.font.FontFamily
+import androidx.compose.ui.unit.dp
+import com.rizoma.consola.data.PtySession
+import com.rizoma.consola.ui.theme.MuyTenue
+import com.rizoma.consola.ui.theme.NeonCian
+import com.rizoma.consola.ui.theme.NeonRosa
+import com.rizoma.consola.ui.theme.NeonVerde
+import com.rizoma.consola.ui.theme.Superficie
+import com.rizoma.consola.ui.theme.SuperficieAlta
+import com.rizoma.consola.ui.theme.Tenue
+import com.rizoma.consola.ui.theme.Vacio
+
+@Composable
+fun SessionListScreen(
+    sessions: List<PtySession>,
+    loading: Boolean,
+    error: String?,
+    onRefresh: () -> Unit,
+    onAttach: (PtySession) -> Unit,
+    onKill: (String) -> Unit,
+    onSpawn: (command: String, cwd: String, label: String) -> Unit,
+    onSettings: () -> Unit,
+) {
+    var showSpawn by remember { mutableStateOf(false) }
+
+    Column(Modifier.fillMaxSize().background(Vacio)) {
+        Row(
+            Modifier.fillMaxWidth().background(Superficie).padding(horizontal = 16.dp, vertical = 12.dp),
+            verticalAlignment = Alignment.CenterVertical,
+        ) {
+            Column(Modifier.weight(1f)) {
+                Text("consola", color = NeonVerde)
+                Text("claudes vivos en el daemon", color = MuyTenue)
+            }
+            Text("↻", color = if (loading) MuyTenue else NeonCian, modifier = Modifier.clickable { onRefresh() }.padding(horizontal = 10.dp))
+            Text("⚙", color = Tenue, modifier = Modifier.clickable { onSettings() }.padding(start = 6.dp))
+        }
+
+        if (error != null) {
+            Text(
+                "sin conexión: $error",
+                color = NeonRosa,
+                modifier = Modifier.fillMaxWidth().background(SuperficieAlta).padding(horizontal = 16.dp, vertical = 10.dp),
+            )
+        }
+
+        Box(Modifier.weight(1f).fillMaxWidth()) {
+            if (sessions.isEmpty()) {
+                Text(
+                    if (loading) "buscando…" else "no hay sesiones · toca + para abrir una",
+                    color = MuyTenue,
+                    modifier = Modifier.align(Alignment.Center).padding(24.dp),
+                )
+            } else {
+                LazyColumn(
+                    Modifier.fillMaxSize().padding(horizontal = 12.dp),
+                    verticalArrangement = Arrangement.spacedBy(8.dp),
+                    contentPadding = androidx.compose.foundation.layout.PaddingValues(vertical = 12.dp),
+                ) {
+                    items(sessions, key = { it.id }) { s ->
+                        SessionCard(s, onAttach = { onAttach(s) }, onKill = { onKill(s.id) })
+                    }
+                }
+            }
+
+            // Botón "nueva sesión".
+            Text(
+                "+",
+                color = Vacio,
+                modifier = Modifier
+                    .align(Alignment.BottomEnd)
+                    .padding(24.dp)
+                    .clip(RoundedCornerShape(50))
+                    .background(NeonVerde)
+                    .clickable { showSpawn = true }
+                    .padding(horizontal = 22.dp, vertical = 14.dp),
+            )
+        }
+    }
+
+    if (showSpawn) {
+        SpawnDialog(
+            onDismiss = { showSpawn = false },
+            onCreate = { cmd, cwd, label ->
+                showSpawn = false
+                onSpawn(cmd, cwd, label)
+            },
+        )
+    }
+}
+
+@Composable
+private fun SessionCard(session: PtySession, onAttach: () -> Unit, onKill: () -> Unit) {
+    Column(
+        Modifier
+            .fillMaxWidth()
+            .clip(RoundedCornerShape(12.dp))
+            .background(Superficie)
+            .clickable { onAttach() }
+            .padding(14.dp),
+    ) {
+        Row(verticalAlignment = Alignment.CenterVertical) {
+            Box(
+                Modifier
+                    .clip(RoundedCornerShape(50))
+                    .background(if (session.alive) NeonVerde else MuyTenue)
+                    .padding(5.dp),
+            ) {}
+            Text(
+                session.label.ifBlank { session.program },
+                color = Tenue,
+                modifier = Modifier.padding(start = 10.dp).weight(1f),
+            )
+            Text("✕", color = NeonRosa, modifier = Modifier.clickable { onKill() }.padding(start = 8.dp))
+        }
+        Text(
+            session.commandLine,
+            color = NeonCian,
+            fontFamily = FontFamily.Monospace,
+            modifier = Modifier.padding(top = 6.dp),
+        )
+        Text(
+            meta(session),
+            color = MuyTenue,
+            modifier = Modifier.padding(top = 4.dp),
+        )
+    }
+}
+
+private fun meta(s: PtySession): String {
+    val estado = if (s.alive) "viva" else "muerta (código ${s.exitCode ?: "?"})"
+    val adj = if (s.attached > 0) " · ${s.attached} adjunto(s)" else ""
+    val dir = if (s.cwd.isNotBlank() && s.cwd != ".") " · ${s.cwd}" else ""
+    return "$estado · ${s.cols}×${s.rows}$adj$dir"
+}
+
+@Composable
+private fun SpawnDialog(onDismiss: () -> Unit, onCreate: (String, String, String) -> Unit) {
+    var command by remember { mutableStateOf("bash") }
+    var cwd by remember { mutableStateOf(".") }
+    var label by remember { mutableStateOf("") }
+
+    AlertDialog(
+        onDismissRequest = onDismiss,
+        containerColor = Superficie,
+        titleContentColor = NeonVerde,
+        textContentColor = Tenue,
+        title = { Text("nueva sesión") },
+        text = {
+            Column {
+                Field("comando", command) { command = it }
+                Field("directorio", cwd) { cwd = it }
+                Field("etiqueta (opcional)", label) { label = it }
+            }
+        },
+        confirmButton = {
+            TextButton(onClick = { onCreate(command, cwd, label) }) {
+                Text("crear", color = NeonVerde)
+            }
+        },
+        dismissButton = {
+            TextButton(onClick = onDismiss) { Text("cancelar", color = MuyTenue) }
+        },
+    )
+}
+
+@Composable
+private fun Field(label: String, value: String, onChange: (String) -> Unit) {
+    OutlinedTextField(
+        value = value,
+        onValueChange = onChange,
+        label = { Text(label, color = MuyTenue) },
+        singleLine = true,
+        modifier = Modifier.fillMaxWidth().padding(vertical = 4.dp),
+    )
+}
diff --git a/consola/src/main/java/com/rizoma/consola/ui/SettingsScreen.kt b/consola/src/main/java/com/rizoma/consola/ui/SettingsScreen.kt
new file mode 100644
index 0000000..885cb92
--- /dev/null
+++ b/consola/src/main/java/com/rizoma/consola/ui/SettingsScreen.kt
@@ -0,0 +1,83 @@
+package com.rizoma.consola.ui
+
+import androidx.compose.foundation.background
+import androidx.compose.foundation.clickable
+import androidx.compose.foundation.layout.Column
+import androidx.compose.foundation.layout.Row
+import androidx.compose.foundation.layout.fillMaxSize
+import androidx.compose.foundation.layout.fillMaxWidth
+import androidx.compose.foundation.layout.padding
+import androidx.compose.foundation.shape.RoundedCornerShape
+import androidx.compose.material3.OutlinedTextField
+import androidx.compose.material3.Text
+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.draw.clip
+import androidx.compose.ui.unit.dp
+import com.rizoma.consola.data.GatewayConfig
+import com.rizoma.consola.ui.theme.MuyTenue
+import com.rizoma.consola.ui.theme.NeonVerde
+import com.rizoma.consola.ui.theme.Superficie
+import com.rizoma.consola.ui.theme.Tenue
+import com.rizoma.consola.ui.theme.Vacio
+
+@Composable
+fun SettingsScreen(
+    config: GatewayConfig,
+    onSave: (url: String, token: String) -> Unit,
+    onBack: () -> Unit,
+) {
+    var url by remember(config) { mutableStateOf(config.baseUrl) }
+    var token by remember(config) { mutableStateOf(config.token) }
+
+    Column(Modifier.fillMaxSize().background(Vacio)) {
+        Row(
+            Modifier.fillMaxWidth().background(Superficie).padding(horizontal = 16.dp, vertical = 12.dp),
+            verticalAlignment = Alignment.CenterVertical,
+        ) {
+            Text("‹", color = NeonVerde, modifier = Modifier.clickable { onBack() }.padding(end = 12.dp))
+            Text("ajustes del gateway", color = Tenue)
+        }
+
+        Column(Modifier.padding(16.dp)) {
+            OutlinedTextField(
+                value = url,
+                onValueChange = { url = it },
+                label = { Text("url del gateway", color = MuyTenue) },
+                singleLine = true,
+                modifier = Modifier.fillMaxWidth().padding(vertical = 6.dp),
+            )
+            Text(
+                "p. ej. http://10.0.2.2:7378 (emulador) o http://<ip-del-host>:7378",
+                color = MuyTenue,
+                modifier = Modifier.padding(bottom = 8.dp),
+            )
+            OutlinedTextField(
+                value = token,
+                onValueChange = { token = it },
+                label = { Text("token (opcional)", color = MuyTenue) },
+                singleLine = true,
+                modifier = Modifier.fillMaxWidth().padding(vertical = 6.dp),
+            )
+            Text(
+                "vacío si el gateway corre sin SHIPOTE_GATEWAY_TOKEN",
+                color = MuyTenue,
+                modifier = Modifier.padding(bottom = 16.dp),
+            )
+            Text(
+                "guardar",
+                color = Vacio,
+                modifier = Modifier
+                    .clip(RoundedCornerShape(10.dp))
+                    .background(NeonVerde)
+                    .clickable { onSave(url, token) }
+                    .padding(horizontal = 22.dp, vertical = 12.dp),
+            )
+        }
+    }
+}
diff --git a/consola/src/main/java/com/rizoma/consola/ui/TerminalScreen.kt b/consola/src/main/java/com/rizoma/consola/ui/TerminalScreen.kt
new file mode 100644
index 0000000..7fd86d2
--- /dev/null
+++ b/consola/src/main/java/com/rizoma/consola/ui/TerminalScreen.kt
@@ -0,0 +1,286 @@
+package com.rizoma.consola.ui
+
+import android.graphics.Paint
+import android.graphics.Typeface
+import androidx.compose.foundation.Canvas
+import androidx.compose.foundation.background
+import androidx.compose.foundation.clickable
+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.fillMaxSize
+import androidx.compose.foundation.layout.fillMaxWidth
+import androidx.compose.foundation.layout.padding
+import androidx.compose.foundation.layout.size
+import androidx.compose.foundation.rememberScrollState
+import androidx.compose.foundation.horizontalScroll
+import androidx.compose.foundation.text.BasicTextField
+import androidx.compose.foundation.text.KeyboardOptions
+import androidx.compose.material3.Text
+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.draw.alpha
+import androidx.compose.ui.focus.focusRequester
+import androidx.compose.ui.focus.FocusRequester
+import androidx.compose.ui.graphics.Color
+import androidx.compose.ui.graphics.drawscope.drawIntoCanvas
+import androidx.compose.ui.graphics.nativeCanvas
+import androidx.compose.ui.graphics.toArgb
+import androidx.compose.ui.input.key.Key
+import androidx.compose.ui.input.key.KeyEventType
+import androidx.compose.ui.input.key.key
+import androidx.compose.ui.input.key.onPreviewKeyEvent
+import androidx.compose.ui.input.key.type
+import androidx.compose.ui.layout.onSizeChanged
+import androidx.compose.ui.platform.LocalDensity
+import androidx.compose.ui.text.input.KeyboardType
+import androidx.compose.ui.text.input.TextFieldValue
+import androidx.compose.ui.unit.dp
+import androidx.compose.ui.unit.sp
+import com.rizoma.consola.TermStatus
+import com.rizoma.consola.term.TerminalEmulator
+import com.rizoma.consola.term.TerminalFrame
+import com.rizoma.consola.ui.theme.MuyTenue
+import com.rizoma.consola.ui.theme.NeonCian
+import com.rizoma.consola.ui.theme.NeonRosa
+import com.rizoma.consola.ui.theme.NeonVerde
+import com.rizoma.consola.ui.theme.Superficie
+import com.rizoma.consola.ui.theme.SuperficieAlta
+import com.rizoma.consola.ui.theme.Tenue
+import com.rizoma.consola.ui.theme.Vacio
+
+private val FG_DEFAULT = Color(0xFFE6ECE6).toArgb()
+private val BG_DEFAULT = Vacio.toArgb()
+private val CURSOR = NeonVerde.toArgb()
+
+@Composable
+fun TerminalScreen(
+    title: String,
+    status: TermStatus,
+    message: String?,
+    frame: TerminalFrame?,
+    onSized: (rows: Int, cols: Int) -> Unit,
+    onText: (String) -> Unit,
+    onBytes: (ByteArray) -> Unit,
+    onBack: () -> Unit,
+) {
+    val focus = remember { FocusRequester() }
+
+    Column(Modifier.fillMaxSize().background(Vacio)) {
+        // Barra superior.
+        Row(
+            Modifier.fillMaxWidth().background(Superficie).padding(horizontal = 14.dp, vertical = 10.dp),
+            verticalAlignment = Alignment.CenterVertical,
+        ) {
+            Text("‹", color = NeonVerde, modifier = Modifier.clickable { onBack() }.padding(end = 12.dp))
+            Column(Modifier.fillMaxWidth()) {
+                Text(title.ifBlank { "terminal" }, color = Tenue, maxLines = 1)
+                Text(statusLine(status, message), color = statusColor(status), maxLines = 1)
+            }
+        }
+
+        // Pantalla del terminal — tap para enfocar el teclado.
+        Box(
+            Modifier
+                .fillMaxWidth()
+                .weight(1f)
+                .background(Vacio)
+                .clickable { focus.requestFocus() }
+                .padding(start = 6.dp, top = 4.dp),
+        ) {
+            TerminalCanvas(frame = frame, onSized = onSized)
+            // Campo oculto que captura las teclas del teclado virtual.
+            HiddenInput(focus = focus, onText = onText, onBytes = onBytes)
+        }
+
+        // Fila de teclas especiales (lo que un teclado virtual no manda).
+        FunctionKeyRow(onBytes = onBytes, onFocus = { focus.requestFocus() })
+    }
+}
+
+@Composable
+private fun TerminalCanvas(frame: TerminalFrame?, onSized: (Int, Int) -> Unit) {
+    val density = LocalDensity.current
+    val textPx = with(density) { 13.sp.toPx() }
+    val paint = remember {
+        Paint().apply {
+            typeface = Typeface.MONOSPACE
+            isAntiAlias = true
+        }
+    }
+    paint.textSize = textPx
+    val fm = paint.fontMetrics
+    val cellW = paint.measureText("M").coerceAtLeast(1f)
+    val cellH = (fm.descent - fm.ascent).coerceAtLeast(1f)
+    val baseline = -fm.ascent
+
+    var reported by remember { mutableStateOf(0 to 0) }
+
+    Canvas(
+        Modifier
+            .fillMaxSize()
+            .onSizeChanged { sz ->
+                val cols = (sz.width / cellW).toInt().coerceAtLeast(1)
+                val rows = (sz.height / cellH).toInt().coerceAtLeast(1)
+                if (rows to cols != reported) {
+                    reported = rows to cols
+                    onSized(rows, cols)
+                }
+            },
+    ) {
+        drawIntoCanvas { canvas ->
+            val nc = canvas.nativeCanvas
+            val f = frame ?: return@drawIntoCanvas
+            for (y in 0 until f.rows) {
+                val top = y * cellH
+                var x = 0
+                while (x < f.cols) {
+                    val st0 = f.style[y][x]
+                    val rawFg = f.fg[y][x]
+                    val rawBg = f.bg[y][x]
+                    var end = x + 1
+                    while (end < f.cols &&
+                        f.style[y][end] == st0 && f.fg[y][end] == rawFg && f.bg[y][end] == rawBg
+                    ) end++
+
+                    val bold = st0 and 1 != 0
+                    val inverse = st0 and 2 != 0
+                    var fg = resolve(rawFg, FG_DEFAULT)
+                    var bg = resolve(rawBg, BG_DEFAULT)
+                    if (inverse) { val t = fg; fg = bg; bg = t }
+
+                    val left = x * cellW
+                    val right = end * cellW
+                    if (bg != BG_DEFAULT) {
+                        paint.color = bg
+                        nc.drawRect(left, top, right, top + cellH, paint)
+                    }
+                    paint.color = fg
+                    paint.isFakeBoldText = bold
+                    val sb = StringBuilder(end - x)
+                    for (xx in x until end) sb.append(f.chars[y][xx])
+                    nc.drawText(sb.toString(), left, top + baseline, paint)
+                    x = end
+                }
+            }
+            // Cursor: bloque neón con el glifo en negativo.
+            if (f.cursorVisible && f.cursorRow < f.rows && f.cursorCol < f.cols) {
+                val left = f.cursorCol * cellW
+                val top = f.cursorRow * cellH
+                paint.color = CURSOR
+                paint.alpha = 170
+                nc.drawRect(left, top, left + cellW, top + cellH, paint)
+                paint.alpha = 255
+                paint.color = BG_DEFAULT
+                paint.isFakeBoldText = false
+                nc.drawText(f.chars[f.cursorRow][f.cursorCol].toString(), left, top + baseline, paint)
+            }
+        }
+    }
+}
+
+private fun resolve(color: Int, default: Int): Int =
+    if (color == TerminalEmulator.DEFAULT) default else color
+
+@Composable
+private fun HiddenInput(
+    focus: FocusRequester,
+    onText: (String) -> Unit,
+    onBytes: (ByteArray) -> Unit,
+) {
+    var value by remember { mutableStateOf(TextFieldValue("")) }
+    BasicTextField(
+        value = value,
+        onValueChange = { nv ->
+            val typed = nv.text
+            if (typed.isNotEmpty()) {
+                // Enter del IME llega como '\n'; el terminal quiere CR.
+                onText(typed.replace('\n', '\r'))
+            }
+            // Reset: el campo solo sirve para capturar deltas, no acumula.
+            value = TextFieldValue("")
+        },
+        keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Password),
+        modifier = Modifier
+            .size(1.dp)
+            .alpha(0f)
+            .focusRequester(focus)
+            .onPreviewKeyEvent { ev ->
+                if (ev.type != KeyEventType.KeyDown) return@onPreviewKeyEvent false
+                val bytes = when (ev.key) {
+                    Key.Backspace -> byteArrayOf(0x7f)
+                    Key.Enter, Key.NumPadEnter -> byteArrayOf(0x0d)
+                    Key.Tab -> byteArrayOf(0x09)
+                    Key.Escape -> byteArrayOf(0x1b)
+                    Key.DirectionUp -> esc('A')
+                    Key.DirectionDown -> esc('B')
+                    Key.DirectionRight -> esc('C')
+                    Key.DirectionLeft -> esc('D')
+                    else -> null
+                }
+                if (bytes != null) { onBytes(bytes); true } else false
+            },
+    )
+}
+
+@Composable
+private fun FunctionKeyRow(onBytes: (ByteArray) -> Unit, onFocus: () -> Unit) {
+    val scroll = rememberScrollState()
+    Row(
+        Modifier
+            .fillMaxWidth()
+            .background(SuperficieAlta)
+            .horizontalScroll(scroll)
+            .padding(horizontal = 8.dp, vertical = 6.dp),
+        horizontalArrangement = Arrangement.spacedBy(6.dp),
+        verticalAlignment = Alignment.CenterVertical,
+    ) {
+        KeyCap("esc") { onBytes(byteArrayOf(0x1b)) }
+        KeyCap("tab") { onBytes(byteArrayOf(0x09)) }
+        KeyCap("^C") { onBytes(byteArrayOf(0x03)) }
+        KeyCap("^D") { onBytes(byteArrayOf(0x04)) }
+        KeyCap("^L") { onBytes(byteArrayOf(0x0c)) }
+        KeyCap("←") { onBytes(esc('D')) }
+        KeyCap("↑") { onBytes(esc('A')) }
+        KeyCap("↓") { onBytes(esc('B')) }
+        KeyCap("→") { onBytes(esc('C')) }
+        KeyCap("⌫") { onBytes(byteArrayOf(0x7f)) }
+        KeyCap("↩") { onBytes(byteArrayOf(0x0d)) }
+        KeyCap("⌨", accent = NeonCian) { onFocus() }
+    }
+}
+
+@Composable
+private fun KeyCap(label: String, accent: Color = NeonVerde, onClick: () -> Unit) {
+    Text(
+        label,
+        color = accent,
+        modifier = Modifier
+            .background(Superficie)
+            .clickable { onClick() }
+            .padding(horizontal = 12.dp, vertical = 8.dp),
+    )
+}
+
+private fun esc(final: Char): ByteArray = byteArrayOf(0x1b, '['.code.toByte(), final.code.toByte())
+
+private fun statusLine(status: TermStatus, message: String?): String = when (status) {
+    TermStatus.Connecting -> "conectando…"
+    TermStatus.Live -> "en vivo"
+    TermStatus.Exited -> message ?: "terminó"
+    TermStatus.Error -> message ?: "error"
+    TermStatus.Closed -> message ?: "desconectado"
+}
+
+private fun statusColor(status: TermStatus): Color = when (status) {
+    TermStatus.Live -> NeonVerde
+    TermStatus.Connecting -> MuyTenue
+    TermStatus.Error -> NeonRosa
+    else -> Tenue
+}
diff --git a/consola/src/main/java/com/rizoma/consola/ui/theme/Theme.kt b/consola/src/main/java/com/rizoma/consola/ui/theme/Theme.kt
new file mode 100644
index 0000000..afd044a
--- /dev/null
+++ b/consola/src/main/java/com/rizoma/consola/ui/theme/Theme.kt
@@ -0,0 +1,36 @@
+package com.rizoma.consola.ui.theme
+
+import androidx.compose.material3.MaterialTheme
+import androidx.compose.material3.darkColorScheme
+import androidx.compose.runtime.Composable
+import androidx.compose.ui.graphics.Color
+
+// Paleta del rizoma: neón sobre vacío (compartida con Órbita/Trazo).
+val Vacio = Color(0xFF050507)
+val Superficie = Color(0xFF0C0C12)
+val SuperficieAlta = Color(0xFF14141C)
+val NeonVerde = Color(0xFF7CFF4F)
+val NeonRosa = Color(0xFFFF2E88)
+val NeonCian = Color(0xFF00E5FF)
+val Tenue = Color(0xB3FFFFFF)
+val MuyTenue = Color(0x66FFFFFF)
+
+private val EsquemaConsola = darkColorScheme(
+    primary = NeonVerde,
+    onPrimary = Vacio,
+    secondary = NeonRosa,
+    onSecondary = Vacio,
+    tertiary = NeonCian,
+    onTertiary = Vacio,
+    background = Vacio,
+    onBackground = Color.White,
+    surface = Superficie,
+    onSurface = Color.White,
+    surfaceVariant = SuperficieAlta,
+    onSurfaceVariant = Tenue,
+)
+
+@Composable
+fun ConsolaTheme(content: @Composable () -> Unit) {
+    MaterialTheme(colorScheme = EsquemaConsola, content = content)
+}
diff --git a/consola/src/main/res/drawable/ic_launcher_fg.xml b/consola/src/main/res/drawable/ic_launcher_fg.xml
new file mode 100644
index 0000000..8abd2be
--- /dev/null
+++ b/consola/src/main/res/drawable/ic_launcher_fg.xml
@@ -0,0 +1,24 @@
+<?xml version="1.0" encoding="utf-8"?>
+<vector xmlns:android="http://schemas.android.com/apk/res/android"
+    android:width="108dp"
+    android:height="108dp"
+    android:viewportWidth="108"
+    android:viewportHeight="108">
+    <!-- prompt: chevron neón -->
+    <path
+        android:pathData="M 40 44 L 52 54 L 40 64"
+        android:strokeColor="#7CFF4F"
+        android:strokeWidth="4"
+        android:strokeLineCap="round"
+        android:strokeLineJoin="round" />
+    <!-- cursor: bloque parpadeante -->
+    <path
+        android:fillColor="#00E5FF"
+        android:pathData="M 58 60 h 12 v 5 h -12 z" />
+    <!-- subrayado de la línea de comando -->
+    <path
+        android:pathData="M 40 72 L 70 72"
+        android:strokeColor="#FF2E88"
+        android:strokeWidth="2.5"
+        android:strokeLineCap="round" />
+</vector>
diff --git a/consola/src/main/res/mipmap-anydpi-v26/ic_launcher.xml b/consola/src/main/res/mipmap-anydpi-v26/ic_launcher.xml
new file mode 100644
index 0000000..088fcce
--- /dev/null
+++ b/consola/src/main/res/mipmap-anydpi-v26/ic_launcher.xml
@@ -0,0 +1,5 @@
+<?xml version="1.0" encoding="utf-8"?>
+<adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android">
+    <background android:drawable="@color/ic_bg" />
+    <foreground android:drawable="@drawable/ic_launcher_fg" />
+</adaptive-icon>
diff --git a/consola/src/main/res/values/colors.xml b/consola/src/main/res/values/colors.xml
new file mode 100644
index 0000000..917256d
--- /dev/null
+++ b/consola/src/main/res/values/colors.xml
@@ -0,0 +1,4 @@
+<?xml version="1.0" encoding="utf-8"?>
+<resources>
+    <color name="ic_bg">#050507</color>
+</resources>
diff --git a/consola/src/main/res/values/strings.xml b/consola/src/main/res/values/strings.xml
new file mode 100644
index 0000000..7c5073e
--- /dev/null
+++ b/consola/src/main/res/values/strings.xml
@@ -0,0 +1,4 @@
+<?xml version="1.0" encoding="utf-8"?>
+<resources>
+    <string name="app_name">consola</string>
+</resources>
diff --git a/consola/src/main/res/values/themes.xml b/consola/src/main/res/values/themes.xml
new file mode 100644
index 0000000..923d0f9
--- /dev/null
+++ b/consola/src/main/res/values/themes.xml
@@ -0,0 +1,9 @@
+<?xml version="1.0" encoding="utf-8"?>
+<resources>
+    <style name="Theme.Consola" parent="android:Theme.Material.NoActionBar">
+        <item name="android:windowBackground">#050507</item>
+        <item name="android:statusBarColor">@android:color/transparent</item>
+        <item name="android:navigationBarColor">@android:color/transparent</item>
+        <item name="android:windowLayoutInDisplayCutoutMode">shortEdges</item>
+    </style>
+</resources>
diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml
index 91d1ef1..7733bd4 100644
--- a/gradle/libs.versions.toml
+++ b/gradle/libs.versions.toml
@@ -7,6 +7,7 @@ activityCompose = "1.10.1"
 composeBom = "2025.06.01"
 datastore = "1.1.7"
 coroutines = "1.10.2"
+okhttp = "4.12.0"
 
 [libraries]
 androidx-core-ktx = { group = "androidx.core", name = "core-ktx", version.ref = "coreKtx" }
@@ -22,6 +23,7 @@ androidx-compose-animation = { group = "androidx.compose.animation", name = "ani
 androidx-compose-material3 = { group = "androidx.compose.material3", name = "material3" }
 androidx-datastore-preferences = { group = "androidx.datastore", name = "datastore-preferences", version.ref = "datastore" }
 kotlinx-coroutines-android = { group = "org.jetbrains.kotlinx", name = "kotlinx-coroutines-android", version.ref = "coroutines" }
+okhttp = { group = "com.squareup.okhttp3", name = "okhttp", version.ref = "okhttp" }
 
 [plugins]
 android-application = { id = "com.android.application", version.ref = "agp" }
diff --git a/settings.gradle.kts b/settings.gradle.kts
index 6fe071b..6d52dec 100644
--- a/settings.gradle.kts
+++ b/settings.gradle.kts
@@ -22,3 +22,4 @@ dependencyResolutionManagement {
 rootProject.name = "rizoma"
 include(":launcher")
 include(":keyboard")
+include(":consola")