feat(#532): add process.list + process.kill manager screen (#556)

Co-authored-by: M57 <m57@hermes-control.app>
This commit is contained in:
M57andM57 authored and GitHub committed 2026-07-11 20:17:03 +03:00
1 parent cf893585b1
commit afa82ceec2
10 files changed
+661

No files matched your search

@@ -0,0 +1,254 @@
package com.m57.hermescontrol.ui.process
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Memory
import androidx.compose.material.icons.filled.PlayArrow
import androidx.compose.material.icons.filled.Stop
import androidx.compose.material3.AlertDialog
import androidx.compose.material3.Card
import androidx.compose.material3.CardDefaults
import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
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.graphics.Color
import androidx.compose.ui.graphics.vector.ImageVector
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import androidx.lifecycle.viewmodel.compose.viewModel
import com.m57.hermescontrol.R
import com.m57.hermescontrol.data.model.ProcessInfo
import com.m57.hermescontrol.theme.LocalHermesStatusColors
import com.m57.hermescontrol.ui.common.EmptyState
import com.m57.hermescontrol.ui.common.ErrorState
import com.m57.hermescontrol.ui.common.HermesScaffold
import com.m57.hermescontrol.ui.common.LoadingState
import com.m57.hermescontrol.ui.common.NavIcon
import com.m57.hermescontrol.ui.common.ToastEffect
import com.m57.hermescontrol.ui.common.listContentPadding
import com.m57.hermescontrol.ui.common.listItemSpacing
@Composable
fun ProcessesScreen(
modifier: Modifier = Modifier,
onOpenDrawer: (() -> Unit)? = null,
viewModel: ProcessesViewModel = viewModel { ProcessesViewModel() },
) {
val state by viewModel.uiState.collectAsStateWithLifecycle()
var killTarget by remember { mutableStateOf<ProcessInfo?>(null) }
LaunchedEffect(Unit) {
if (state.sessionId != null) viewModel.load()
}
ToastEffect(toastMessage = state.toastMessage, onClearToast = viewModel::clearToast)
HermesScaffold(
title = { Text(stringResource(R.string.screen_processes)) },
navigationIcon = onOpenDrawer?.let { NavIcon.Menu(it) },
isRefreshing = state.isLoading,
onRefresh = { viewModel.load() },
modifier = modifier,
) {
when {
state.sessionId == null ->
EmptyState(
title = stringResource(R.string.processes_no_session_title),
subtitle = stringResource(R.string.processes_no_session_desc),
icon = Icons.Filled.Memory,
)
state.isLoading && state.processes.isEmpty() ->
LoadingState()
state.errorMessage != null ->
ErrorState(
message = state.errorMessage ?: stringResource(R.string.error_unknown),
onRetry = { viewModel.load() },
)
state.processes.isEmpty() ->
EmptyState(
title = stringResource(R.string.processes_empty_title),
subtitle = stringResource(R.string.processes_empty_desc),
icon = Icons.Filled.Memory,
)
else ->
LazyColumn(
modifier = Modifier.fillMaxSize(),
contentPadding = listContentPadding,
verticalArrangement = listItemSpacing,
) {
items(state.processes, key = { it.sessionId }) { process ->
ProcessCard(
process = process,
isKilling = state.killingId == process.sessionId,
onKill = { killTarget = process },
)
}
}
}
}
killTarget?.let { process ->
AlertDialog(
onDismissRequest = { killTarget = null },
title = { Text(stringResource(R.string.processes_kill_title)) },
text = {
Text(
stringResource(
R.string.processes_kill_desc,
process.title,
),
)
},
confirmButton = {
TextButton(
onClick = {
viewModel.kill(process.sessionId)
killTarget = null
},
) { Text(stringResource(R.string.processes_kill_confirm)) }
},
dismissButton = {
TextButton(onClick = { killTarget = null }) {
Text(stringResource(android.R.string.cancel))
}
},
)
}
}
@Composable
private fun ProcessCard(
process: ProcessInfo,
isKilling: Boolean,
onKill: () -> Unit,
) {
val statusColors = LocalHermesStatusColors.current
Card(
modifier = Modifier.fillMaxWidth(),
colors =
CardDefaults.cardColors(
containerColor = MaterialTheme.colorScheme.surfaceContainer,
),
) {
Row(
modifier =
Modifier
.fillMaxWidth()
.padding(horizontal = 12.dp, vertical = 10.dp),
verticalAlignment = Alignment.Top,
) {
Icon(
imageVector = process.statusIcon(),
contentDescription = null,
tint = process.statusColor(statusColors),
modifier = Modifier.size(20.dp),
)
Spacer(modifier = Modifier.width(10.dp))
Column(modifier = Modifier.weight(1f)) {
Text(
text = process.title,
style = MaterialTheme.typography.titleSmall,
fontWeight = FontWeight.SemiBold,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
)
Spacer(modifier = Modifier.height(2.dp))
Text(
text = process.subtitle,
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
maxLines = 2,
overflow = TextOverflow.Ellipsis,
)
if (process.isRunning) {
Spacer(modifier = Modifier.height(2.dp))
Text(
text = stringResource(R.string.processes_running_hint),
style = MaterialTheme.typography.labelSmall,
color = statusColors.success,
)
}
}
if (process.isRunning) {
Spacer(modifier = Modifier.width(8.dp))
if (isKilling) {
CircularProgressIndicator(
modifier = Modifier.size(20.dp),
strokeWidth = 2.dp,
)
} else {
IconButton(
onClick = onKill,
modifier = Modifier.size(36.dp),
) {
Icon(
imageVector = Icons.Filled.Stop,
contentDescription = stringResource(R.string.processes_kill_cd),
tint = statusColors.error,
)
}
}
}
}
}
}
private val ProcessInfo.subtitle: String
get() {
val parts = mutableListOf<String>()
pid?.let { parts += "pid $it" }
uptimeSeconds?.let { parts += formatUptime(it) }
if (!isRunning) {
exitCode?.let { parts += "exit $it" }
}
cwd?.let { parts += it }
return parts.joinToString(" · ")
}
private fun formatUptime(seconds: Int): String {
val m = seconds / 60
val s = seconds % 60
return when {
m <= 0 -> "${s}s"
m < 60 -> "${m}m ${s}s"
else -> {
val h = m / 60
"${h}h ${m % 60}m"
}
}
}
private fun ProcessInfo.statusIcon(): ImageVector = if (isRunning) Icons.Filled.PlayArrow else Icons.Filled.Memory
@Composable
private fun ProcessInfo.statusColor(statusColors: com.m57.hermescontrol.theme.HermesStatusColors): Color =
if (isRunning) statusColors.success else statusColors.info
@@ -0,0 +1,168 @@
package com.m57.hermescontrol.ui.process
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import com.m57.hermescontrol.data.model.ProcessInfo
import com.m57.hermescontrol.data.session.ActiveSessionHolder
import com.m57.hermescontrol.data.ws.HermesWsClient
import com.m57.hermescontrol.data.ws.WsMethods
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.update
import kotlinx.coroutines.launch
data class ProcessesUiState(
val isLoading: Boolean = false,
val processes: List<ProcessInfo> = emptyList(),
val errorMessage: String? = null,
val toastMessage: String? = null,
/** The process id currently being killed (drives the in-row spinner). */
val killingId: String? = null,
/** The active session id the list is scoped to (null = no chat session yet). */
val sessionId: String? = null,
)
class ProcessesViewModel : ViewModel() {
private val _uiState = MutableStateFlow(ProcessesUiState())
val uiState: StateFlow<ProcessesUiState> = _uiState.asStateFlow()
init {
// Keep the session id in sync and auto-load whenever it becomes known.
viewModelScope.launch {
ActiveSessionHolder.activeSessionId.collect { sid ->
_uiState.update { it.copy(sessionId = sid) }
if (sid != null) {
load()
} else {
_uiState.update {
it.copy(
processes = emptyList(),
errorMessage = null,
)
}
}
}
}
}
/** Pull the active session's process snapshot from the gateway. */
fun load() {
val sessionId =
ActiveSessionHolder.activeSessionId.value
?: run {
_uiState.update {
it.copy(
isLoading = false,
processes = emptyList(),
errorMessage = "Open a chat session first — process list is session-scoped.",
)
}
return
}
_uiState.update { it.copy(isLoading = true, errorMessage = null) }
viewModelScope.launch {
try {
val result =
HermesWsClient
.request(
WsMethods.PROCESS_LIST,
mapOf("session_id" to sessionId),
).await()
val processes = parseProcessList(result)
_uiState.update {
it.copy(
isLoading = false,
processes = processes,
errorMessage = null,
)
}
} catch (e: Exception) {
_uiState.update {
it.copy(
isLoading = false,
errorMessage = "Could not load processes: ${e.message ?: e.javaClass.simpleName}",
)
}
}
}
}
/** Kill a single process by id, then refresh the list. */
fun kill(processId: String) {
val sessionId =
ActiveSessionHolder.activeSessionId.value
?: run {
_uiState.update { it.copy(toastMessage = "No active session — cannot kill.") }
return
}
if (_uiState.value.killingId != null) return // one kill at a time
_uiState.update { it.copy(killingId = processId) }
viewModelScope.launch {
try {
HermesWsClient
.request(
WsMethods.PROCESS_KILL,
mapOf(
"process_id" to processId,
"session_id" to sessionId,
),
).await()
_uiState.update { it.copy(killingId = null, toastMessage = "Process killed") }
load()
} catch (e: Exception) {
_uiState.update {
it.copy(
killingId = null,
toastMessage = "Kill failed: ${e.message ?: e.javaClass.simpleName}",
)
}
}
}
}
fun clearToast() {
_uiState.update { it.copy(toastMessage = null) }
}
/**
* Parse the `process.list` RPC result (`{ "processes": [ {…} ] }`) into
* [ProcessInfo]s. The result is a `kotlinx` `toAny()` map (numbers arrive as
* Double/Long/Int, booleans as Boolean) — see [HermesWsClient].
*/
private fun parseProcessList(result: Any?): List<ProcessInfo> {
val map = result as? Map<*, *> ?: return emptyList()
val rawList = map["processes"] as? List<*> ?: return emptyList()
return rawList.mapNotNull { entry ->
val proc = entry as? Map<*, *> ?: return@mapNotNull null
ProcessInfo.fromMap(proc)
}
}
}
private fun Map<*, *>.str(key: String): String? = (this[key] as? String)?.takeIf { it.isNotBlank() }
private fun Map<*, *>.intOrNull(key: String): Int? = (this[key] as? Number)?.toInt()
private fun Map<*, *>.bool(key: String): Boolean = (this[key] as? Boolean) ?: false
/** Build a [ProcessInfo] from a raw `process.list` entry map. */
fun ProcessInfo.Companion.fromMap(raw: Map<*, *>): ProcessInfo? {
val m = raw.mapKeys { it.key.toString() }
val sessionId = m.str("session_id") ?: return null
return ProcessInfo(
sessionId = sessionId,
command = m.str("command"),
cwd = m.str("cwd"),
pid = m.intOrNull("pid"),
startedAt = m.str("started_at"),
uptimeSeconds = m.intOrNull("uptime_seconds"),
status = m.str("status"),
outputPreview = m.str("output_preview"),
outputTail = m.str("output_tail"),
exitCode = m.intOrNull("exit_code"),
sessionScoped = m.bool("session_scoped"),
)
}