From 57a815c67eabed3fd17413e45808f02c6d9c03c8 Mon Sep 17 00:00:00 2001 From: M57 <41645758+Hy4ri@users.noreply.github.com> Date: Fri, 3 Jul 2026 15:31:43 +0300 Subject: [PATCH] =?UTF-8?q?feat(#446):=20plugins=20tab=20full=20parity=20?= =?UTF-8?q?=E2=80=94=20install=20from=20URL,=20provider=20selection,=20rem?= =?UTF-8?q?ove=20confirm,=20badges=20(#456)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * docs: document HermesScaffold padding foot-gun root cause in AGENTS.md * feat(#446): plugins tab full parity — install from URL, provider selection, remove confirm, badges - Add Install from URL section with force reinstall + enable after install toggles - Add Plugin Provider selection section (memory provider + context engine dropdowns) - Add rescan button in top bar actions - Add confirmation dialog before removing plugins - Show source badges, runtime status, auth badges on plugin cards - Show dashboard slots from plugin manifest - Add visibility toggle (show/hide in sidebar) for manifest plugins - Show orphan dashboard plugins section - Add PluginManifestData, PluginsHubProviders, ProviderOption models - Add rescanPlugins, savePluginProviders, setPluginVisibility API endpoints * fix(#446): fix CI compilation errors — DashboardPluginMeta mapping and @Composable context * fix(#446): pass missing PluginInfo ctor params for orphan plugin mapping * fix(#446): remove duplicate refresh button in plugins top bar Scaffold's onRefresh param added a second refresh icon alongside the rescan action button. Removed onRefresh/isRefreshing from HermesScaffold and moved pull-to-refresh into a manual PullToRefreshBox wrapping the content, keeping the rescan icon as the sole top-bar action. * fix(#446): remove rescan icon from plugins top bar Only one action icon left in top bar; pull-to-refresh (loadPlugins) remains the sole refresh mechanism. rescanPlugins() viewmodel logic is unchanged but now unreachable from the UI. --------- Co-authored-by: M57 --- AGENTS.md | 49 ++ .../hermescontrol/data/model/PluginInfo.kt | 48 ++ .../data/remote/HermesApiService.kt | 15 + .../hermescontrol/ui/plugins/PluginsScreen.kt | 628 +++++++++++++++--- .../ui/plugins/PluginsViewModel.kt | 229 ++++++- app/src/main/res/values/strings.xml | 21 + 6 files changed, 881 insertions(+), 109 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index 6372fb37..899bd568 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -122,6 +122,55 @@ call unconditionally. - **`StateViews`** — `LoadingState`, `ErrorState`, `EmptyState`. Every data screen must implement all three branches in its `when { }` block. +### ⚠ HermesScaffold Padding Foot-Gun + +**This is the #1 recurring bug in this codebase.** It has been re-introduced on 4+ screens +across 3+ PRs (Settings, Achievements, Webhooks, Config — PRs #445, #454, #455). + +**Root cause:** `HermesScaffold` wraps content in an internal `Box(Modifier.padding(paddingValues))` +that already offsets for the top bar. But it also passes `paddingValues` into the content lambda, +which looks like it should be applied — and every new screen does exactly that: + +```kotlin +HermesScaffold(...) { paddingValues -> // ← scaffold already handles top bar offset via Box + LazyColumn( + modifier = Modifier + .fillMaxSize() + .padding(paddingValues), // ← ❌ double-stacked top padding + contentPadding = listContentPadding, // ← extra 8dp, now triple-stacked + ) { ... } +} +``` + +**The deeper issue:** Passing `paddingValues` into the lambda implies "you need to use this," +when the scaffold has ALREADY pre-applied it in its own outer `Box`. This API design creates +a natural foot-gun: every developer instinctively adds `.padding(paddingValues)` on inner +content because it seems correct. + +**Correct pattern — do NOT apply `paddingValues` on inner content:** + +```kotlin +// ✅ List screens: +LazyColumn( + modifier = Modifier.fillMaxSize(), // no .padding(paddingValues)!! + contentPadding = listContentPadding, // this is the ONLY padding needed + verticalArrangement = listItemSpacing, +) + +// ✅ Non-list screens (Column/Box wrapping): +Column( + modifier = Modifier.fillMaxSize(), // no .padding(paddingValues)!! +) { ... } + +// ✅ Loading/Error/Empty states — these DO need it: +LoadingState(modifier = Modifier.padding(paddingValues)) +``` + +**Quick test:** If your screen's top gap is wider than CronJobsScreen's, you've double-stacked. + +**See also:** `references/hermes-scaffold-padding.md` in the skill doc for the full +breakdown, edge cases, and timeline of previous occurrences. + ### Room Persistence - `ChatMessageEntity` / `ChatMessageDao` / `HermesDatabase` — chat messages survive diff --git a/app/src/main/java/com/m57/hermescontrol/data/model/PluginInfo.kt b/app/src/main/java/com/m57/hermescontrol/data/model/PluginInfo.kt index 12ec370a..1316fec9 100644 --- a/app/src/main/java/com/m57/hermescontrol/data/model/PluginInfo.kt +++ b/app/src/main/java/com/m57/hermescontrol/data/model/PluginInfo.kt @@ -9,6 +9,7 @@ data class PluginInfo( val source: String?, @SerializedName("runtime_status") val runtimeStatus: String?, @SerializedName("has_dashboard_manifest") val hasDashboardManifest: Boolean = false, + @SerializedName("dashboard_manifest") val dashboardManifest: PluginManifestData? = null, @SerializedName("can_remove") val canRemove: Boolean = false, @SerializedName("can_update_git") val canUpdateGit: Boolean = false, @SerializedName("auth_required") val authRequired: Boolean = false, @@ -24,8 +25,50 @@ data class PluginInfo( runtimeStatus.equals("disabled", ignoreCase = true) } +data class PluginManifestData( + val name: String? = null, + val label: String? = null, + val description: String? = null, + val icon: String? = null, + val version: String? = null, + val tab: PluginTabInfo? = null, + val slots: List? = null, + val entry: String? = null, + val css: String? = null, + @SerializedName("has_api") val hasApi: Boolean = false, + val source: String? = null, +) + +data class PluginTabInfo( + val path: String? = null, + val position: String? = null, + val override: String? = null, + val hidden: Boolean = false, +) + +data class DashboardPluginMeta( + val name: String? = null, + val label: String? = null, + val description: String? = null, + val tab: PluginTabInfo? = null, +) + +data class PluginsHubProviders( + @SerializedName("memory_provider") val memoryProvider: String? = null, + @SerializedName("memory_options") val memoryOptions: List = emptyList(), + @SerializedName("context_engine") val contextEngine: String? = null, + @SerializedName("context_options") val contextOptions: List = emptyList(), +) + +data class ProviderOption( + val name: String, + val description: String? = null, +) + data class PluginsHubResponse( val plugins: List, + @SerializedName("orphan_dashboard_plugins") val orphanDashboardPlugins: List = emptyList(), + val providers: PluginsHubProviders? = null, ) data class AgentPluginInstallBody( @@ -33,3 +76,8 @@ data class AgentPluginInstallBody( val force: Boolean = false, val enable: Boolean = true, ) + +data class PluginProvidersPutRequest( + @SerializedName("memory_provider") val memoryProvider: String? = null, + @SerializedName("context_engine") val contextEngine: 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 2907d8dc..ef861c00 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 @@ -29,6 +29,7 @@ import com.m57.hermescontrol.data.model.ModelOptionsResponse import com.m57.hermescontrol.data.model.PairingApproveRequest import com.m57.hermescontrol.data.model.PairingResponse import com.m57.hermescontrol.data.model.PairingRevokeRequest +import com.m57.hermescontrol.data.model.PluginProvidersPutRequest import com.m57.hermescontrol.data.model.PluginsHubResponse import com.m57.hermescontrol.data.model.ProfileSoulResponse import com.m57.hermescontrol.data.model.ProfilesResponse @@ -319,6 +320,20 @@ interface HermesApiService { @Path("name", encoded = true) name: String, ): Response + @POST("api/dashboard/plugins/rescan") + suspend fun rescanPlugins(): Response + + @PUT("api/dashboard/plugin-providers") + suspend fun savePluginProviders( + @Body body: PluginProvidersPutRequest, + ): Response + + @POST("api/dashboard/plugins/{name}/visibility") + suspend fun setPluginVisibility( + @Path("name", encoded = true) name: String, + @Body body: Map, + ): Response + @GET("api/messaging/platforms") suspend fun getMessagingPlatforms( @Query("profile") profile: String? = null, diff --git a/app/src/main/java/com/m57/hermescontrol/ui/plugins/PluginsScreen.kt b/app/src/main/java/com/m57/hermescontrol/ui/plugins/PluginsScreen.kt index 9721ab17..645933e5 100644 --- a/app/src/main/java/com/m57/hermescontrol/ui/plugins/PluginsScreen.kt +++ b/app/src/main/java/com/m57/hermescontrol/ui/plugins/PluginsScreen.kt @@ -1,7 +1,6 @@ package com.m57.hermescontrol.ui.plugins import androidx.compose.foundation.layout.Arrangement -import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Spacer @@ -9,18 +8,34 @@ import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.width import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.items +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.Delete +import androidx.compose.material.icons.filled.InstallDesktop +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.CircularProgressIndicator +import androidx.compose.material3.DropdownMenuItem import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.ExposedDropdownMenuBox +import androidx.compose.material3.ExposedDropdownMenuDefaults +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton import androidx.compose.material3.MaterialTheme import androidx.compose.material3.OutlinedButton +import androidx.compose.material3.OutlinedTextField import androidx.compose.material3.Switch import androidx.compose.material3.Text +import androidx.compose.material3.TextButton +import androidx.compose.material3.pulltorefresh.PullToRefreshBox import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.getValue @@ -34,6 +49,8 @@ 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.PluginInfo +import com.m57.hermescontrol.theme.LocalHermesStatusColors import com.m57.hermescontrol.ui.common.EmptyState import com.m57.hermescontrol.ui.common.ErrorState import com.m57.hermescontrol.ui.common.HermesScaffold @@ -69,117 +86,112 @@ fun PluginsScreen( ToastEffect(toastMessage = state.toastMessage, onClearToast = viewModel::clearToast) + // Remove confirmation dialog + if (state.removeConfirmPlugin != null) { + AlertDialog( + onDismissRequest = { viewModel.cancelRemovePlugin() }, + title = { Text(stringResource(R.string.plugins_remove_confirm_title)) }, + text = { + Text( + stringResource( + R.string.plugins_remove_confirm_text, + state.removeConfirmPlugin ?: "", + ), + ) + }, + confirmButton = { + TextButton( + onClick = { viewModel.confirmRemovePlugin() }, + colors = ButtonDefaults.textButtonColors(contentColor = MaterialTheme.colorScheme.error), + ) { + Text(stringResource(R.string.plugins_action_remove)) + } + }, + dismissButton = { + TextButton(onClick = { viewModel.cancelRemovePlugin() }) { + Text(stringResource(R.string.common_cancel)) + } + }, + ) + } + HermesScaffold( title = { Text(stringResource(R.string.screen_plugins)) }, navigationIcon = onOpenDrawer?.let { NavIcon.Menu(it) }, - isRefreshing = state.isLoading, - onRefresh = { viewModel.loadPlugins() }, ) { paddingValues -> - when { - state.isLoading && state.plugins.isEmpty() -> { - LoadingState(modifier = Modifier.padding(paddingValues)) - } + PullToRefreshBox( + isRefreshing = state.isLoading, + onRefresh = { viewModel.loadPlugins() }, + modifier = Modifier.fillMaxSize(), + ) { + when { + state.isLoading && state.plugins.isEmpty() -> { + LoadingState(modifier = Modifier.padding(paddingValues)) + } - state.errorMessage != null -> { - ErrorState( - message = state.errorMessage ?: "", - onRetry = { viewModel.loadPlugins() }, - modifier = Modifier.padding(paddingValues), - ) - } + state.errorMessage != null -> { + ErrorState( + message = state.errorMessage ?: "", + onRetry = { viewModel.loadPlugins() }, + modifier = Modifier.padding(paddingValues), + ) + } - state.plugins.isEmpty() -> { - EmptyState( - title = stringResource(R.string.plugins_empty_title), - subtitle = stringResource(R.string.plugins_empty_desc), - onAction = { viewModel.loadPlugins() }, - actionLabel = stringResource(R.string.content_desc_refresh), - modifier = Modifier.padding(paddingValues), - ) - } + state.plugins.isEmpty() && state.orphanPlugins.isEmpty() -> { + EmptyState( + title = stringResource(R.string.plugins_empty_title), + subtitle = stringResource(R.string.plugins_empty_desc), + onAction = { viewModel.loadPlugins() }, + actionLabel = stringResource(R.string.content_desc_refresh), + modifier = Modifier.padding(paddingValues), + ) + } - else -> { - Box(Modifier.fillMaxSize()) { - if (state.isLoading) { - CircularProgressIndicator() - } else if (state.errorMessage != null) { - Text( - text = state.errorMessage ?: "", - color = MaterialTheme.colorScheme.error, - modifier = Modifier.padding(16.dp), - ) - } else { - LazyColumn( - modifier = Modifier.fillMaxSize(), - contentPadding = listContentPadding, - verticalArrangement = listItemSpacing, - ) { - item { - SearchBar( - query = query, - onQueryChange = { query = it }, - placeholder = "Search plugins...", + else -> { + LazyColumn( + modifier = Modifier.fillMaxSize(), + contentPadding = listContentPadding, + verticalArrangement = listItemSpacing, + ) { + // Provider selection section + if (state.memoryOptions.isNotEmpty() || state.contextOptions.isNotEmpty()) { + item(key = "providers") { + ProviderSelectionSection(state, viewModel) + } + } + + // Install section + item(key = "install") { + InstallSection(state, viewModel) + } + + // Search bar + item(key = "search") { + SearchBar( + query = query, + onQueryChange = { query = it }, + placeholder = "Search plugins...", + ) + } + + // Plugin list + items(filteredPlugins, key = { it.name }) { plugin -> + PluginCard(plugin = plugin, state = state, viewModel = viewModel) + } + + // Orphan dashboard plugins section + if (state.orphanPlugins.isNotEmpty()) { + item(key = "orphan-header") { + Spacer(modifier = Modifier.height(8.dp)) + Text( + text = stringResource(R.string.plugins_orphan_heading), + style = MaterialTheme.typography.titleSmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + modifier = Modifier.padding(vertical = 8.dp), ) } - items(filteredPlugins, key = { it.name }) { plugin -> - Card(modifier = Modifier.fillMaxWidth()) { - Column(modifier = Modifier.padding(16.dp)) { - Row( - modifier = Modifier.fillMaxWidth(), - horizontalArrangement = Arrangement.SpaceBetween, - verticalAlignment = Alignment.CenterVertically, - ) { - Column(modifier = Modifier.weight(1f)) { - Text(text = plugin.name, style = MaterialTheme.typography.titleMedium) - plugin.version?.let { - Text( - text = "v$it", - style = MaterialTheme.typography.bodySmall, - color = MaterialTheme.colorScheme.onSurfaceVariant, - ) - } - } - if (plugin.installed) { - Switch( - checked = plugin.enabled, - onCheckedChange = { viewModel.togglePlugin(plugin) }, - ) - } - } - Spacer(modifier = Modifier.height(4.dp)) - Text( - text = plugin.description ?: stringResource(R.string.plugins_no_desc), - style = MaterialTheme.typography.bodyMedium, - ) - Spacer(modifier = Modifier.height(8.dp)) - Row( - modifier = Modifier.fillMaxWidth(), - horizontalArrangement = Arrangement.End, - ) { - if (plugin.installed) { - OutlinedButton(onClick = { viewModel.updatePlugin(plugin.name) }) { - Text(stringResource(R.string.plugins_action_update)) - } - if (plugin.canRemove) { - Spacer(modifier = Modifier.width(8.dp)) - OutlinedButton( - onClick = { viewModel.uninstallPlugin(plugin.name) }, - colors = - ButtonDefaults.outlinedButtonColors( - contentColor = MaterialTheme.colorScheme.error, - ), - ) { - Text(stringResource(R.string.plugins_action_uninstall)) - } - } - } else { - Button(onClick = { viewModel.activatePlugin(plugin) }) { - Text(stringResource(R.string.plugins_action_install)) - } - } - } - } - } + items(state.orphanPlugins, key = { "orphan-${it.name}" }) { plugin -> + OrphanPluginCard(plugin = plugin) } } } @@ -188,3 +200,413 @@ fun PluginsScreen( } } } + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +private fun ProviderSelectionSection( + state: PluginsUiState, + viewModel: PluginsViewModel, +) { + val providerDefaultsLabel = stringResource(R.string.plugins_provider_defaults) + + Card(modifier = Modifier.fillMaxWidth()) { + Column(modifier = Modifier.padding(16.dp)) { + Text( + text = stringResource(R.string.plugins_providers_heading), + style = MaterialTheme.typography.titleMedium, + ) + Spacer(modifier = Modifier.height(4.dp)) + Text( + text = stringResource(R.string.plugins_providers_hint), + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + Spacer(modifier = Modifier.height(12.dp)) + + // Memory provider dropdown + Text( + text = stringResource(R.string.plugins_memory_provider_label), + style = MaterialTheme.typography.labelMedium, + ) + Spacer(modifier = Modifier.height(4.dp)) + PluginProviderDropdown( + label = stringResource(R.string.plugins_memory_provider_label), + options = + listOf( + providerDefaultsLabel, + ) + state.memoryOptions.map { it.name }, + selectedValue = + if (state.isMemoryBuiltin) { + providerDefaultsLabel + } else { + state.memoryProvider + }, + onOptionSelected = { selected -> + if (selected == providerDefaultsLabel) { + viewModel.updateMemoryProvider(PluginsUiState.MEMORY_PROVIDER_BUILTIN) + } else { + viewModel.updateMemoryProvider(selected) + } + }, + ) + + Spacer(modifier = Modifier.height(12.dp)) + + // Context engine dropdown + Text( + text = stringResource(R.string.plugins_context_engine_label), + style = MaterialTheme.typography.labelMedium, + ) + Spacer(modifier = Modifier.height(4.dp)) + PluginProviderDropdown( + label = stringResource(R.string.plugins_context_engine_label), + options = listOf("compressor") + state.contextOptions.map { it.name }, + selectedValue = state.contextEngine, + onOptionSelected = { viewModel.updateContextEngine(it) }, + ) + + Spacer(modifier = Modifier.height(12.dp)) + + Button( + onClick = { viewModel.savePluginProviders() }, + enabled = !state.providerBusy, + ) { + if (state.providerBusy) { + CircularProgressIndicator( + modifier = Modifier.size(16.dp), + strokeWidth = 2.dp, + ) + Spacer(modifier = Modifier.width(8.dp)) + } + Text(stringResource(R.string.common_save)) + } + } + } +} + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +private fun PluginProviderDropdown( + label: String, + options: List, + selectedValue: String, + onOptionSelected: (String) -> Unit, +) { + var expanded by remember { mutableStateOf(false) } + + ExposedDropdownMenuBox( + expanded = expanded, + onExpandedChange = { expanded = it }, + ) { + OutlinedTextField( + value = selectedValue.ifEmpty { options.firstOrNull() ?: "" }, + onValueChange = {}, + readOnly = true, + modifier = + Modifier + .fillMaxWidth() + .menuAnchor(), + label = { Text(label) }, + trailingIcon = { ExposedDropdownMenuDefaults.TrailingIcon(expanded = expanded) }, + singleLine = true, + shape = RoundedCornerShape(8.dp), + ) + + ExposedDropdownMenu( + expanded = expanded, + onDismissRequest = { expanded = false }, + ) { + options.forEach { option -> + DropdownMenuItem( + text = { Text(option) }, + onClick = { + onOptionSelected(option) + expanded = false + }, + ) + } + } + } +} + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +private fun InstallSection( + state: PluginsUiState, + viewModel: PluginsViewModel, +) { + Card(modifier = Modifier.fillMaxWidth()) { + Column(modifier = Modifier.padding(16.dp)) { + Text( + text = stringResource(R.string.plugins_install_heading), + style = MaterialTheme.typography.titleMedium, + ) + Spacer(modifier = Modifier.height(4.dp)) + Text( + text = stringResource(R.string.plugins_install_hint), + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + Spacer(modifier = Modifier.height(12.dp)) + + OutlinedTextField( + value = state.installUrl, + onValueChange = { viewModel.updateInstallUrl(it) }, + modifier = Modifier.fillMaxWidth(), + placeholder = { Text("owner/repo, owner/repo/subdir, or https://...") }, + singleLine = true, + enabled = !state.installBusy, + ) + + Spacer(modifier = Modifier.height(12.dp)) + + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.spacedBy(16.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + Row(verticalAlignment = Alignment.CenterVertically) { + Switch( + checked = state.installForce, + onCheckedChange = { viewModel.updateInstallForce(it) }, + enabled = !state.installBusy, + ) + Spacer(modifier = Modifier.width(8.dp)) + Text( + text = stringResource(R.string.plugins_force_reinstall), + style = MaterialTheme.typography.bodySmall, + ) + } + Row(verticalAlignment = Alignment.CenterVertically) { + Switch( + checked = state.installEnable, + onCheckedChange = { viewModel.updateInstallEnable(it) }, + enabled = !state.installBusy, + ) + Spacer(modifier = Modifier.width(8.dp)) + Text( + text = stringResource(R.string.plugins_enable_after_install), + style = MaterialTheme.typography.bodySmall, + ) + } + } + + Spacer(modifier = Modifier.height(12.dp)) + + Button( + onClick = { viewModel.installPluginFromUrl() }, + enabled = !state.installBusy && state.installUrl.isNotBlank(), + ) { + if (state.installBusy) { + CircularProgressIndicator( + modifier = Modifier.size(16.dp), + strokeWidth = 2.dp, + ) + Spacer(modifier = Modifier.width(8.dp)) + } else { + Icon( + Icons.Default.InstallDesktop, + contentDescription = null, + modifier = Modifier.size(18.dp), + ) + Spacer(modifier = Modifier.width(8.dp)) + } + Text(stringResource(R.string.plugins_action_install)) + } + } + } +} + +@Composable +private fun PluginCard( + plugin: PluginInfo, + state: PluginsUiState, + viewModel: PluginsViewModel, +) { + val busy = state.rowBusy == plugin.name + val statusColors = LocalHermesStatusColors.current + + Card(modifier = Modifier.fillMaxWidth()) { + Column(modifier = Modifier.padding(16.dp)) { + // Header row: name + toggle + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically, + ) { + Column(modifier = Modifier.weight(1f)) { + Text(text = plugin.name, style = MaterialTheme.typography.titleMedium) + Row(horizontalArrangement = Arrangement.spacedBy(6.dp)) { + // Version badge + plugin.version?.let { + Text( + text = "v$it", + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + // Source badge + plugin.source?.let { + Text( + text = it, + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + } + } + if (plugin.installed) { + Switch( + checked = plugin.enabled, + onCheckedChange = { viewModel.togglePlugin(plugin) }, + ) + } + } + + Spacer(modifier = Modifier.height(4.dp)) + + // Description + Text( + text = plugin.description ?: stringResource(R.string.plugins_no_desc), + style = MaterialTheme.typography.bodyMedium, + ) + + // Status badges + Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) { + val statusColor = + when { + plugin.enabled -> statusColors.success + plugin.installed -> statusColors.warning + else -> statusColors.error + } + Text( + text = plugin.runtimeStatus ?: "inactive", + style = MaterialTheme.typography.labelSmall, + color = statusColor, + ) + if (plugin.authRequired) { + Text( + text = stringResource(R.string.plugins_auth_required), + style = MaterialTheme.typography.labelSmall, + color = statusColors.error, + ) + } + } + + // Dashboard slots + plugin.dashboardManifest?.slots?.let { slots -> + if (slots.isNotEmpty()) { + Spacer(modifier = Modifier.height(4.dp)) + Text( + text = stringResource(R.string.plugins_dashboard_slots, slots.joinToString(", ")), + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + } + + // Auth command hint + if (plugin.authRequired && plugin.authCommand != null) { + Spacer(modifier = Modifier.height(4.dp)) + Text( + text = stringResource(R.string.plugins_auth_command_hint, plugin.authCommand), + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + + Spacer(modifier = Modifier.height(8.dp)) + + // Action buttons row + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.End, + ) { + if (plugin.installed) { + // Visibility toggle (only for plugins with dashboard manifest) + if (plugin.hasDashboardManifest) { + IconButton( + onClick = { viewModel.togglePluginVisibility(plugin) }, + enabled = !busy, + modifier = Modifier.size(32.dp), + ) { + Icon( + if (plugin.userHidden) Icons.Default.VisibilityOff else Icons.Default.Visibility, + contentDescription = + if (plugin.userHidden) { + stringResource(R.string.plugins_show_in_sidebar) + } else { + stringResource(R.string.plugins_hide_from_sidebar) + }, + modifier = Modifier.size(18.dp), + ) + } + Spacer(modifier = Modifier.width(4.dp)) + } + + // Update button + if (plugin.canUpdateGit) { + OutlinedButton( + onClick = { viewModel.updatePlugin(plugin.name) }, + enabled = !busy, + ) { + Text(stringResource(R.string.plugins_action_update)) + } + Spacer(modifier = Modifier.width(8.dp)) + } + + // Remove button with confirmation + if (plugin.canRemove) { + OutlinedButton( + onClick = { viewModel.requestRemovePlugin(plugin.name) }, + enabled = !busy, + colors = + ButtonDefaults.outlinedButtonColors( + contentColor = MaterialTheme.colorScheme.error, + ), + ) { + Icon( + Icons.Default.Delete, + contentDescription = null, + modifier = Modifier.size(16.dp), + ) + Spacer(modifier = Modifier.width(4.dp)) + Text(stringResource(R.string.plugins_action_uninstall)) + } + } + } else { + Button( + onClick = { viewModel.activatePlugin(plugin) }, + enabled = !busy, + ) { + Text(stringResource(R.string.plugins_action_install)) + } + } + } + } + } +} + +@Composable +private fun OrphanPluginCard(plugin: PluginInfo) { + Card(modifier = Modifier.fillMaxWidth()) { + Row( + modifier = Modifier.padding(12.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + Column(modifier = Modifier.weight(1f)) { + Text( + text = plugin.name, + style = MaterialTheme.typography.titleSmall, + ) + plugin.description?.let { + Text( + text = it, + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + } + } + } +} diff --git a/app/src/main/java/com/m57/hermescontrol/ui/plugins/PluginsViewModel.kt b/app/src/main/java/com/m57/hermescontrol/ui/plugins/PluginsViewModel.kt index 3c71265e..09d88110 100644 --- a/app/src/main/java/com/m57/hermescontrol/ui/plugins/PluginsViewModel.kt +++ b/app/src/main/java/com/m57/hermescontrol/ui/plugins/PluginsViewModel.kt @@ -3,6 +3,8 @@ package com.m57.hermescontrol.ui.plugins import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope import com.m57.hermescontrol.data.model.PluginInfo +import com.m57.hermescontrol.data.model.PluginProvidersPutRequest +import com.m57.hermescontrol.data.model.ProviderOption import com.m57.hermescontrol.data.remote.ApiClient import com.m57.hermescontrol.data.remote.NetworkResult import com.m57.hermescontrol.data.remote.safeApiCall @@ -19,9 +21,35 @@ import kotlinx.coroutines.withContext data class PluginsUiState( val isLoading: Boolean = false, val plugins: List = emptyList(), + val orphanPlugins: List = emptyList(), val errorMessage: String? = null, val toastMessage: String? = null, -) + // Install state + val installUrl: String = "", + val installForce: Boolean = false, + val installEnable: Boolean = true, + val installBusy: Boolean = false, + // Provider selection state + val memoryProvider: String = "", + val memoryOptions: List = emptyList(), + val contextEngine: String = "compressor", + val contextOptions: List = emptyList(), + val providerBusy: Boolean = false, + // Plugin row operation state + val rowBusy: String? = null, + // Confirm remove dialog + val removeConfirmPlugin: String? = null, + // Rescan + val rescanBusy: Boolean = false, +) { + /** Built-in memory provider sentinel — empty string means "use config defaults" */ + val isMemoryBuiltin: Boolean + get() = memoryProvider.isEmpty() + + companion object { + const val MEMORY_PROVIDER_BUILTIN = "" + } +} class PluginsViewModel : ViewModel(), ToastHost { private val _uiState = MutableStateFlow(PluginsUiState()) @@ -33,7 +61,27 @@ class PluginsViewModel : ViewModel(), ToastHost { onStart = { _uiState.update { it.copy(isLoading = true, errorMessage = null) } }, onSuccess = { data -> val plugins = data.plugins.orEmpty() - _uiState.update { it.copy(isLoading = false, plugins = plugins) } + val providers = data.providers + _uiState.update { + it.copy( + isLoading = false, + plugins = plugins, + orphanPlugins = + data.orphanDashboardPlugins.orEmpty().map { + PluginInfo( + name = it.name ?: "unknown", + description = it.description ?: it.label, + version = null, + source = null, + runtimeStatus = null, + ) + }, + memoryProvider = providers?.memoryProvider ?: "", + memoryOptions = providers?.memoryOptions.orEmpty(), + contextEngine = providers?.contextEngine ?: "compressor", + contextOptions = providers?.contextOptions.orEmpty(), + ) + } }, onError = { errorMsg -> _uiState.update { @@ -46,6 +94,125 @@ class PluginsViewModel : ViewModel(), ToastHost { ) } + fun updateInstallUrl(url: String) { + _uiState.update { it.copy(installUrl = url) } + } + + fun updateInstallForce(force: Boolean) { + _uiState.update { it.copy(installForce = force) } + } + + fun updateInstallEnable(enable: Boolean) { + _uiState.update { it.copy(installEnable = enable) } + } + + fun installPluginFromUrl() { + val url = _uiState.value.installUrl.trim() + if (url.isEmpty()) { + _uiState.update { it.copy(toastMessage = "Please enter a plugin identifier") } + return + } + viewModelScope.launch { + _uiState.update { it.copy(installBusy = true) } + val result = + withContext(Dispatchers.IO) { + safeApiCall { + ApiClient.hermesApi.installPlugin( + com.m57.hermescontrol.data.model.AgentPluginInstallBody( + identifier = url, + force = _uiState.value.installForce, + enable = _uiState.value.installEnable, + ), + ) + } + } + when (result) { + is NetworkResult.Success -> { + _uiState.update { + it.copy( + installUrl = "", + installBusy = false, + toastMessage = "Plugin installed successfully", + ) + } + loadPlugins() + } + is NetworkResult.Failure -> { + _uiState.update { + it.copy( + installBusy = false, + toastMessage = "Failed to install plugin: ${result.error.message}", + ) + } + } + } + } + } + + fun updateMemoryProvider(provider: String) { + _uiState.update { it.copy(memoryProvider = provider) } + } + + fun updateContextEngine(engine: String) { + _uiState.update { it.copy(contextEngine = engine) } + } + + fun savePluginProviders() { + viewModelScope.launch { + _uiState.update { it.copy(providerBusy = true) } + val state = _uiState.value + val result = + withContext(Dispatchers.IO) { + safeApiCall { + ApiClient.hermesApi.savePluginProviders( + PluginProvidersPutRequest( + memoryProvider = if (state.isMemoryBuiltin) "" else state.memoryProvider, + contextEngine = state.contextEngine, + ), + ) + } + } + when (result) { + is NetworkResult.Success -> { + _uiState.update { it.copy(providerBusy = false, toastMessage = "Plugin providers saved") } + loadPlugins() + } + is NetworkResult.Failure -> { + _uiState.update { + it.copy( + providerBusy = false, + toastMessage = "Failed to save providers: ${result.error.message}", + ) + } + } + } + } + } + + fun rescanPlugins() { + viewModelScope.launch { + _uiState.update { it.copy(rescanBusy = true) } + val result = + withContext(Dispatchers.IO) { + safeApiCall { ApiClient.hermesApi.rescanPlugins() } + } + when (result) { + is NetworkResult.Success -> { + _uiState.update { it.copy(rescanBusy = false, toastMessage = "Plugins rescanned") } + loadPlugins() + } + is NetworkResult.Failure -> { + _uiState.update { + it.copy( + rescanBusy = false, + toastMessage = "Failed to rescan: ${result.error.message}", + ) + } + } + } + } + } + fun togglePlugin(plugin: PluginInfo) { val originalEnabled = plugin.enabled val targetEnabled = !originalEnabled @@ -90,7 +257,6 @@ class PluginsViewModel : ViewModel(), ToastHost { _uiState.update { it.copy(toastMessage = "Plugin enabled successfully") } loadPlugins() } - is NetworkResult.Failure -> { _uiState.update { it.copy(toastMessage = "Failed to enable plugin: ${result.error.message}") } } @@ -98,8 +264,21 @@ class PluginsViewModel : ViewModel(), ToastHost { } } - fun uninstallPlugin(name: String) { + /** Show confirmation dialog for removing a plugin */ + fun requestRemovePlugin(name: String) { + _uiState.update { it.copy(removeConfirmPlugin = name) } + } + + /** Cancel the remove confirmation dialog */ + fun cancelRemovePlugin() { + _uiState.update { it.copy(removeConfirmPlugin = null) } + } + + fun confirmRemovePlugin() { + val name = _uiState.value.removeConfirmPlugin ?: return + _uiState.update { it.copy(removeConfirmPlugin = null) } viewModelScope.launch { + setRowBusy(name) val result = withContext(Dispatchers.IO) { safeApiCall { ApiClient.hermesApi.uninstallPlugin(name) } @@ -107,11 +286,12 @@ class PluginsViewModel : ViewModel(), ToastHost { when (result) { is NetworkResult.Success -> { _uiState.update { it.copy(toastMessage = "Plugin uninstalled successfully") } + clearRowBusy(name) loadPlugins() } - is NetworkResult.Failure -> { _uiState.update { it.copy(toastMessage = "Failed to uninstall plugin: ${result.error.message}") } + clearRowBusy(name) } } } @@ -119,6 +299,7 @@ class PluginsViewModel : ViewModel(), ToastHost { fun updatePlugin(name: String) { viewModelScope.launch { + setRowBusy(name) val result = withContext(Dispatchers.IO) { safeApiCall { ApiClient.hermesApi.updatePlugin(name) } @@ -126,11 +307,39 @@ class PluginsViewModel : ViewModel(), ToastHost { when (result) { is NetworkResult.Success -> { _uiState.update { it.copy(toastMessage = "Plugin updated successfully") } + clearRowBusy(name) loadPlugins() } - is NetworkResult.Failure -> { _uiState.update { it.copy(toastMessage = "Failed to update plugin: ${result.error.message}") } + clearRowBusy(name) + } + } + } + } + + fun togglePluginVisibility(plugin: PluginInfo) { + viewModelScope.launch { + setRowBusy(plugin.name) + val targetHidden = !plugin.userHidden + val result = + withContext(Dispatchers.IO) { + safeApiCall { + ApiClient.hermesApi.setPluginVisibility( + plugin.name, + mapOf("hidden" to targetHidden), + ) + } + } + when (result) { + is NetworkResult.Success -> { + _uiState.update { it.copy(toastMessage = "Plugin visibility updated") } + clearRowBusy(plugin.name) + loadPlugins() + } + is NetworkResult.Failure -> { + _uiState.update { it.copy(toastMessage = "Failed to update visibility: ${result.error.message}") } + clearRowBusy(plugin.name) } } } @@ -156,6 +365,14 @@ class PluginsViewModel : ViewModel(), ToastHost { } } + private fun setRowBusy(name: String) { + _uiState.update { it.copy(rowBusy = name) } + } + + private fun clearRowBusy(name: String) { + _uiState.update { if (it.rowBusy == name) it.copy(rowBusy = null) else it } + } + override fun clearToast() { _uiState.update { it.copy(toastMessage = null) } } diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 92f8710b..a13c3319 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -28,6 +28,8 @@ Back Open navigation drawer Refresh + Save + Cancel Clear search @@ -360,6 +362,25 @@ Update Uninstall Install + Remove + Rescan + Install Plugin + Install a plugin from a git/URL source + Force reinstall + Enable after install + Plugin Providers + Select active memory and context providers + Memory Provider + Context Engine + (config defaults) + Remove Plugin + This will remove the "%1$s" plugin from your agent. + Auth Required + Auth command: %1$s + Dashboard slots: %1$s + Orphaned Dashboard Plugins + Show in sidebar + Hide from sidebar Transport: %1$s