From fe79ff42b45b316204885d01d5d9e213dbbcd9b2 Mon Sep 17 00:00:00 2001 From: M57 <41645758+Hy4ri@users.noreply.github.com> Date: Thu, 6 Aug 2026 17:57:58 +0300 Subject: [PATCH] feat(toolsets): per-toolset config/env/provider/post-setup management (#782) (#831) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * 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 --- .../java/com/m57/hermescontrol/Navigation.kt | 10 + .../com/m57/hermescontrol/NavigationKeys.kt | 5 + .../hermescontrol/data/model/ToolsetConfig.kt | 95 +++ .../data/remote/HermesApiService.kt | 31 + .../ui/toolsets/ToolsetDetailScreen.kt | 708 ++++++++++++++++++ .../ui/toolsets/ToolsetDetailViewModel.kt | 330 ++++++++ .../ui/toolsets/ToolsetsScreen.kt | 42 +- app/src/main/res/values/strings.xml | 31 + .../ui/toolsets/ToolsetDetailViewModelTest.kt | 322 ++++++++ 9 files changed, 1558 insertions(+), 16 deletions(-) create mode 100644 app/src/main/java/com/m57/hermescontrol/data/model/ToolsetConfig.kt create mode 100644 app/src/main/java/com/m57/hermescontrol/ui/toolsets/ToolsetDetailScreen.kt create mode 100644 app/src/main/java/com/m57/hermescontrol/ui/toolsets/ToolsetDetailViewModel.kt create mode 100644 app/src/test/java/com/m57/hermescontrol/ui/toolsets/ToolsetDetailViewModelTest.kt diff --git a/app/src/main/java/com/m57/hermescontrol/Navigation.kt b/app/src/main/java/com/m57/hermescontrol/Navigation.kt index 66db646c..4a018e88 100644 --- a/app/src/main/java/com/m57/hermescontrol/Navigation.kt +++ b/app/src/main/java/com/m57/hermescontrol/Navigation.kt @@ -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 { key -> + ToolsetDetailScreen( + name = key.name, + label = key.label, + onBack = { NavigationController.goBack() }, + ) + } } @Composable diff --git a/app/src/main/java/com/m57/hermescontrol/NavigationKeys.kt b/app/src/main/java/com/m57/hermescontrol/NavigationKeys.kt index 49ab8100..5388e93b 100644 --- a/app/src/main/java/com/m57/hermescontrol/NavigationKeys.kt +++ b/app/src/main/java/com/m57/hermescontrol/NavigationKeys.kt @@ -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 diff --git a/app/src/main/java/com/m57/hermescontrol/data/model/ToolsetConfig.kt b/app/src/main/java/com/m57/hermescontrol/data/model/ToolsetConfig.kt new file mode 100644 index 00000000..d767f767 --- /dev/null +++ b/app/src/main/java/com/m57/hermescontrol/data/model/ToolsetConfig.kt @@ -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 ` 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 = 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 = 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, + val profile: String? = null, +) + +@Serializable +data class ToolsetEnvUpdateResponse( + val ok: Boolean = false, + val name: String? = null, + val saved: List = emptyList(), + val skipped: List = emptyList(), + val isSet: Map = 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, +) diff --git a/app/src/main/java/com/m57/hermescontrol/data/remote/HermesApiService.kt b/app/src/main/java/com/m57/hermescontrol/data/remote/HermesApiService.kt index 4d86df19..01c4cc4f 100644 --- a/app/src/main/java/com/m57/hermescontrol/data/remote/HermesApiService.kt +++ b/app/src/main/java/com/m57/hermescontrol/data/remote/HermesApiService.kt @@ -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 + // ── Per-toolset management (issue #782) ──────────────────────────── + @GET("api/tools/toolsets/{name}/config") + suspend fun getToolsetConfig( + @Path("name") name: String, + ): Response + + @PUT("api/tools/toolsets/{name}/provider") + suspend fun selectToolsetProvider( + @Path("name") name: String, + @Body body: ToolsetProviderSelectRequest, + ): Response + + @PUT("api/tools/toolsets/{name}/env") + suspend fun saveToolsetEnv( + @Path("name") name: String, + @Body body: ToolsetEnvUpdateRequest, + ): Response + + @POST("api/tools/toolsets/{name}/post-setup") + suspend fun runToolsetPostSetup( + @Path("name") name: String, + @Body body: ToolsetPostSetupRequest, + ): Response + @GET("api/plugins/hermes-achievements/achievements") suspend fun getAchievements(): Response diff --git a/app/src/main/java/com/m57/hermescontrol/ui/toolsets/ToolsetDetailScreen.kt b/app/src/main/java/com/m57/hermescontrol/ui/toolsets/ToolsetDetailScreen.kt new file mode 100644 index 00000000..814b8a54 --- /dev/null +++ b/app/src/main/java/com/m57/hermescontrol/ui/toolsets/ToolsetDetailScreen.kt @@ -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(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, + 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 + } + } diff --git a/app/src/main/java/com/m57/hermescontrol/ui/toolsets/ToolsetDetailViewModel.kt b/app/src/main/java/com/m57/hermescontrol/ui/toolsets/ToolsetDetailViewModel.kt new file mode 100644 index 00000000..24e86e0c --- /dev/null +++ b/app/src/main/java/com/m57/hermescontrol/ui/toolsets/ToolsetDetailViewModel.kt @@ -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 = 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 = 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 = _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 }, + ) + }, + ) +} diff --git a/app/src/main/java/com/m57/hermescontrol/ui/toolsets/ToolsetsScreen.kt b/app/src/main/java/com/m57/hermescontrol/ui/toolsets/ToolsetsScreen.kt index 2f071b45..1e6d5b77 100644 --- a/app/src/main/java/com/m57/hermescontrol/ui/toolsets/ToolsetsScreen.kt +++ b/app/src/main/java/com/m57/hermescontrol/ui/toolsets/ToolsetsScreen.kt @@ -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(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 }, - ) - } } } diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index c7c006bc..47351a80 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -524,6 +524,37 @@ No toolsets reported Verify configuration or retry + + No configurable backends for this toolset + Ready + Needs keys + Needs sign-in + Needs setup + Active + Use this backend + Requires Nous Portal sign-in to activate + No API key required + Set + Not set + Save + Delete + Remove key? + This clears %1$s from the environment. + Edit %1$s + Reveal %1$s + Hide %1$s + Clear %1$s + Run setup + Re-run setup + Running… + Installed + Installs missing dependencies for this backend + Dependencies are installed + Setup finished (exit %1$d) + Setup failed (exit %1$d) + Search: %1$s + Extract: %1$s + Global Webhooks Status Subscriptions diff --git a/app/src/test/java/com/m57/hermescontrol/ui/toolsets/ToolsetDetailViewModelTest.kt b/app/src/test/java/com/m57/hermescontrol/ui/toolsets/ToolsetDetailViewModelTest.kt new file mode 100644 index 00000000..fd08befc --- /dev/null +++ b/app/src/test/java/com/m57/hermescontrol/ui/toolsets/ToolsetDetailViewModelTest.kt @@ -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 errorResponse(code: Int): Response = 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()) } + } +}