feat(toolsets): per-toolset config/env/provider/post-setup management (#782) (#831)

* feat(toolsets): per-toolset config/env/provider/post-setup management (#782)

* fix(toolsets): per-entry toolset name for shared detail ViewModel (#782)

NavDisplay uses the activity-level ViewModelStoreOwner, so class-keyed
viewModel{} returns the same ToolsetDetailViewModel for every detail
entry; the constructor-captured name froze the first-opened toolset into
all entries (on-device: every toolset showed web's providers, no further
/config calls after the first). The screen now drives setToolset(name) +
loadConfig() per entry, and setToolset resets per-toolset state
(config, revealed secrets, running post-setup) so nothing bleeds across
toolsets.

* style(toolsets): single-line scrollable tool chip strip (#782)

A wrapping FlowRow stacked chips vertically for toolsets with many
tools (web etc.), ballooning card height — especially on large font
scales. Chips now sit on one horizontally scrollable row.

* chore(toolsets): drop stray terminal-noise line from chip strip change

---------

Co-authored-by: M57 <hy4ri@users.noreply.github.com>
This commit is contained in:
M57andM57 authored and GitHub committed 2026-08-06 17:57:58 +03:00
1 parent d9f437939a
commit fe79ff42b4
9 files changed
+1558 -16

No files matched your search

@@ -56,6 +56,7 @@ import com.m57.hermescontrol.ui.settings.SettingsBehaviorPage
import com.m57.hermescontrol.ui.settings.SettingsChatPage
import com.m57.hermescontrol.ui.settings.SettingsConnectionPage
import com.m57.hermescontrol.ui.settings.SettingsViewModel
import com.m57.hermescontrol.ui.toolsets.ToolsetDetailScreen
import kotlinx.coroutines.launch
import com.m57.hermescontrol.ui.authlogin.AuthLoginScreen as AuthLoginScreenContent
import com.m57.hermescontrol.ui.landing.LandingScreen as LandingScreenContent
@@ -129,6 +130,15 @@ private fun appEntryProvider(
viewModel = viewModel { SettingsViewModel() },
)
}
// ── Toolset detail drill-down (issue #782) ────────────────────────
entry<ToolsetDetailKey> { key ->
ToolsetDetailScreen(
name = key.name,
label = key.label,
onBack = { NavigationController.goBack() },
)
}
}
@Composable
@@ -23,6 +23,11 @@ import kotlinx.serialization.Serializable
@Serializable data object ToolsetsScreen : NavKey
@Serializable data class ToolsetDetailKey(
val name: String,
val label: String? = null,
) : NavKey
@Serializable data object AchievementsScreen : NavKey
@Serializable data object ConfigScreen : NavKey
@@ -0,0 +1,95 @@
package com.m57.hermescontrol.data.model
import kotlinx.serialization.Serializable
/**
* Per-toolset management payloads — issue #782.
*
* Contracts verified against hermes-agent hermes_cli/web_routers/tools.py:
* - GET /api/tools/toolsets/{name}/config → provider matrix + key status
* (web toolset additionally resolves active_search_backend /
* active_extract_backend the way the runtime dispatchers do).
* - PUT /api/tools/toolsets/{name}/provider {provider} → {ok, name, provider,
* needs_nous_auth?, feature?} (managed Nous rows without Portal entitlement
* write the keys but won't activate until sign-in).
* - PUT /api/tools/toolsets/{name}/env {env: {KEY: value}} → {ok, name,
* saved[], skipped[], is_set{}}; keys validated against the toolset
* category's env-var allowlist, blank values skipped ("leave unchanged").
* - POST /api/tools/toolsets/{name}/post-setup {key} → {ok, pid, name,
* key}; spawns `hermes tools post-setup <key>` and the client tails
* GET /api/actions/{name}/status (see ActionStatusResponse).
*/
@Serializable
data class ToolsetConfigResponse(
val name: String,
val hasCategory: Boolean = false,
val providers: List<ToolsetProvider> = emptyList(),
val activeProvider: String? = null,
val activeSearchBackend: String? = null,
val activeExtractBackend: String? = null,
)
@Serializable
data class ToolsetProvider(
val name: String,
val badge: String? = null,
val tag: String? = null,
val envVars: List<ToolsetEnvVar> = emptyList(),
val postSetup: String? = null,
val requiresNousAuth: Boolean = false,
val isActive: Boolean = false,
val status: String? = null,
)
@Serializable
data class ToolsetEnvVar(
val key: String,
val prompt: String? = null,
val url: String? = null,
val default: String? = null,
val isSet: Boolean = false,
)
@Serializable
data class ToolsetProviderSelectRequest(
val provider: String,
val profile: String? = null,
)
@Serializable
data class ToolsetProviderSelectResponse(
val ok: Boolean = false,
val name: String? = null,
val provider: String? = null,
val needsNousAuth: Boolean = false,
val feature: String? = null,
)
@Serializable
data class ToolsetEnvUpdateRequest(
val env: Map<String, String>,
val profile: String? = null,
)
@Serializable
data class ToolsetEnvUpdateResponse(
val ok: Boolean = false,
val name: String? = null,
val saved: List<String> = emptyList(),
val skipped: List<String> = emptyList(),
val isSet: Map<String, Boolean> = emptyMap(),
)
@Serializable
data class ToolsetPostSetupRequest(
val key: String,
val profile: String? = null,
)
@Serializable
data class ToolsetPostSetupResponse(
val ok: Boolean = false,
val pid: Int? = null,
val name: String? = null,
val key: String? = null,
)
@@ -107,6 +107,13 @@ import com.m57.hermescontrol.data.model.TelegramOnboardingStartResponse
import com.m57.hermescontrol.data.model.TelegramOnboardingStatusResponse
import com.m57.hermescontrol.data.model.ToggleSkillRequest
import com.m57.hermescontrol.data.model.Toolset
import com.m57.hermescontrol.data.model.ToolsetConfigResponse
import com.m57.hermescontrol.data.model.ToolsetEnvUpdateRequest
import com.m57.hermescontrol.data.model.ToolsetEnvUpdateResponse
import com.m57.hermescontrol.data.model.ToolsetPostSetupRequest
import com.m57.hermescontrol.data.model.ToolsetPostSetupResponse
import com.m57.hermescontrol.data.model.ToolsetProviderSelectRequest
import com.m57.hermescontrol.data.model.ToolsetProviderSelectResponse
import com.m57.hermescontrol.data.model.ToolsetToggleRequest
import com.m57.hermescontrol.data.model.UpdateCheckResponse
import com.m57.hermescontrol.data.model.UpdateCronJobRequest
@@ -407,6 +414,30 @@ interface HermesApiService {
@Body body: ToolsetToggleRequest,
): Response<Unit>
// ── Per-toolset management (issue #782) ────────────────────────────
@GET("api/tools/toolsets/{name}/config")
suspend fun getToolsetConfig(
@Path("name") name: String,
): Response<ToolsetConfigResponse>
@PUT("api/tools/toolsets/{name}/provider")
suspend fun selectToolsetProvider(
@Path("name") name: String,
@Body body: ToolsetProviderSelectRequest,
): Response<ToolsetProviderSelectResponse>
@PUT("api/tools/toolsets/{name}/env")
suspend fun saveToolsetEnv(
@Path("name") name: String,
@Body body: ToolsetEnvUpdateRequest,
): Response<ToolsetEnvUpdateResponse>
@POST("api/tools/toolsets/{name}/post-setup")
suspend fun runToolsetPostSetup(
@Path("name") name: String,
@Body body: ToolsetPostSetupRequest,
): Response<ToolsetPostSetupResponse>
@GET("api/plugins/hermes-achievements/achievements")
suspend fun getAchievements(): Response<AchievementsResponse>
@@ -0,0 +1,708 @@
@file:OptIn(
androidx.compose.material3.ExperimentalMaterial3Api::class,
androidx.compose.foundation.layout.ExperimentalLayoutApi::class,
)
package com.m57.hermescontrol.ui.toolsets
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.FlowRow
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.heightIn
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.foundation.rememberScrollState
import androidx.compose.foundation.text.KeyboardOptions
import androidx.compose.foundation.verticalScroll
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Delete
import androidx.compose.material.icons.filled.Edit
import androidx.compose.material.icons.filled.Visibility
import androidx.compose.material.icons.filled.VisibilityOff
import androidx.compose.material3.AlertDialog
import androidx.compose.material3.Button
import androidx.compose.material3.ButtonDefaults
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.OutlinedTextField
import androidx.compose.material3.SuggestionChip
import androidx.compose.material3.Surface
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.res.stringResource
import androidx.compose.ui.text.font.FontFamily
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.input.KeyboardCapitalization
import androidx.compose.ui.text.input.PasswordVisualTransformation
import androidx.compose.ui.text.input.VisualTransformation
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.ToolsetConfigResponse
import com.m57.hermescontrol.data.model.ToolsetEnvVar
import com.m57.hermescontrol.data.model.ToolsetProvider
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.NavIcon
import com.m57.hermescontrol.ui.common.SkeletonListState
import com.m57.hermescontrol.ui.common.StatusBadge
import com.m57.hermescontrol.ui.common.StatusBadgeType
import com.m57.hermescontrol.ui.common.ToastEffect
import com.m57.hermescontrol.ui.common.listContentPadding
import com.m57.hermescontrol.ui.common.listItemSpacing
/**
* Per-toolset management page (issue #782) — the mobile counterpart of the
* desktop toolset-config-panel: provider pick with honest readiness pills,
* per-provider API-key entry (save/clear/reveal), and the post-setup install
* hook with an inlined live log.
*/
@Composable
fun ToolsetDetailScreen(
name: String,
label: String?,
onBack: () -> Unit,
viewModel: ToolsetDetailViewModel = viewModel { ToolsetDetailViewModel() },
) {
val state by viewModel.uiState.collectAsStateWithLifecycle()
val config = state.config
var deleteTarget by remember { mutableStateOf<ToolsetEnvVar?>(null) }
// The VM is shared across all toolset detail entries (activity-level
// ViewModelStoreOwner) — drive it with the CURRENT entry's toolset.
LaunchedEffect(name) {
viewModel.setToolset(name)
viewModel.loadConfig()
}
ToastEffect(toastMessage = state.toastMessage, onClearToast = viewModel::clearToast)
deleteTarget?.let { envVar ->
AlertDialog(
onDismissRequest = { deleteTarget = null },
title = { Text(stringResource(R.string.toolset_delete_env_title)) },
text = { Text(stringResource(R.string.toolset_delete_env_message, envVar.key)) },
confirmButton = {
Button(
onClick = {
deleteTarget = null
viewModel.clearEnvVar(envVar.key)
},
colors =
ButtonDefaults.buttonColors(
containerColor = MaterialTheme.colorScheme.error,
),
) {
Text(stringResource(R.string.toolset_action_delete))
}
},
dismissButton = {
TextButton(onClick = { deleteTarget = null }) {
Text(stringResource(R.string.action_cancel))
}
},
)
}
HermesScaffold(
title = { Text(label ?: name) },
navigationIcon = NavIcon.Back(onBack),
drawerGesturesEnabled = false,
isRefreshing = state.isLoading,
onRefresh = { viewModel.loadConfig() },
) { paddingValues ->
when {
state.isLoading && config == null -> {
SkeletonListState(modifier = Modifier.padding(paddingValues))
}
state.errorMessage != null && config == null -> {
ErrorState(
message = state.errorMessage ?: "",
onRetry = { viewModel.loadConfig() },
modifier = Modifier.padding(paddingValues),
)
}
config == null -> {
EmptyState(
title = stringResource(R.string.toolsets_empty_title),
subtitle = stringResource(R.string.toolsets_empty_desc),
onAction = { viewModel.loadConfig() },
actionLabel = stringResource(R.string.content_desc_refresh),
modifier = Modifier.padding(paddingValues),
)
}
else -> {
ToolsetConfigContent(
config = config,
state = state,
onToggleExpanded = viewModel::toggleProviderExpanded,
onSelectProvider = viewModel::selectProvider,
onSaveEnv = viewModel::saveEnvVar,
onRequestDeleteEnv = { deleteTarget = it },
onRevealEnv = viewModel::revealEnvVar,
onHideEnv = viewModel::hideEnvVar,
onRunPostSetup = viewModel::runPostSetup,
)
}
}
}
}
@Composable
private fun ToolsetConfigContent(
config: ToolsetConfigResponse,
state: ToolsetDetailUiState,
onToggleExpanded: (String) -> Unit,
onSelectProvider: (ToolsetProvider) -> Unit,
onSaveEnv: (String, String) -> Unit,
onRequestDeleteEnv: (ToolsetEnvVar) -> Unit,
onRevealEnv: (String) -> Unit,
onHideEnv: (String) -> Unit,
onRunPostSetup: (String) -> Unit,
) {
val noBackendsText = stringResource(R.string.toolset_detail_no_backends)
LazyColumn(
modifier = Modifier.fillMaxSize(),
contentPadding = listContentPadding,
verticalArrangement = listItemSpacing,
) {
if (config.activeSearchBackend != null || config.activeExtractBackend != null) {
item(key = "web-capabilities") {
FlowRow(
horizontalArrangement = Arrangement.spacedBy(6.dp),
verticalArrangement = Arrangement.spacedBy(4.dp),
) {
config.activeSearchBackend?.let {
SuggestionChip(
onClick = {},
label = { Text(stringResource(R.string.toolset_web_search_active, it)) },
)
}
config.activeExtractBackend?.let {
SuggestionChip(
onClick = {},
label = { Text(stringResource(R.string.toolset_web_extract_active, it)) },
)
}
}
}
}
if (config.providers.isEmpty()) {
item(key = "no-backends") {
Text(
text = noBackendsText,
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant,
modifier = Modifier.padding(vertical = 24.dp),
)
}
} else {
items(config.providers, key = { "provider-${it.name}" }) { provider ->
ToolsetProviderCard(
provider = provider,
expanded = state.expandedProvider == provider.name,
selecting = state.selectingProvider == provider.name,
savingEnvKey = state.savingEnvKey,
deletingEnvKey = state.deletingEnvKey,
revealedValues = state.revealedValues,
postSetup = state.postSetup,
onToggleExpanded = { onToggleExpanded(provider.name) },
onSelectProvider = { onSelectProvider(provider) },
onSaveEnv = onSaveEnv,
onRequestDeleteEnv = onRequestDeleteEnv,
onRevealEnv = onRevealEnv,
onHideEnv = onHideEnv,
onRunPostSetup = onRunPostSetup,
)
}
}
}
}
@Composable
private fun ToolsetProviderCard(
provider: ToolsetProvider,
expanded: Boolean,
selecting: Boolean,
savingEnvKey: String?,
deletingEnvKey: String?,
revealedValues: Map<String, String>,
postSetup: PostSetupState?,
onToggleExpanded: () -> Unit,
onSelectProvider: () -> Unit,
onSaveEnv: (String, String) -> Unit,
onRequestDeleteEnv: (ToolsetEnvVar) -> Unit,
onRevealEnv: (String) -> Unit,
onHideEnv: (String) -> Unit,
onRunPostSetup: (String) -> Unit,
) {
val statusText = stringResource(provider.statusTextRes())
val statusType = provider.statusType()
val activeText = stringResource(R.string.toolset_active_badge)
val noKeysText = stringResource(R.string.toolset_no_api_key_required)
val useBackendText = stringResource(R.string.toolset_action_use_backend)
Card(
modifier = Modifier.fillMaxWidth(),
colors =
CardDefaults.cardColors(
containerColor =
if (expanded) {
MaterialTheme.colorScheme.primaryContainer.copy(alpha = 0.10f)
} else {
MaterialTheme.colorScheme.surfaceVariant
},
),
) {
Column(modifier = Modifier.fillMaxWidth()) {
// ── Header (tap to expand/collapse) ────────────────────────
Row(
modifier =
Modifier
.fillMaxWidth()
.clickable(onClick = onToggleExpanded)
.padding(horizontal = 16.dp, vertical = 12.dp),
verticalAlignment = Alignment.CenterVertically,
) {
Column(modifier = Modifier.weight(1f)) {
Text(
text = provider.name,
style = MaterialTheme.typography.titleMedium,
fontWeight = FontWeight.Bold,
)
FlowRow(
horizontalArrangement = Arrangement.spacedBy(6.dp),
verticalArrangement = Arrangement.spacedBy(4.dp),
modifier = Modifier.padding(top = 4.dp),
) {
provider.badge?.takeIf { it.isNotBlank() }?.let {
StatusBadge(text = it, status = StatusBadgeType.NEUTRAL)
}
if (provider.isActive) {
StatusBadge(text = activeText, status = StatusBadgeType.SUCCESS)
}
if (statusText.isNotEmpty()) {
StatusBadge(text = statusText, status = statusType)
}
}
}
if (selecting) {
CircularProgressIndicator(
modifier = Modifier.size(18.dp),
strokeWidth = 2.dp,
)
Spacer(modifier = Modifier.width(8.dp))
}
}
// ── Expanded body ──────────────────────────────────────────
if (expanded) {
Column(
modifier =
Modifier
.fillMaxWidth()
.padding(start = 16.dp, end = 16.dp, bottom = 16.dp),
verticalArrangement = Arrangement.spacedBy(12.dp),
) {
provider.tag?.takeIf { it.isNotBlank() }?.let {
Text(
text = it,
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
if (!provider.isActive) {
Button(onClick = onSelectProvider, enabled = !selecting) {
Text(useBackendText)
}
}
if (provider.requiresNousAuth) {
Text(
text = stringResource(R.string.toolset_nous_signin_hint),
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
if (provider.envVars.isEmpty()) {
Text(
text = noKeysText,
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
} else {
provider.envVars.forEach { envVar ->
ToolsetEnvVarRow(
envVar = envVar,
saving = savingEnvKey == envVar.key,
deleting = deletingEnvKey == envVar.key,
revealedValue = revealedValues[envVar.key],
onSave = { value -> onSaveEnv(envVar.key, value) },
onRequestDelete = { onRequestDeleteEnv(envVar) },
onReveal = { onRevealEnv(envVar.key) },
onHide = { onHideEnv(envVar.key) },
)
}
}
provider.postSetup?.let { postSetupKey ->
PostSetupRunner(
postSetupKey = postSetupKey,
installed = provider.status == "ready",
postSetup = postSetup,
onRun = { onRunPostSetup(postSetupKey) },
)
}
}
}
}
}
}
@Composable
private fun ToolsetEnvVarRow(
envVar: ToolsetEnvVar,
saving: Boolean,
deleting: Boolean,
revealedValue: String?,
onSave: (String) -> Unit,
onRequestDelete: () -> Unit,
onReveal: () -> Unit,
onHide: () -> Unit,
) {
var editing by remember { mutableStateOf(false) }
var value by remember { mutableStateOf("") }
val setText = stringResource(R.string.toolset_env_set)
val notSetText = stringResource(R.string.toolset_env_not_set)
val saveText = stringResource(R.string.toolset_env_save)
val cancelText = stringResource(R.string.action_cancel)
val editDesc = stringResource(R.string.toolset_edit_env_desc, envVar.key)
val revealDesc = stringResource(R.string.toolset_reveal_env_desc, envVar.key)
val hideDesc = stringResource(R.string.toolset_hide_env_desc, envVar.key)
val clearDesc = stringResource(R.string.toolset_clear_env_desc, envVar.key)
Surface(
shape = MaterialTheme.shapes.medium,
color = MaterialTheme.colorScheme.surface,
modifier = Modifier.fillMaxWidth(),
) {
Column(
modifier = Modifier.padding(12.dp),
verticalArrangement = Arrangement.spacedBy(8.dp),
) {
Row(verticalAlignment = Alignment.CenterVertically) {
Column(modifier = Modifier.weight(1f)) {
Text(
text = envVar.key,
style = MaterialTheme.typography.bodyMedium,
fontWeight = FontWeight.SemiBold,
fontFamily = FontFamily.Monospace,
)
envVar.prompt?.takeIf { it.isNotBlank() && it != envVar.key }?.let {
Spacer(modifier = Modifier.height(2.dp))
Text(
text = it,
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
}
StatusBadge(
text = if (envVar.isSet) setText else notSetText,
status = if (envVar.isSet) StatusBadgeType.SUCCESS else StatusBadgeType.WARNING,
)
}
if (!editing) {
FlowRow(
horizontalArrangement = Arrangement.spacedBy(4.dp),
verticalArrangement = Arrangement.spacedBy(4.dp),
) {
IconButton(
onClick = { editing = true },
enabled = !saving && !deleting,
modifier = Modifier.size(36.dp),
) {
Icon(
imageVector = Icons.Filled.Edit,
contentDescription = editDesc,
modifier = Modifier.size(18.dp),
)
}
if (revealedValue == null) {
IconButton(
onClick = onReveal,
enabled = envVar.isSet && !saving && !deleting,
modifier = Modifier.size(36.dp),
) {
Icon(
imageVector = Icons.Filled.Visibility,
contentDescription = revealDesc,
modifier = Modifier.size(18.dp),
)
}
} else {
IconButton(
onClick = onHide,
modifier = Modifier.size(36.dp),
) {
Icon(
imageVector = Icons.Filled.VisibilityOff,
contentDescription = hideDesc,
modifier = Modifier.size(18.dp),
)
}
}
if (envVar.isSet) {
IconButton(
onClick = onRequestDelete,
enabled = !saving && !deleting,
modifier = Modifier.size(36.dp),
) {
if (deleting) {
CircularProgressIndicator(
modifier = Modifier.size(16.dp),
strokeWidth = 2.dp,
)
} else {
Icon(
imageVector = Icons.Filled.Delete,
contentDescription = clearDesc,
modifier = Modifier.size(18.dp),
tint = MaterialTheme.colorScheme.error,
)
}
}
}
}
}
revealedValue?.let {
Text(
text = it,
style = MaterialTheme.typography.bodySmall,
fontFamily = FontFamily.Monospace,
modifier = Modifier.fillMaxWidth(),
)
}
if (editing) {
OutlinedTextField(
value = value,
onValueChange = { value = it },
modifier = Modifier.fillMaxWidth(),
singleLine = true,
label = { Text(envVar.key) },
placeholder = { Text(envVar.prompt ?: envVar.key) },
visualTransformation =
if (revealedValue != null) {
VisualTransformation.None
} else {
PasswordVisualTransformation()
},
keyboardOptions =
KeyboardOptions(
capitalization = KeyboardCapitalization.None,
),
enabled = !saving,
)
FlowRow(
horizontalArrangement = Arrangement.spacedBy(8.dp),
verticalArrangement = Arrangement.spacedBy(4.dp),
) {
Button(
onClick = {
onSave(value)
value = ""
editing = false
},
enabled = !saving && value.isNotBlank(),
) {
if (saving) {
CircularProgressIndicator(
modifier = Modifier.size(14.dp),
strokeWidth = 2.dp,
color = MaterialTheme.colorScheme.onPrimary,
)
Spacer(modifier = Modifier.width(6.dp))
}
Text(saveText)
}
TextButton(
onClick = {
editing = false
value = ""
},
enabled = !saving,
) {
Text(cancelText)
}
}
}
}
}
}
@Composable
private fun PostSetupRunner(
postSetupKey: String,
installed: Boolean,
postSetup: PostSetupState?,
onRun: () -> Unit,
) {
val hintText =
if (installed) {
stringResource(R.string.toolset_post_setup_installed_hint)
} else {
stringResource(R.string.toolset_post_setup_hint)
}
val installedText = stringResource(R.string.toolset_post_setup_installed)
val runText = stringResource(R.string.toolset_post_setup_run)
val rerunText = stringResource(R.string.toolset_post_setup_rerun)
val runningText = stringResource(R.string.toolset_post_setup_running)
val activeState = postSetup?.takeIf { it.key == postSetupKey }
Surface(
shape = MaterialTheme.shapes.medium,
color = MaterialTheme.colorScheme.surface,
modifier = Modifier.fillMaxWidth(),
) {
Column(
modifier = Modifier.padding(12.dp),
verticalArrangement = Arrangement.spacedBy(8.dp),
) {
Row(verticalAlignment = Alignment.CenterVertically) {
Column(modifier = Modifier.weight(1f)) {
Text(
text = hintText,
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
activeState?.exitCode?.let { code ->
Spacer(modifier = Modifier.height(2.dp))
Text(
text =
if (code == 0) {
stringResource(R.string.toolset_post_setup_done_ok, code)
} else {
stringResource(R.string.toolset_post_setup_done_fail, code)
},
style = MaterialTheme.typography.bodySmall,
fontWeight = FontWeight.Medium,
color =
if (code == 0) {
MaterialTheme.colorScheme.primary
} else {
MaterialTheme.colorScheme.error
},
)
}
}
if (installed && activeState == null) {
StatusBadge(text = installedText, status = StatusBadgeType.SUCCESS)
Spacer(modifier = Modifier.width(8.dp))
}
if (activeState?.running == true) {
Button(onClick = {}, enabled = false) {
CircularProgressIndicator(
modifier = Modifier.size(14.dp),
strokeWidth = 2.dp,
color = MaterialTheme.colorScheme.onPrimary,
)
Spacer(modifier = Modifier.width(6.dp))
Text(runningText)
}
} else {
Button(onClick = onRun) {
Text(if (installed) rerunText else runText)
}
}
}
val lines = activeState?.lines.orEmpty()
if (activeState?.running == true || lines.isNotEmpty()) {
Surface(
shape = MaterialTheme.shapes.small,
color = MaterialTheme.colorScheme.surfaceVariant,
modifier = Modifier.fillMaxWidth(),
) {
Column(
modifier =
Modifier
.fillMaxWidth()
.heightIn(max = 180.dp)
.verticalScroll(rememberScrollState())
.padding(10.dp),
) {
Text(
text = if (lines.isEmpty()) runningText else lines.joinToString("\n"),
style = MaterialTheme.typography.bodySmall,
fontFamily = FontFamily.Monospace,
)
}
}
}
}
}
}
private fun ToolsetProvider.statusTextRes(): Int =
when (status) {
"ready" -> R.string.toolset_status_ready
"needs_keys" -> R.string.toolset_status_needs_keys
"needs_auth" -> R.string.toolset_status_needs_auth
"needs_setup" -> R.string.toolset_status_needs_setup
else ->
// Older backends may omit `status` — fall back to the legacy
// env-var heuristic the desktop used before server-computed
// readiness landed.
if (envVars.isEmpty() || envVars.all { it.isSet }) {
R.string.toolset_status_ready
} else {
R.string.toolset_status_needs_keys
}
}
private fun ToolsetProvider.statusType(): StatusBadgeType =
when (status) {
"ready" -> StatusBadgeType.SUCCESS
"needs_keys", "needs_auth", "needs_setup" -> StatusBadgeType.WARNING
else ->
if (envVars.isEmpty() || envVars.all { it.isSet }) {
StatusBadgeType.SUCCESS
} else {
StatusBadgeType.WARNING
}
}
@@ -0,0 +1,330 @@
package com.m57.hermescontrol.ui.toolsets
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import com.m57.hermescontrol.data.model.EnvVarDeleteRequest
import com.m57.hermescontrol.data.model.EnvVarRevealRequest
import com.m57.hermescontrol.data.model.ToolsetConfigResponse
import com.m57.hermescontrol.data.model.ToolsetEnvUpdateRequest
import com.m57.hermescontrol.data.model.ToolsetPostSetupRequest
import com.m57.hermescontrol.data.model.ToolsetProvider
import com.m57.hermescontrol.data.model.ToolsetProviderSelectRequest
import com.m57.hermescontrol.data.remote.ApiClient
import com.m57.hermescontrol.data.remote.NetworkResult
import com.m57.hermescontrol.data.remote.safeApiCall
import com.m57.hermescontrol.ui.common.ToastHost
import kotlinx.coroutines.Job
import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.update
import kotlinx.coroutines.isActive
import kotlinx.coroutines.launch
/** Live state of a spawned post-setup install (mirrors the desktop log tail). */
data class PostSetupState(
val key: String,
val running: Boolean,
val lines: List<String> = emptyList(),
val exitCode: Int? = null,
)
data class ToolsetDetailUiState(
val isLoading: Boolean = false,
val config: ToolsetConfigResponse? = null,
val errorMessage: String? = null,
val toastMessage: String? = null,
val expandedProvider: String? = null,
val selectingProvider: String? = null,
val savingEnvKey: String? = null,
val deletingEnvKey: String? = null,
val revealedValues: Map<String, String> = emptyMap(),
val postSetup: PostSetupState? = null,
)
/**
* Per-toolset management — issue #782. Contracts verified against
* hermes-agent web_routers/tools.py; mirrors the desktop
* toolset-config-panel (provider pick, env-var save/clear/reveal, post-setup
* spawn + action-log tail). Deliberately avoids an explicit IO hop: Retrofit
* suspend calls already run off the caller thread, and skipping
* `withContext(Dispatchers.IO)` keeps unit tests free of the static
* Dispatchers mock that bleeds across test classes.
*
* NOTE: this ViewModel is shared across all toolset detail nav entries — the
* app's NavDisplay uses the activity-level ViewModelStoreOwner, so
* class-keyed `viewModel {}` returns the same instance for every entry.
* The toolset name is therefore NOT a constructor arg (it would freeze the
* first-opened toolset into every entry — bug found on-device 2026-08-06).
* The screen drives [setToolset] + [loadConfig] per entry instead.
*/
class ToolsetDetailViewModel : ViewModel(), ToastHost {
private val _uiState = MutableStateFlow(ToolsetDetailUiState())
val uiState: StateFlow<ToolsetDetailUiState> = _uiState.asStateFlow()
private var toolsetName: String = ""
private var postSetupJob: Job? = null
/**
* Point the shared instance at [name] and drop all state from the
* previously viewed toolset (config, revealed secrets, running
* post-setup) so one toolset's data never bleeds into another.
*/
fun setToolset(name: String) {
if (name == toolsetName) return
toolsetName = name
postSetupJob?.cancel()
_uiState.value = ToolsetDetailUiState()
}
fun loadConfig() {
_uiState.update { it.copy(isLoading = true, errorMessage = null) }
viewModelScope.launch {
val result = safeApiCall { ApiClient.hermesApi.getToolsetConfig(toolsetName) }
_uiState.update { state ->
when (result) {
is NetworkResult.Success -> {
val config = result.data
// Default the expanded provider to the one active in
// config (is_active / active_provider), else the first
// provider — claimed once so refreshes never yank the
// user's open/collapsed choice.
val defaultExpanded =
config.providers.firstOrNull { it.isActive || it.name == config.activeProvider }?.name
?: config.providers.firstOrNull()?.name
state.copy(
isLoading = false,
config = config,
errorMessage = null,
expandedProvider = if (state.config == null) defaultExpanded else state.expandedProvider,
)
}
is NetworkResult.Failure ->
state.copy(
isLoading = false,
errorMessage = "Failed to load toolset config: ${result.error.message}",
)
}
}
}
}
fun toggleProviderExpanded(name: String) {
_uiState.update {
it.copy(expandedProvider = if (it.expandedProvider == name) null else name)
}
}
fun selectProvider(provider: ToolsetProvider) {
if (_uiState.value.selectingProvider != null) return
_uiState.update { it.copy(selectingProvider = provider.name) }
viewModelScope.launch {
val result =
safeApiCall {
ApiClient.hermesApi.selectToolsetProvider(
toolsetName,
ToolsetProviderSelectRequest(provider = provider.name),
)
}
_uiState.update { state ->
when (result) {
is NetworkResult.Success -> {
val message =
if (result.data.needsNousAuth) {
"Backend set to ${provider.name} — needs Nous Portal sign-in to activate"
} else {
"Backend set to ${provider.name}"
}
state.copy(
selectingProvider = null,
config =
state.config?.let { cfg ->
cfg.copy(
activeProvider = provider.name,
providers = cfg.providers.map { it.copy(isActive = it.name == provider.name) },
)
},
toastMessage = message,
)
}
is NetworkResult.Failure ->
state.copy(
selectingProvider = null,
toastMessage = "Failed to select backend: ${result.error.message}",
)
}
}
}
}
fun saveEnvVar(
key: String,
value: String,
) {
if (value.isBlank()) return
_uiState.update { it.copy(savingEnvKey = key) }
viewModelScope.launch {
val result =
safeApiCall {
ApiClient.hermesApi.saveToolsetEnv(
toolsetName,
ToolsetEnvUpdateRequest(env = mapOf(key to value)),
)
}
_uiState.update { state ->
when (result) {
is NetworkResult.Success ->
state.copy(
savingEnvKey = null,
config = state.config?.let { patchEnvIsSet(it, key, true) },
toastMessage = "Saved $key",
)
is NetworkResult.Failure ->
state.copy(
savingEnvKey = null,
toastMessage = "Failed to save $key: ${result.error.message}",
)
}
}
}
}
fun clearEnvVar(key: String) {
_uiState.update { it.copy(deletingEnvKey = key) }
viewModelScope.launch {
val result = safeApiCall { ApiClient.hermesApi.deleteEnvVar(EnvVarDeleteRequest(key)) }
_uiState.update { state ->
when (result) {
is NetworkResult.Success ->
state.copy(
deletingEnvKey = null,
config = state.config?.let { patchEnvIsSet(it, key, false) },
revealedValues = state.revealedValues - key,
toastMessage = "Cleared $key",
)
is NetworkResult.Failure ->
state.copy(
deletingEnvKey = null,
toastMessage = "Failed to clear $key: ${result.error.message}",
)
}
}
}
}
fun revealEnvVar(key: String) {
viewModelScope.launch {
val result = safeApiCall { ApiClient.hermesApi.revealEnvVar(EnvVarRevealRequest(key)) }
_uiState.update { state ->
when (result) {
is NetworkResult.Success ->
state.copy(revealedValues = state.revealedValues + (key to result.data.value))
is NetworkResult.Failure ->
state.copy(toastMessage = "Failed to reveal $key: ${result.error.message}")
}
}
}
}
fun hideEnvVar(key: String) {
_uiState.update { state ->
state.copy(revealedValues = state.revealedValues - key)
}
}
/**
* Spawn the provider's post-setup install hook and tail its action log
* (the desktop PostSetupRunner pattern): poll
* GET /api/actions/{name}/status every ~1.2s until it exits, then refresh
* the config so readiness pills reflect the finished install.
*/
fun runPostSetup(key: String) {
if (_uiState.value.postSetup?.running == true) return
postSetupJob?.cancel()
_uiState.update { it.copy(postSetup = PostSetupState(key = key, running = true)) }
postSetupJob =
viewModelScope.launch {
val spawn =
safeApiCall {
ApiClient.hermesApi.runToolsetPostSetup(
toolsetName,
ToolsetPostSetupRequest(key = key),
)
}
when (spawn) {
is NetworkResult.Failure -> {
_uiState.update {
it.copy(
postSetup = null,
toastMessage = "Failed to start setup: ${spawn.error.message}",
)
}
}
is NetworkResult.Success -> {
if (spawn.data.ok != true) {
_uiState.update {
it.copy(
postSetup = null,
toastMessage = "Failed to start setup: spawn rejected",
)
}
return@launch
}
val actionName = spawn.data.name ?: "tools-post-setup"
while (isActive) {
delay(1200)
val status = safeApiCall { ApiClient.hermesApi.getActionStatus(actionName) }
if (status is NetworkResult.Success) {
val data = status.data
val done = data.running != true
_uiState.update { state ->
state.copy(
postSetup =
state.postSetup?.copy(
lines = data.lines ?: state.postSetup?.lines ?: emptyList(),
running = data.running == true,
exitCode = data.exit_code,
),
)
}
if (done) {
loadConfig()
break
}
}
}
}
}
}
}
fun closePostSetup() {
postSetupJob?.cancel()
_uiState.update { it.copy(postSetup = null) }
}
override fun clearToast() {
_uiState.update { it.copy(toastMessage = null) }
}
private fun patchEnvIsSet(
config: ToolsetConfigResponse,
key: String,
isSet: Boolean,
): ToolsetConfigResponse =
config.copy(
providers =
config.providers.map { provider ->
provider.copy(
envVars = provider.envVars.map { if (it.key == key) it.copy(isSet = isSet) else it },
)
},
)
}
@@ -1,10 +1,10 @@
package com.m57.hermescontrol.ui.toolsets
import androidx.compose.foundation.clickable
import androidx.compose.foundation.horizontalScroll
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.FlowRow
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxSize
@@ -13,6 +13,7 @@ import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items
import androidx.compose.foundation.rememberScrollState
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Refresh
import androidx.compose.material3.Card
@@ -38,9 +39,9 @@ import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.dp
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import androidx.lifecycle.viewmodel.compose.viewModel
import com.m57.hermescontrol.NavigationController
import com.m57.hermescontrol.R
import com.m57.hermescontrol.data.model.Toolset
import com.m57.hermescontrol.ui.common.DetailDialog
import com.m57.hermescontrol.ToolsetDetailKey
import com.m57.hermescontrol.ui.common.EmptyState
import com.m57.hermescontrol.ui.common.ErrorState
import com.m57.hermescontrol.ui.common.HermesScaffold
@@ -50,7 +51,6 @@ import com.m57.hermescontrol.ui.common.SkeletonListState
import com.m57.hermescontrol.ui.common.ToastEffect
import com.m57.hermescontrol.ui.common.listContentPadding
import com.m57.hermescontrol.ui.common.listItemSpacing
import com.m57.hermescontrol.ui.common.toDetailRows
@OptIn(ExperimentalMaterial3Api::class)
@Composable
@@ -62,7 +62,6 @@ fun ToolsetsScreen(
val state by viewModel.uiState.collectAsStateWithLifecycle()
var query by remember { mutableStateOf("") }
var showDetail by remember { mutableStateOf<Toolset?>(null) }
val filteredToolsets =
remember(query, state.toolsets) {
@@ -141,7 +140,19 @@ fun ToolsetsScreen(
}
items(filteredToolsets, key = { it.name }) { toolset ->
Card(
modifier = Modifier.fillMaxWidth().clickable(onClick = { showDetail = toolset }),
modifier =
Modifier
.fillMaxWidth()
.clickable(
onClick = {
NavigationController.navigateTo(
ToolsetDetailKey(
name = toolset.name,
label = toolset.label,
),
)
},
),
colors =
CardDefaults.cardColors(
containerColor =
@@ -180,9 +191,15 @@ fun ToolsetsScreen(
toolset.tools?.let { tools ->
if (tools.isNotEmpty()) {
Spacer(modifier = Modifier.height(8.dp))
FlowRow(
// Single-line chip strip — toolsets can carry a dozen+
// tools, and a wrapping FlowRow stacks chips vertically
// (huge cards on large font scales). Scroll instead.
Row(
modifier =
Modifier
.fillMaxWidth()
.horizontalScroll(rememberScrollState()),
horizontalArrangement = Arrangement.spacedBy(6.dp),
verticalArrangement = Arrangement.spacedBy(4.dp),
) {
tools.forEach { tool ->
SuggestionChip(
@@ -191,6 +208,7 @@ fun ToolsetsScreen(
Text(
text = tool,
style = MaterialTheme.typography.labelSmall,
maxLines = 1,
)
},
)
@@ -213,13 +231,5 @@ fun ToolsetsScreen(
}
}
}
showDetail?.let { toolset ->
DetailDialog(
title = toolset.label ?: toolset.name,
rows = toolset.toDetailRows(),
onDismiss = { showDetail = null },
)
}
}
}