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 },
)
}
}
}
+31
View File
@@ -524,6 +524,37 @@
<string name="toolsets_empty_title">No toolsets reported</string>
<string name="toolsets_empty_desc">Verify configuration or retry</string>
<!-- Toolset Detail Screen (issue #782) -->
<string name="toolset_detail_no_backends">No configurable backends for this toolset</string>
<string name="toolset_status_ready">Ready</string>
<string name="toolset_status_needs_keys">Needs keys</string>
<string name="toolset_status_needs_auth">Needs sign-in</string>
<string name="toolset_status_needs_setup">Needs setup</string>
<string name="toolset_active_badge">Active</string>
<string name="toolset_action_use_backend">Use this backend</string>
<string name="toolset_nous_signin_hint">Requires Nous Portal sign-in to activate</string>
<string name="toolset_no_api_key_required">No API key required</string>
<string name="toolset_env_set">Set</string>
<string name="toolset_env_not_set">Not set</string>
<string name="toolset_env_save">Save</string>
<string name="toolset_action_delete">Delete</string>
<string name="toolset_delete_env_title">Remove key?</string>
<string name="toolset_delete_env_message">This clears %1$s from the environment.</string>
<string name="toolset_edit_env_desc">Edit %1$s</string>
<string name="toolset_reveal_env_desc">Reveal %1$s</string>
<string name="toolset_hide_env_desc">Hide %1$s</string>
<string name="toolset_clear_env_desc">Clear %1$s</string>
<string name="toolset_post_setup_run">Run setup</string>
<string name="toolset_post_setup_rerun">Re-run setup</string>
<string name="toolset_post_setup_running">Running…</string>
<string name="toolset_post_setup_installed">Installed</string>
<string name="toolset_post_setup_hint">Installs missing dependencies for this backend</string>
<string name="toolset_post_setup_installed_hint">Dependencies are installed</string>
<string name="toolset_post_setup_done_ok">Setup finished (exit %1$d)</string>
<string name="toolset_post_setup_done_fail">Setup failed (exit %1$d)</string>
<string name="toolset_web_search_active">Search: %1$s</string>
<string name="toolset_web_extract_active">Extract: %1$s</string>
<!-- Webhooks Screen -->
<string name="webhooks_sec_status">Global Webhooks Status</string>
<string name="webhooks_sec_subscriptions">Subscriptions</string>
@@ -0,0 +1,322 @@
package com.m57.hermescontrol.ui.toolsets
import com.m57.hermescontrol.data.model.ActionStatusResponse
import com.m57.hermescontrol.data.model.EnvVarRevealResponse
import com.m57.hermescontrol.data.model.ToolsetConfigResponse
import com.m57.hermescontrol.data.model.ToolsetEnvUpdateResponse
import com.m57.hermescontrol.data.model.ToolsetEnvVar
import com.m57.hermescontrol.data.model.ToolsetPostSetupResponse
import com.m57.hermescontrol.data.model.ToolsetProvider
import com.m57.hermescontrol.data.model.ToolsetProviderSelectResponse
import com.m57.hermescontrol.data.remote.ApiClient
import com.m57.hermescontrol.data.remote.HermesApiService
import io.mockk.coEvery
import io.mockk.coVerify
import io.mockk.every
import io.mockk.mockk
import io.mockk.mockkObject
import io.mockk.unmockkAll
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.test.StandardTestDispatcher
import kotlinx.coroutines.test.advanceUntilIdle
import kotlinx.coroutines.test.resetMain
import kotlinx.coroutines.test.setMain
import okhttp3.ResponseBody.Companion.toResponseBody
import org.junit.After
import org.junit.Assert.assertEquals
import org.junit.Assert.assertFalse
import org.junit.Assert.assertNull
import org.junit.Assert.assertTrue
import org.junit.Before
import org.junit.Test
import retrofit2.Response
/**
* Issue #782 — per-toolset management. Contracts verified against
* hermes-agent web_routers/tools.py:
* - GET /api/tools/toolsets/{name}/config → {name, has_category, providers,
* active_provider, ...} where each provider row carries env_vars with
* is_set, post_setup key and a server-computed readiness status.
* - PUT /api/tools/toolsets/{name}/provider {provider} → {ok, name, provider}
* - PUT /api/tools/toolsets/{name}/env {env} → {ok, saved, skipped, is_set}
* - POST /api/tools/toolsets/{name}/post-setup {key} → {ok, pid, name, key},
* then tail GET /api/actions/{name}/status until running flips false.
*
* NOTE: no mockkStatic(Dispatchers) here — a static Dispatchers mock bleeds
* JVM-wide and breaks later test classes. The ViewModel under test does no
* explicit IO hop (Retrofit suspend calls run off the caller thread), so
* setMain(StandardTestDispatcher) alone makes it fully deterministic.
*/
@OptIn(ExperimentalCoroutinesApi::class)
class ToolsetDetailViewModelTest {
private val testDispatcher = StandardTestDispatcher()
private lateinit var mockApi: HermesApiService
private val providerA =
ToolsetProvider(
name = "prov-a",
envVars = listOf(ToolsetEnvVar(key = "KEY_A", prompt = "Prompt A", isSet = true)),
status = "ready",
)
private val providerB =
ToolsetProvider(
name = "prov-b",
envVars = listOf(ToolsetEnvVar(key = "KEY_B", isSet = false)),
postSetup = "camofox",
isActive = true,
status = "needs_keys",
)
private val config =
ToolsetConfigResponse(
name = "web",
hasCategory = true,
providers = listOf(providerA, providerB),
activeProvider = "prov-b",
)
@Before
fun setUp() {
Dispatchers.setMain(testDispatcher)
mockkObject(ApiClient)
mockApi = mockk(relaxed = true)
every { ApiClient.hermesApi } returns mockApi
coEvery { mockApi.getToolsetConfig("web") } returns Response.success(config)
}
@After
fun tearDown() {
Dispatchers.resetMain()
unmockkAll()
}
private fun <T> errorResponse(code: Int): Response<T> = Response.error(code, "{}".toResponseBody(null))
private fun createViewModel(): ToolsetDetailViewModel {
val vm = ToolsetDetailViewModel()
vm.setToolset("web")
vm.loadConfig()
testDispatcher.scheduler.advanceUntilIdle()
return vm
}
@Test
fun `loadConfig success populates config and defaults expanded provider to active`() {
val vm = createViewModel()
val state = vm.uiState.value
assertFalse(state.isLoading)
assertNull(state.errorMessage)
assertEquals(config, state.config)
// The default-expanded provider mirrors the desktop panel: the
// provider actually active in config (is_active / active_provider).
assertEquals("prov-b", state.expandedProvider)
}
@Test
fun `loadConfig failure surfaces error and keeps config null`() {
coEvery { mockApi.getToolsetConfig("web") } returns errorResponse(400)
val vm = createViewModel()
val state = vm.uiState.value
assertNull(state.config)
assertTrue(state.errorMessage?.contains("Failed to load toolset config") == true)
assertFalse(state.isLoading)
}
@Test
fun `selectProvider success patches active provider and toasts`() {
coEvery { mockApi.selectToolsetProvider("web", any()) } returns
Response.success(ToolsetProviderSelectResponse(ok = true, name = "web", provider = "prov-a"))
val vm = createViewModel()
vm.selectProvider(providerA)
testDispatcher.scheduler.advanceUntilIdle()
val state = vm.uiState.value
assertEquals("prov-a", state.config?.activeProvider)
assertEquals(listOf(true, false), state.config?.providers?.map { it.isActive })
assertEquals("Backend set to prov-a", state.toastMessage)
assertNull(state.selectingProvider)
}
@Test
fun `selectProvider failure toasts and keeps config untouched`() {
coEvery { mockApi.selectToolsetProvider("web", any()) } returns errorResponse(400)
val vm = createViewModel()
vm.selectProvider(providerA)
testDispatcher.scheduler.advanceUntilIdle()
val state = vm.uiState.value
assertEquals("prov-b", state.config?.activeProvider)
assertTrue(state.toastMessage?.contains("Failed to select backend") == true)
assertNull(state.selectingProvider)
}
@Test
fun `saveEnvVar success marks the key set locally`() {
coEvery { mockApi.saveToolsetEnv("web", any()) } returns
Response.success(ToolsetEnvUpdateResponse(ok = true, saved = listOf("KEY_B")))
val vm = createViewModel()
vm.saveEnvVar("KEY_B", "secret-value")
testDispatcher.scheduler.advanceUntilIdle()
val state = vm.uiState.value
assertEquals(true, state.config?.providers?.get(1)?.envVars?.first { it.key == "KEY_B" }?.isSet)
assertEquals("Saved KEY_B", state.toastMessage)
assertNull(state.savingEnvKey)
}
@Test
fun `saveEnvVar with blank value does not call the backend`() {
val vm = createViewModel()
vm.saveEnvVar("KEY_B", " ")
testDispatcher.scheduler.advanceUntilIdle()
coVerify(exactly = 0) { mockApi.saveToolsetEnv(any(), any()) }
assertNull(vm.uiState.value.savingEnvKey)
assertNull(vm.uiState.value.toastMessage)
}
@Test
fun `clearEnvVar success marks key unset and drops revealed value`() {
coEvery { mockApi.revealEnvVar(any()) } returns
Response.success(EnvVarRevealResponse(key = "KEY_B", value = "sekret"))
coEvery { mockApi.deleteEnvVar(any()) } returns Response.success(Unit)
val vm = createViewModel()
vm.revealEnvVar("KEY_B")
testDispatcher.scheduler.advanceUntilIdle()
assertEquals("sekret", vm.uiState.value.revealedValues["KEY_B"])
vm.clearEnvVar("KEY_B")
testDispatcher.scheduler.advanceUntilIdle()
val state = vm.uiState.value
assertEquals(false, state.config?.providers?.get(1)?.envVars?.first { it.key == "KEY_B" }?.isSet)
assertFalse(state.revealedValues.containsKey("KEY_B"))
assertEquals("Cleared KEY_B", state.toastMessage)
assertNull(state.deletingEnvKey)
}
@Test
fun `reveal then hide toggles revealed value`() {
coEvery { mockApi.revealEnvVar(any()) } returns
Response.success(EnvVarRevealResponse(key = "KEY_B", value = "sekret"))
val vm = createViewModel()
vm.revealEnvVar("KEY_B")
testDispatcher.scheduler.advanceUntilIdle()
assertEquals("sekret", vm.uiState.value.revealedValues["KEY_B"])
vm.hideEnvVar("KEY_B")
assertFalse(vm.uiState.value.revealedValues.containsKey("KEY_B"))
}
@Test
fun `setToolset switches config to the selected toolset and drops prior state`() {
// Regression for the on-device bug (2026-08-06): the VM is shared
// across all toolset detail nav entries, so the name must be driven
// per entry — every toolset must load ITS OWN config, and state from
// the previously viewed toolset (revealed secrets included) must not
// bleed into the next one.
val ttsConfig =
ToolsetConfigResponse(
name = "tts",
hasCategory = true,
providers = listOf(providerA.copy(isActive = false)),
)
coEvery { mockApi.getToolsetConfig("tts") } returns Response.success(ttsConfig)
coEvery { mockApi.revealEnvVar(any()) } returns
Response.success(EnvVarRevealResponse(key = "KEY_B", value = "sekret"))
val vm = createViewModel()
vm.revealEnvVar("KEY_B")
testDispatcher.scheduler.advanceUntilIdle()
assertEquals("sekret", vm.uiState.value.revealedValues["KEY_B"])
vm.setToolset("tts")
vm.loadConfig()
testDispatcher.scheduler.advanceUntilIdle()
val state = vm.uiState.value
assertEquals("tts", state.config?.name)
assertTrue(state.revealedValues.isEmpty())
// Default-expand re-claims for the NEW toolset (its first provider),
// not the previous toolset's active one.
assertEquals("prov-a", state.expandedProvider)
coVerify { mockApi.getToolsetConfig("tts") }
}
@Test
fun `runPostSetup polls action log to completion then refreshes config`() {
coEvery { mockApi.runToolsetPostSetup("web", any()) } returns
Response.success(
ToolsetPostSetupResponse(
ok = true,
pid = 42,
name = "tools-post-setup",
key = "camofox",
),
)
var polls = 0
coEvery { mockApi.getActionStatus("tools-post-setup") } answers {
polls++
if (polls == 1) {
Response.success(
ActionStatusResponse(
name = "tools-post-setup",
running = true,
lines = listOf("installing…"),
),
)
} else {
Response.success(
ActionStatusResponse(
name = "tools-post-setup",
running = false,
exit_code = 0,
lines = listOf("installing…", "done"),
),
)
}
}
coEvery { mockApi.getToolsetConfig("web") } returns Response.success(config)
val vm = createViewModel()
vm.runPostSetup("camofox")
testDispatcher.scheduler.advanceUntilIdle()
val state = vm.uiState.value
assertEquals(0, state.postSetup?.exitCode)
assertFalse(state.postSetup?.running == true)
assertEquals(listOf("installing…", "done"), state.postSetup?.lines)
// The config is refreshed once the install finishes so readiness
// pills reflect the new install state (initial load + refresh).
coVerify(exactly = 2) { mockApi.getToolsetConfig("web") }
}
@Test
fun `runPostSetup with rejected spawn does not poll`() {
coEvery { mockApi.runToolsetPostSetup("web", any()) } returns
Response.success(
ToolsetPostSetupResponse(
ok = false,
pid = null,
name = "tools-post-setup",
key = "camofox",
),
)
val vm = createViewModel()
vm.runPostSetup("camofox")
testDispatcher.scheduler.advanceUntilIdle()
assertNull(vm.uiState.value.postSetup)
assertTrue(vm.uiState.value.toastMessage?.contains("spawn rejected") == true)
coVerify(exactly = 0) { mockApi.getActionStatus(any()) }
}
}