tawasuyu

sergio/humanoid

HTML 50Markdown 1TOML 10 Sutichaykuna

consola: acciones rápidas en la notif (responder/continuar/esc)

La notif de aviso ahora trae acciones que inyectan al PTY exacto vía
PtySendInput sin abrir el terminal: RemoteInput 'responder' (texto +
Enter), '⏎ continuar' (CR) y 'esc'. El servicio carga config fresca de
Settings (robusto con la app cerrada) y cancela el aviso tras actuar.

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

e2e8a7b60e4cf78f1a2964feb25ef2ce31acd255 · ruwaq Sergio · (4 killa ñawpaq) · tayta 03a89d0db · kay commitpi sach'ata qhaway

Semántico sayk'uy

Mana ima sutichaypas t'ikrakurqanchu: qillqalla, yachachiylla utaq willaylla t'ikrakurqan.

2 qillqakuna · +101 −6

diff --git a/consola/src/main/java/com/rizoma/consola/data/Protocol.kt b/consola/src/main/java/com/rizoma/consola/data/Protocol.kt
index b7eeea9..01312dd 100644
--- a/consola/src/main/java/com/rizoma/consola/data/Protocol.kt
+++ b/consola/src/main/java/com/rizoma/consola/data/Protocol.kt
@@ -61,6 +61,15 @@ object Rpc {
 
     fun ptyKill(session: String): String =
         JSONObject().put("PtyKill", JSONObject().put("session", session)).toString()
+
+    /** `Request::PtySendInput` — inyecta teclas a una sesión por id (sin attach). */
+    fun ptyInput(session: String, bytes: ByteArray): String {
+        val arr = JSONArray()
+        for (b in bytes) arr.put(b.toInt() and 0xFF)
+        return JSONObject()
+            .put("PtySendInput", JSONObject().put("session", session).put("bytes", arr))
+            .toString()
+    }
 }
 
 /** Resultado de una respuesta `Response` ya parseada. */
@@ -68,6 +77,7 @@ 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 class InputSent(val session: String, val existed: Boolean) : RpcResponse
     data object Pong : RpcResponse
     data class Failure(val message: String) : RpcResponse
 }
@@ -100,6 +110,10 @@ object RpcParser {
                 val k = obj.getJSONObject("PtyKilled")
                 RpcResponse.Killed(k.getString("session"), k.getBoolean("existed"))
             }
+            obj.has("PtyInputSent") -> {
+                val k = obj.getJSONObject("PtyInputSent")
+                RpcResponse.InputSent(k.getString("session"), k.getBoolean("existed"))
+            }
             obj.has("Error") ->
                 RpcResponse.Failure(obj.getJSONObject("Error").getString("message"))
             else -> RpcResponse.Failure("respuesta desconocida: ${trimmed.take(120)}")
diff --git a/consola/src/main/java/com/rizoma/consola/service/ConsolaService.kt b/consola/src/main/java/com/rizoma/consola/service/ConsolaService.kt
index 245a503..cab6dce 100644
--- a/consola/src/main/java/com/rizoma/consola/service/ConsolaService.kt
+++ b/consola/src/main/java/com/rizoma/consola/service/ConsolaService.kt
@@ -14,6 +14,7 @@ import androidx.compose.runtime.getValue
 import androidx.compose.runtime.mutableStateOf
 import androidx.compose.runtime.setValue
 import androidx.core.app.NotificationCompat
+import androidx.core.app.RemoteInput
 import androidx.core.app.ServiceCompat
 import com.rizoma.consola.MainActivity
 import com.rizoma.consola.R
@@ -24,6 +25,8 @@ 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.Rpc
+import com.rizoma.consola.data.Settings
 import com.rizoma.consola.term.TerminalEmulator
 import com.rizoma.consola.term.TerminalFrame
 import kotlinx.coroutines.CoroutineScope
@@ -33,6 +36,7 @@ import kotlinx.coroutines.SupervisorJob
 import kotlinx.coroutines.cancel
 import kotlinx.coroutines.channels.Channel
 import kotlinx.coroutines.delay
+import kotlinx.coroutines.flow.first
 import kotlinx.coroutines.launch
 import kotlinx.coroutines.withContext
 import org.json.JSONObject
@@ -103,6 +107,29 @@ class ConsolaService : Service() {
     override fun onBind(intent: Intent?): IBinder = binder
 
     override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int {
+        when (intent?.action) {
+            ACTION_REPLY -> {
+                val session = intent.getStringExtra(EXTRA_SESSION)
+                val reply = RemoteInput.getResultsFromIntent(intent)
+                    ?.getCharSequence(KEY_REPLY)?.toString()
+                if (!session.isNullOrBlank() && !reply.isNullOrEmpty()) {
+                    // El texto + Enter: responde a Claude sin abrir el terminal.
+                    injectInput(session, (reply + "\r").toByteArray(Charsets.UTF_8))
+                }
+                cancelAlert(intent)
+            }
+            ACTION_KEY -> {
+                val session = intent.getStringExtra(EXTRA_SESSION)
+                val bytes = when (intent.getStringExtra(EXTRA_KEY)) {
+                    "enter" -> byteArrayOf(0x0d)
+                    "esc" -> byteArrayOf(0x1b)
+                    "yes" -> "y\r".toByteArray()
+                    else -> null
+                }
+                if (!session.isNullOrBlank() && bytes != null) injectInput(session, bytes)
+                cancelAlert(intent)
+            }
+        }
         if (intent?.action == ACTION_STOP) {
             // "detener" en la notificación = dejar de vigilar y soltar todo.
             supervising = false
@@ -184,17 +211,22 @@ class ConsolaService : Service() {
 
     private fun notifyAlert(title: String, text: String, sessionId: String?) {
         val mgr = getSystemService(NotificationManager::class.java)
+        // id rotativo para que no se pisen avisos consecutivos.
+        val nid = alertId++
+        if (alertId > ALERT_ID_BASE + 50) alertId = ALERT_ID_BASE
+
         // Al tocar: abre la app y, si el evento trae la sesión exacta, se
         // adjunta a ESE terminal (SHUMA_SESSION sembrado por el daemon).
         val intent = Intent(this, MainActivity::class.java)
             .addFlags(Intent.FLAG_ACTIVITY_SINGLE_TOP)
         if (sessionId != null) intent.putExtra(MainActivity.EXTRA_SESSION, sessionId)
         val open = PendingIntent.getActivity(
-            this, sessionId?.hashCode() ?: 2,
+            this, nid * 8,
             intent,
             PendingIntent.FLAG_IMMUTABLE or PendingIntent.FLAG_UPDATE_CURRENT,
         )
-        val n = NotificationCompat.Builder(this, ALERT_CHANNEL_ID)
+
+        val b = NotificationCompat.Builder(this, ALERT_CHANNEL_ID)
             .setSmallIcon(R.drawable.ic_consola)
             .setContentTitle(title)
             .setContentText(text)
@@ -203,10 +235,53 @@ class ConsolaService : Service() {
             .setCategory(NotificationCompat.CATEGORY_MESSAGE)
             .setAutoCancel(true)
             .setContentIntent(open)
-            .build()
-        // ids rotativos para que no se pisen los avisos consecutivos.
-        mgr.notify(alertId++, n)
-        if (alertId > ALERT_ID_BASE + 50) alertId = ALERT_ID_BASE
+
+        // Acciones rápidas: solo si sabemos a qué sesión inyectar.
+        if (sessionId != null) {
+            // Responder con texto libre (se manda + Enter) desde la notif.
+            val reply = RemoteInput.Builder(KEY_REPLY).setLabel("responder a Claude…").build()
+            val replyPending = PendingIntent.getService(
+                this, nid * 8 + 1,
+                serviceAction(ACTION_REPLY, sessionId, nid),
+                PendingIntent.FLAG_MUTABLE or PendingIntent.FLAG_UPDATE_CURRENT,
+            )
+            b.addAction(
+                NotificationCompat.Action.Builder(0, "responder", replyPending)
+                    .addRemoteInput(reply).build(),
+            )
+            b.addAction(0, "⏎ continuar", keyPending(sessionId, "enter", nid, nid * 8 + 2))
+            b.addAction(0, "esc", keyPending(sessionId, "esc", nid, nid * 8 + 3))
+        }
+
+        mgr.notify(nid, b.build())
+    }
+
+    private fun serviceAction(action: String, sessionId: String, nid: Int): Intent =
+        Intent(this, ConsolaService::class.java)
+            .setAction(action)
+            .putExtra(EXTRA_SESSION, sessionId)
+            .putExtra(EXTRA_NOTIF_ID, nid)
+
+    private fun keyPending(sessionId: String, key: String, nid: Int, req: Int): PendingIntent =
+        PendingIntent.getService(
+            this, req,
+            serviceAction(ACTION_KEY, sessionId, nid).putExtra(EXTRA_KEY, key),
+            PendingIntent.FLAG_IMMUTABLE or PendingIntent.FLAG_UPDATE_CURRENT,
+        )
+
+    /** Inyecta teclas a la sesión exacta por `/rpc` (PtySendInput), sin attach. */
+    private fun injectInput(session: String, bytes: ByteArray) {
+        scope.launch {
+            // Config fresca por si la acción llega con la app cerrada.
+            runCatching { Settings(this@ConsolaService).config.first() }.getOrNull()
+                ?.let { client.config = it }
+            client.rpc(Rpc.ptyInput(session, bytes))
+        }
+    }
+
+    private fun cancelAlert(intent: Intent) {
+        val nid = intent.getIntExtra(EXTRA_NOTIF_ID, -1)
+        if (nid >= 0) getSystemService(NotificationManager::class.java).cancel(nid)
     }
 
     private fun ensureAlertChannel() {
@@ -436,5 +511,11 @@ class ConsolaService : Service() {
         private const val NEON_VERDE = 0xFF7CFF4F.toInt()
         private const val SUPERVISOR_SUBTITLE = "vigilando tus Claudes"
         const val ACTION_STOP = "com.rizoma.consola.action.STOP"
+        const val ACTION_REPLY = "com.rizoma.consola.action.REPLY"
+        const val ACTION_KEY = "com.rizoma.consola.action.KEY"
+        const val EXTRA_SESSION = MainActivity.EXTRA_SESSION
+        const val EXTRA_KEY = "key"
+        const val EXTRA_NOTIF_ID = "notif_id"
+        const val KEY_REPLY = "reply"
     }
 }