consola: claudificar — Claude por defecto, modo danger, PATH y teclas
- spawn vía login shell que prepende los bin del usuario al PATH (~/.npm-global/bin, donde vive claude; el daemon arranca con PATH mínimo). Antes 'ni siquiera encontraba claude'. - nueva sesión por defecto: 'claude --dangerously-skip-permissions' (modo danger) + botón flotante '+ claude' de un toque. - fila de teclas del terminal: ctrl/alt pegajosos (modifican la siguiente tecla), PgUp/PgDn/Home/End además de esc/tab/flechas. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Delta semántico
Ninguna definición cambió: el cambio es de texto, documentación o datos.
3 archivos · +109 −28
diff --git a/consola/src/main/java/com/rizoma/consola/ConsolaViewModel.kt b/consola/src/main/java/com/rizoma/consola/ConsolaViewModel.kt
index d915e3e..5b88c2e 100644
--- a/consola/src/main/java/com/rizoma/consola/ConsolaViewModel.kt
+++ b/consola/src/main/java/com/rizoma/consola/ConsolaViewModel.kt
@@ -130,18 +130,27 @@ class ConsolaViewModel(app: Application) : AndroidViewModel(app) {
// ---- Terminal -----------------------------------------------------
- /** Crea una sesión nueva (spawn) y se adjunta. */
+ /**
+ * Crea una sesión nueva (spawn) y se adjunta. El comando corre dentro de un
+ * login shell que **prepende los bin del usuario al PATH** —`~/.npm-global/
+ * bin` (donde vive `claude`), `~/.local/bin`, `~/bin`— porque el daemon
+ * (lanzado por OpenRC) arranca con un PATH mínimo y, en su entorno limpio,
+ * ni los perfiles de shell traen esos directorios. El `exec` reemplaza al
+ * shell para que cerrar el programa cierre la sesión.
+ */
fun spawn(command: String, cwd: String, label: String) {
- val parts = command.trim().split(Regex("\\s+")).filter { it.isNotEmpty() }
- if (parts.isEmpty()) return
+ val cmd = command.trim()
+ if (cmd.isEmpty()) return
+ val niceLabel = label.ifBlank { cmd.substringBefore(' ').substringAfterLast('/') }
+ val userPath = "\$HOME/.npm-global/bin:\$HOME/.local/bin:\$HOME/bin:\$PATH"
startTerminal { svc ->
svc.openTerminal(
session = null,
- program = parts.first(),
- args = parts.drop(1),
+ program = "bash",
+ args = listOf("-lc", "export PATH=\"$userPath\"; exec $cmd"),
cwd = cwd.ifBlank { "." },
- label = label,
- title = label.ifBlank { command },
+ label = niceLabel,
+ title = niceLabel.ifBlank { cmd },
)
}
}
diff --git a/consola/src/main/java/com/rizoma/consola/ui/SessionListScreen.kt b/consola/src/main/java/com/rizoma/consola/ui/SessionListScreen.kt
index 06c3932..e2d3fb0 100644
--- a/consola/src/main/java/com/rizoma/consola/ui/SessionListScreen.kt
+++ b/consola/src/main/java/com/rizoma/consola/ui/SessionListScreen.kt
@@ -90,18 +90,31 @@ fun SessionListScreen(
}
}
- // 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),
- )
+ // Acciones flotantes: un Claude de un toque (danger) + el genérico.
+ Row(
+ Modifier.align(Alignment.BottomEnd).padding(24.dp),
+ horizontalArrangement = Arrangement.spacedBy(12.dp),
+ verticalAlignment = Alignment.CenterVertically,
+ ) {
+ Text(
+ "+ claude",
+ color = Vacio,
+ modifier = Modifier
+ .clip(RoundedCornerShape(50))
+ .background(NeonVerde)
+ .clickable { onSpawn(CLAUDE_CMD, CLAUDE_CWD, "claude") }
+ .padding(horizontal = 18.dp, vertical = 14.dp),
+ )
+ Text(
+ "+",
+ color = NeonVerde,
+ modifier = Modifier
+ .clip(RoundedCornerShape(50))
+ .background(Superficie)
+ .clickable { showSpawn = true }
+ .padding(horizontal = 18.dp, vertical = 14.dp),
+ )
+ }
}
}
@@ -161,10 +174,14 @@ private fun meta(s: PtySession): String {
return "$estado · ${s.cols}×${s.rows}$adj$dir"
}
+private const val CLAUDE_CMD = "claude --dangerously-skip-permissions"
+private const val CLAUDE_CWD = "/home/sergio"
+
@Composable
private fun SpawnDialog(onDismiss: () -> Unit, onCreate: (String, String, String) -> Unit) {
- var command by remember { mutableStateOf("bash") }
- var cwd by remember { mutableStateOf(".") }
+ // Claudificado: por defecto un Claude en modo danger; editable para un shell.
+ var command by remember { mutableStateOf(CLAUDE_CMD) }
+ var cwd by remember { mutableStateOf(CLAUDE_CWD) }
var label by remember { mutableStateOf("") }
AlertDialog(
diff --git a/consola/src/main/java/com/rizoma/consola/ui/TerminalScreen.kt b/consola/src/main/java/com/rizoma/consola/ui/TerminalScreen.kt
index 5073d36..1aa75f4 100644
--- a/consola/src/main/java/com/rizoma/consola/ui/TerminalScreen.kt
+++ b/consola/src/main/java/com/rizoma/consola/ui/TerminalScreen.kt
@@ -83,6 +83,20 @@ fun TerminalScreen(
// Abre la bitácora sola cuando algo falla: ahí es cuando hace falta verla.
LaunchedEffect(status) { if (status == TermStatus.Error) showDebug = true }
+ // Modificadores pegajosos: se arman con un toque y se aplican a la SIGUIENTE
+ // tecla escrita (ctrl+letra, alt+letra), como en un teclado de terminal.
+ var ctrlArmed by remember { mutableStateOf(false) }
+ var altArmed by remember { mutableStateOf(false) }
+ val emitText: (String) -> Unit = { typed ->
+ if (ctrlArmed || altArmed) {
+ onBytes(applyMods(typed, ctrlArmed, altArmed))
+ ctrlArmed = false
+ altArmed = false
+ } else {
+ onText(typed.replace('\n', '\r'))
+ }
+ }
+
Column(Modifier.fillMaxSize().background(Vacio)) {
// Barra superior.
Row(
@@ -116,11 +130,18 @@ fun TerminalScreen(
) {
TerminalCanvas(frame = frame, onSized = onSized)
// Campo oculto que captura las teclas del teclado virtual.
- HiddenInput(focus = focus, onText = onText, onBytes = onBytes)
+ HiddenInput(focus = focus, onText = emitText, onBytes = onBytes)
}
// Fila de teclas especiales (lo que un teclado virtual no manda).
- FunctionKeyRow(onBytes = onBytes, onFocus = { focus.requestFocus() })
+ FunctionKeyRow(
+ onBytes = onBytes,
+ onFocus = { focus.requestFocus() },
+ ctrlArmed = ctrlArmed,
+ altArmed = altArmed,
+ onToggleCtrl = { ctrlArmed = !ctrlArmed; altArmed = false },
+ onToggleAlt = { altArmed = !altArmed; ctrlArmed = false },
+ )
}
}
@@ -275,8 +296,8 @@ private fun HiddenInput(
onValueChange = { nv ->
val typed = nv.text
if (typed.isNotEmpty()) {
- // Enter del IME llega como '\n'; el terminal quiere CR.
- onText(typed.replace('\n', '\r'))
+ // El padre (emitText) aplica modificadores y traduce '\n'→CR.
+ onText(typed)
}
// Reset: el campo solo sirve para capturar deltas, no acumula.
value = TextFieldValue("")
@@ -305,7 +326,14 @@ private fun HiddenInput(
}
@Composable
-private fun FunctionKeyRow(onBytes: (ByteArray) -> Unit, onFocus: () -> Unit) {
+private fun FunctionKeyRow(
+ onBytes: (ByteArray) -> Unit,
+ onFocus: () -> Unit,
+ ctrlArmed: Boolean,
+ altArmed: Boolean,
+ onToggleCtrl: () -> Unit,
+ onToggleAlt: () -> Unit,
+) {
val scroll = rememberScrollState()
Row(
Modifier
@@ -316,6 +344,9 @@ private fun FunctionKeyRow(onBytes: (ByteArray) -> Unit, onFocus: () -> Unit) {
horizontalArrangement = Arrangement.spacedBy(6.dp),
verticalAlignment = Alignment.CenterVertically,
) {
+ // Modificadores pegajosos: se iluminan cuando están armados.
+ KeyCap("ctrl", accent = if (ctrlArmed) Vacio else NeonRosa, fill = if (ctrlArmed) NeonRosa else Superficie) { onToggleCtrl() }
+ KeyCap("alt", accent = if (altArmed) Vacio else NeonRosa, fill = if (altArmed) NeonRosa else Superficie) { onToggleAlt() }
KeyCap("esc") { onBytes(byteArrayOf(0x1b)) }
KeyCap("tab") { onBytes(byteArrayOf(0x09)) }
KeyCap("^C") { onBytes(byteArrayOf(0x03)) }
@@ -325,6 +356,10 @@ private fun FunctionKeyRow(onBytes: (ByteArray) -> Unit, onFocus: () -> Unit) {
KeyCap("↑") { onBytes(esc('A')) }
KeyCap("↓") { onBytes(esc('B')) }
KeyCap("→") { onBytes(esc('C')) }
+ KeyCap("PgUp") { onBytes(csi("5~")) }
+ KeyCap("PgDn") { onBytes(csi("6~")) }
+ KeyCap("Home") { onBytes(csi("H")) }
+ KeyCap("End") { onBytes(csi("F")) }
KeyCap("⌫") { onBytes(byteArrayOf(0x7f)) }
KeyCap("↩") { onBytes(byteArrayOf(0x0d)) }
KeyCap("⌨", accent = NeonCian) { onFocus() }
@@ -332,12 +367,12 @@ private fun FunctionKeyRow(onBytes: (ByteArray) -> Unit, onFocus: () -> Unit) {
}
@Composable
-private fun KeyCap(label: String, accent: Color = NeonVerde, onClick: () -> Unit) {
+private fun KeyCap(label: String, accent: Color = NeonVerde, fill: Color = Superficie, onClick: () -> Unit) {
Text(
label,
color = accent,
modifier = Modifier
- .background(Superficie)
+ .background(fill)
.clickable { onClick() }
.padding(horizontal = 12.dp, vertical = 8.dp),
)
@@ -345,6 +380,26 @@ private fun KeyCap(label: String, accent: Color = NeonVerde, onClick: () -> Unit
private fun esc(final: Char): ByteArray = byteArrayOf(0x1b, '['.code.toByte(), final.code.toByte())
+/** Secuencia CSI (`ESC [` + [tail]), p.ej. `csi("5~")` = PgUp. */
+private fun csi(tail: String): ByteArray =
+ byteArrayOf(0x1b, '['.code.toByte()) + tail.toByteArray(Charsets.US_ASCII)
+
+/**
+ * Aplica los modificadores pegajosos al texto escrito. ctrl pliega la primera
+ * letra a su carácter de control (ctrl+A=0x01…); alt antepone ESC. El resto
+ * del texto va literal.
+ */
+private fun applyMods(text: String, ctrl: Boolean, alt: Boolean): ByteArray {
+ if (text.isEmpty()) return ByteArray(0)
+ val out = ArrayList<Byte>(text.length + 1)
+ if (alt) out.add(0x1b)
+ val first = text[0]
+ val b = if (ctrl) (first.uppercaseChar().code and 0x1f) else first.code
+ out.add(b.toByte())
+ for (i in 1 until text.length) out.add(text[i].code.toByte())
+ return out.toByteArray()
+}
+
private fun statusLine(status: TermStatus, message: String?): String = when (status) {
TermStatus.Connecting -> "conectando…"
TermStatus.Live -> "en vivo"