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>
Semántico sayk'uy
Mana ima sutichaypas t'ikrakurqanchu: qillqalla, yachachiylla utaq willaylla t'ikrakurqan.
21 qillqakuna · +1957 −0
- consola/README.md +43 −0
- consola/build.gradle.kts +53 −0
- consola/src/main/AndroidManifest.xml +27 −0
- consola/src/main/java/com/rizoma/consola/ConsolaViewModel.kt +250 −0
- consola/src/main/java/com/rizoma/consola/MainActivity.kt +20 −0
- consola/src/main/java/com/rizoma/consola/data/GatewayClient.kt +159 −0
- consola/src/main/java/com/rizoma/consola/data/Protocol.kt +128 −0
- consola/src/main/java/com/rizoma/consola/data/Settings.kt +47 −0
- consola/src/main/java/com/rizoma/consola/term/TerminalEmulator.kt +526 −0
- consola/src/main/java/com/rizoma/consola/ui/ConsolaApp.kt +47 −0
- consola/src/main/java/com/rizoma/consola/ui/SessionListScreen.kt +203 −0
- consola/src/main/java/com/rizoma/consola/ui/SettingsScreen.kt +83 −0
- consola/src/main/java/com/rizoma/consola/ui/TerminalScreen.kt +286 −0
- consola/src/main/java/com/rizoma/consola/ui/theme/Theme.kt +36 −0
- consola/src/main/res/drawable/ic_launcher_fg.xml +24 −0
- consola/src/main/res/mipmap-anydpi-v26/ic_launcher.xml +5 −0
- consola/src/main/res/values/colors.xml +4 −0
- consola/src/main/res/values/strings.xml +4 −0
- consola/src/main/res/values/themes.xml +9 −0
- gradle/libs.versions.toml +2 −0
- settings.gradle.kts +1 −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")