feat(android): add settings detail panels (#95148)

* feat(android): add settings detail panels

* fix(android): strip escaped ansi log codes
This commit is contained in:
Voscko
2026-06-23 02:40:24 +02:00
committed by GitHub
parent 32494c7ace
commit ea56b135c8
5 changed files with 277 additions and 14 deletions

View File

@@ -2458,12 +2458,19 @@ class NodeRuntime(
}.orEmpty()
private fun parseGatewayLogEntry(line: String): GatewayLogEntry {
val sanitizedLine = sanitizeGatewayLogText(line)
val root =
try {
json.parseToJsonElement(line).asObjectOrNull()
} catch (_: Throwable) {
null
} ?: return GatewayLogEntry(time = null, level = null, subsystem = null, message = line.trim().ifEmpty { "Empty log entry" })
} ?: return GatewayLogEntry(
time = null,
level = null,
subsystem = null,
message = sanitizedLine.trim().ifEmpty { "Empty log entry" },
raw = sanitizedLine,
)
val meta = root["_meta"].asObjectOrNull()
val time = root["time"].asStringOrNull() ?: meta?.get("date").asStringOrNull()
val level = normalizeLogLevel(meta?.get("logLevelName").asStringOrNull() ?: meta?.get("level").asStringOrNull())
@@ -2481,7 +2488,7 @@ class NodeRuntime(
?: root["message"].asStringOrNull()
?: line
val normalizedMessage =
message
sanitizeGatewayLogText(message)
.trim()
.replace(Regex("\\s+"), " ")
.take(240)
@@ -2489,8 +2496,9 @@ class NodeRuntime(
return GatewayLogEntry(
time = time,
level = level,
subsystem = subsystem?.trim()?.takeIf { it.isNotEmpty() },
subsystem = subsystem?.let(::sanitizeGatewayLogText)?.trim()?.takeIf { it.isNotEmpty() },
message = normalizedMessage,
raw = sanitizedLine,
)
}
@@ -2579,6 +2587,7 @@ class NodeRuntime(
if (name.isEmpty()) return@mapNotNull null
val missing = obj["missing"].asObjectOrNull()
GatewaySkillSummary(
skillKey = obj["skillKey"].asStringOrNull()?.trim()?.takeIf { it.isNotEmpty() } ?: name,
name = name,
description = obj["description"].asStringOrNull()?.trim()?.takeIf { it.isNotEmpty() },
source = obj["source"].asStringOrNull()?.trim()?.takeIf { it.isNotEmpty() } ?: "unknown",
@@ -3101,6 +3110,7 @@ data class GatewaySkillsSummary(
)
data class GatewaySkillSummary(
val skillKey: String,
val name: String,
val description: String?,
val source: String,
@@ -3298,8 +3308,19 @@ data class GatewayLogEntry(
val level: String?,
val subsystem: String?,
val message: String,
val raw: String,
)
private val gatewayAnsiControlPattern = Regex("\\u001B\\[[0-?]*[ -/]*[@-~]")
private val gatewayEscapedAnsiControlPattern = Regex("""\\u001[Bb]\[[0-?]*[ -/]*[@-~]""")
private val gatewayVisibleSgrPattern = Regex("\\[(?:0|\\d{1,3}(?:;\\d{1,3})*)m(?!])")
internal fun sanitizeGatewayLogText(value: String): String =
value
.replace(gatewayAnsiControlPattern, "")
.replace(gatewayEscapedAnsiControlPattern, "")
.replace(gatewayVisibleSgrPattern, "")
private fun JsonObject?.long(key: String): Long? = (this?.get(key) as? JsonPrimitive)?.content?.trim()?.toLongOrNull()
private fun JsonObject?.double(key: String): Double? = (this?.get(key) as? JsonPrimitive)?.content?.trim()?.toDoubleOrNull()

View File

@@ -8,6 +8,8 @@ import ai.openclaw.app.ui.design.ClawSecondaryButton
import ai.openclaw.app.ui.design.ClawStatus
import ai.openclaw.app.ui.design.ClawStatusPill
import ai.openclaw.app.ui.design.ClawTheme
import androidx.activity.compose.BackHandler
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.PaddingValues
@@ -15,13 +17,18 @@ import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.automirrored.filled.KeyboardArrowRight
import androidx.compose.material.icons.filled.Settings
import androidx.compose.material3.HorizontalDivider
import androidx.compose.material3.Icon
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.collectAsState
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.text.style.TextOverflow
@@ -43,6 +50,7 @@ internal fun HealthLogsSettingsScreen(
val logsSummary by viewModel.healthLogsSummary.collectAsState()
val logsRefreshing by viewModel.healthLogsRefreshing.collectAsState()
val logsErrorText by viewModel.healthLogsErrorText.collectAsState()
var selectedLogEntry by remember { mutableStateOf<GatewayLogEntry?>(null) }
LaunchedEffect(isConnected) {
if (isConnected) {
@@ -52,6 +60,11 @@ internal fun HealthLogsSettingsScreen(
}
}
selectedLogEntry?.let { entry ->
GatewayLogDetailSettingsScreen(entry = entry, onBack = { selectedLogEntry = null })
return
}
SettingsDetailFrame(
title = "Health",
subtitle = "Gateway status, phone node readiness, and recent log stream.",
@@ -93,7 +106,46 @@ internal fun HealthLogsSettingsScreen(
Text(text = error, style = ClawTheme.type.body, color = ClawTheme.colors.warning)
}
}
GatewayLogsPanel(isConnected = isConnected, summary = logsSummary)
GatewayLogsPanel(isConnected = isConnected, summary = logsSummary, onLogClick = { selectedLogEntry = it })
}
}
@Composable
private fun GatewayLogDetailSettingsScreen(
entry: GatewayLogEntry,
onBack: () -> Unit,
) {
BackHandler(onBack = onBack)
SettingsDetailFrame(
title = "Log Entry",
subtitle = "Readable gateway log detail.",
icon = Icons.Default.Settings,
onBack = onBack,
) {
SettingsMetricPanel(
rows =
listOf(
SettingsMetric("Time", compactLogTime(entry.time)),
SettingsMetric("Level", entry.level?.uppercase() ?: "LOG"),
SettingsMetric("Subsystem", entry.subsystem ?: "Unknown"),
),
)
ClawPanel {
Column(verticalArrangement = Arrangement.spacedBy(6.dp)) {
Text(text = "Message", style = ClawTheme.type.section, color = ClawTheme.colors.text)
Text(text = entry.message, style = ClawTheme.type.body, color = ClawTheme.colors.text)
}
}
ClawPanel {
Column(verticalArrangement = Arrangement.spacedBy(6.dp)) {
Text(text = "Raw", style = ClawTheme.type.section, color = ClawTheme.colors.text)
Text(
text = entry.raw.take(4_000),
style = ClawTheme.type.caption,
color = ClawTheme.colors.textMuted,
)
}
}
}
}
@@ -148,6 +200,7 @@ private fun HealthStatusRow(
private fun GatewayLogsPanel(
isConnected: Boolean,
summary: GatewayHealthLogsSummary,
onLogClick: (GatewayLogEntry) -> Unit,
) {
Column(verticalArrangement = Arrangement.spacedBy(6.dp)) {
Row(modifier = Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.SpaceBetween, verticalAlignment = Alignment.CenterVertically) {
@@ -170,7 +223,7 @@ private fun GatewayLogsPanel(
val entries = summary.entries.takeLast(12)
Column {
entries.forEachIndexed { index, entry ->
GatewayLogRow(entry = entry)
GatewayLogRow(entry = entry, onClick = { onLogClick(entry) })
if (index != entries.lastIndex) {
HorizontalDivider(color = ClawTheme.colors.border, thickness = 1.dp)
}
@@ -185,9 +238,16 @@ private fun GatewayLogsPanel(
}
@Composable
private fun GatewayLogRow(entry: GatewayLogEntry) {
private fun GatewayLogRow(
entry: GatewayLogEntry,
onClick: () -> Unit,
) {
Row(
modifier = Modifier.fillMaxWidth().padding(horizontal = 10.dp, vertical = 7.dp),
modifier =
Modifier
.fillMaxWidth()
.clickable(onClickLabel = "Open log entry", onClick = onClick)
.padding(horizontal = 10.dp, vertical = 7.dp),
verticalAlignment = Alignment.Top,
horizontalArrangement = Arrangement.spacedBy(9.dp),
) {
@@ -199,6 +259,11 @@ private fun GatewayLogRow(entry: GatewayLogEntry) {
}
}
ClawStatusPill(text = entry.level?.uppercase() ?: "LOG", status = logLevelStatus(entry.level))
Icon(
imageVector = Icons.AutoMirrored.Filled.KeyboardArrowRight,
contentDescription = null,
tint = ClawTheme.colors.textSubtle,
)
}
}

View File

@@ -856,6 +856,7 @@ private fun GatewaySettingsScreen(
var bootstrapTokenInput by remember { mutableStateOf("") }
var passwordInput by remember { mutableStateOf("") }
var validationText by remember { mutableStateOf<String?>(null) }
var showSetupCodeHelp by remember { mutableStateOf(false) }
SettingsDetailFrame(title = "Gateway", subtitle = "Connection between this phone and OpenClaw.", icon = Icons.Default.Cloud, onBack = onBack) {
SettingsMetricPanel(
@@ -876,7 +877,17 @@ private fun GatewaySettingsScreen(
Column(verticalArrangement = Arrangement.spacedBy(10.dp)) {
Text(text = "Pair New Gateway", style = ClawTheme.type.section, color = ClawTheme.colors.text)
Text(text = "Clear this phone's saved gateway access and scan a fresh setup code.", style = ClawTheme.type.body, color = ClawTheme.colors.textMuted)
ClawSecondaryButton(text = "Pair New Gateway", onClick = viewModel::pairNewGateway, modifier = Modifier.fillMaxWidth(), icon = Icons.Default.QrCode2)
Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
ClawSecondaryButton(text = "Pair New Gateway", onClick = viewModel::pairNewGateway, modifier = Modifier.weight(1f), icon = Icons.Default.QrCode2)
ClawSecondaryButton(text = "Setup Code", onClick = { showSetupCodeHelp = !showSetupCodeHelp }, modifier = Modifier.weight(1f), icon = Icons.Default.Info)
}
if (showSetupCodeHelp) {
Text(
text = "Android can scan or paste an existing setup code, but this gateway does not expose setup-code generation to the app yet. Generate the QR/code on the gateway host with openclaw qr, then scan it here or paste the setup code below.",
style = ClawTheme.type.caption,
color = ClawTheme.colors.textMuted,
)
}
}
}
ClawPanel {

View File

@@ -10,17 +10,24 @@ import ai.openclaw.app.ui.design.ClawStatus
import ai.openclaw.app.ui.design.ClawStatusPill
import ai.openclaw.app.ui.design.ClawTextBadge
import ai.openclaw.app.ui.design.ClawTheme
import androidx.activity.compose.BackHandler
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.automirrored.filled.KeyboardArrowRight
import androidx.compose.material.icons.filled.Settings
import androidx.compose.material3.Icon
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Modifier
import androidx.compose.ui.unit.dp
@@ -37,6 +44,7 @@ internal fun SkillsSettingsScreen(
val skills = skillsSummary.skills
val readyCount = skills.count { skillReady(it) }
val needsSetupCount = skills.count { skillNeedsSetup(it) }
var selectedSkillKey by remember { mutableStateOf<String?>(null) }
LaunchedEffect(isConnected) {
if (isConnected) {
@@ -44,6 +52,17 @@ internal fun SkillsSettingsScreen(
}
}
selectedSkillKey?.let { skillKey ->
val selectedSkill = skills.firstOrNull { it.skillKey == skillKey }
SkillDetailSettingsScreen(
skill = selectedSkill,
skillKey = skillKey,
isConnected = isConnected,
onBack = { selectedSkillKey = null },
)
return
}
SettingsDetailFrame(
title = "Skills",
subtitle = "Installed capabilities available to OpenClaw.",
@@ -83,25 +102,117 @@ internal fun SkillsSettingsScreen(
Text(text = "Skills installed on the gateway will appear here.", style = ClawTheme.type.body, color = ClawTheme.colors.textMuted)
}
}
else -> SkillsPanel(skills = skills)
else -> SkillsPanel(skills = skills, onSkillClick = { selectedSkillKey = it.skillKey })
}
}
}
@Composable
private fun SkillsPanel(skills: List<GatewaySkillSummary>) {
ClawListPanel(items = skills) { skill ->
SkillListRow(skill = skill)
private fun SkillDetailSettingsScreen(
skill: GatewaySkillSummary?,
skillKey: String,
isConnected: Boolean,
onBack: () -> Unit,
) {
BackHandler(onBack = onBack)
SettingsDetailFrame(
title = skill?.name ?: skillKey,
subtitle = "Inspect installed skill capability and setup state.",
icon = Icons.Default.Settings,
onBack = onBack,
) {
skill?.let { summary ->
SettingsMetricPanel(
rows =
listOf(
SettingsMetric("Status", skillStatusText(summary)),
SettingsMetric("Source", skillSourceLabel(summary)),
SettingsMetric("Missing", summary.missingCount.toString()),
),
)
SkillSetupPanel(summary)
}
SkillDetailPanel(skill = skill, isConnected = isConnected)
}
}
@Composable
private fun SkillListRow(skill: GatewaySkillSummary) {
private fun SkillSetupPanel(skill: GatewaySkillSummary) {
ClawPanel {
Column(verticalArrangement = Arrangement.spacedBy(6.dp)) {
Text(text = "Setup", style = ClawTheme.type.section, color = ClawTheme.colors.text)
Text(text = skillConfigurationText(skill), style = ClawTheme.type.body, color = ClawTheme.colors.textMuted)
}
}
}
@Composable
private fun SkillDetailPanel(
skill: GatewaySkillSummary?,
isConnected: Boolean,
) {
if (!isConnected) {
ClawPanel {
Text(text = "Connect the gateway to load skill details.", style = ClawTheme.type.body, color = ClawTheme.colors.textMuted)
}
return
}
if (skill == null) {
ClawPanel {
Text(text = "Skill detail is not available in the current skills status.", style = ClawTheme.type.body, color = ClawTheme.colors.textMuted)
}
return
}
SettingsMetricPanel(
rows =
listOf(
SettingsMetric("Skill Key", skill.skillKey),
SettingsMetric("Display", skill.name),
SettingsMetric("Source", skillSourceLabel(skill)),
SettingsMetric("Install Options", skill.installCount.toString()),
),
)
skill.description?.let { description ->
ClawPanel {
Column(verticalArrangement = Arrangement.spacedBy(6.dp)) {
Text(text = "Description", style = ClawTheme.type.section, color = ClawTheme.colors.text)
Text(text = description, style = ClawTheme.type.body, color = ClawTheme.colors.textMuted)
}
}
}
}
@Composable
private fun SkillsPanel(
skills: List<GatewaySkillSummary>,
onSkillClick: (GatewaySkillSummary) -> Unit,
) {
ClawListPanel(items = skills) { skill ->
SkillListRow(skill = skill, onClick = { onSkillClick(skill) })
}
}
@Composable
private fun SkillListRow(
skill: GatewaySkillSummary,
onClick: () -> Unit,
) {
ClawDetailRow(
title = skill.name,
subtitle = skillSubtitle(skill),
modifier = Modifier.clickable(onClickLabel = "Open skill detail", onClick = onClick),
leading = { ClawTextBadge(text = skillBadge(skill)) },
trailing = { ClawStatusPill(text = skillStatusText(skill), status = skillStatus(skill)) },
trailing = {
Row(horizontalArrangement = Arrangement.spacedBy(6.dp)) {
ClawStatusPill(text = skillStatusText(skill), status = skillStatus(skill))
Icon(
imageVector = Icons.AutoMirrored.Filled.KeyboardArrowRight,
contentDescription = null,
tint = ClawTheme.colors.textSubtle,
)
}
},
)
}
@@ -135,6 +246,15 @@ private fun skillSubtitle(skill: GatewaySkillSummary): String {
return listOfNotNull(skill.description, skillSourceLabel(skill), issue).joinToString(" · ")
}
private fun skillConfigurationText(skill: GatewaySkillSummary): String =
when {
skill.disabled -> "This skill is disabled on the gateway. Android shows detail only; enable or configure it from desktop or CLI."
skill.blockedByAllowlist -> "This skill is blocked by the gateway allowlist. Android can inspect it, but allowlist changes stay on desktop or CLI."
skill.missingCount > 0 -> "This skill needs ${skill.missingCount} setup item(s). Android shows what is installed; setup/config changes stay on desktop or CLI."
!skill.eligible -> "This skill is installed but not currently eligible to run. Use desktop or CLI for configuration changes."
else -> "Ready on this gateway. Android detail is read-only; install, update, and configuration changes stay on desktop or CLI."
}
private fun skillSourceLabel(skill: GatewaySkillSummary): String =
when (skill.source) {
"openclaw-bundled" -> if (skill.bundled) "Built-in" else "Bundled"

View File

@@ -0,0 +1,46 @@
package ai.openclaw.app
import org.junit.Assert.assertEquals
import org.junit.Test
class GatewayLogTextTest {
@Test
fun sanitizeGatewayLogTextRemovesAnsiSgrSequences() {
assertEquals(
"hindsight: Skipping retain",
sanitizeGatewayLogText("\u001B[38;5;103mhindsight:\u001B[0m Skipping retain"),
)
}
@Test
fun sanitizeGatewayLogTextRemovesVisibleSgrFragments() {
assertEquals(
"hindsight: Skipping retain",
sanitizeGatewayLogText("[38;5;103mhindsight:[0m Skipping retain"),
)
}
@Test
fun sanitizeGatewayLogTextRemovesSingleParameterVisibleSgrFragments() {
assertEquals(
"error and bold",
sanitizeGatewayLogText("[31merror[0m and [1mbold[0m"),
)
}
@Test
fun sanitizeGatewayLogTextRemovesJsonEscapedAnsiSgrSequences() {
assertEquals(
"""{"1":"hindsight: Skipping retain"}""",
sanitizeGatewayLogText("""{"1":"\u001b[38;5;103mhindsight:\u001b[0m Skipping retain"}"""),
)
}
@Test
fun sanitizeGatewayLogTextKeepsPlainBracketedText() {
assertEquals(
"cache ttl [5m] expired",
sanitizeGatewayLogText("cache ttl [5m] expired"),
)
}
}