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>
Semantic delta
No definition changed: this change is text, documentation or data.
2 files · +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"
}
}