feat(#446): plugins tab full parity — install from URL, provider selection, remove confirm, badges (#456)

* 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 <hy4ri@users.noreply.github.com>
This commit is contained in:
M57andM57 authored and GitHub committed 2026-07-03 15:31:43 +03:00
1 parent 0ee24144c0
commit 57a815c67e
6 files changed
+881 -109

No files matched your search

@@ -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<String>? = 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<ProviderOption> = emptyList(),
@SerializedName("context_engine") val contextEngine: String? = null,
@SerializedName("context_options") val contextOptions: List<ProviderOption> = emptyList(),
)
data class ProviderOption(
val name: String,
val description: String? = null,
)
data class PluginsHubResponse(
val plugins: List<PluginInfo>,
@SerializedName("orphan_dashboard_plugins") val orphanDashboardPlugins: List<DashboardPluginMeta> = 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,
)
@@ -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<Unit>
@POST("api/dashboard/plugins/rescan")
suspend fun rescanPlugins(): Response<Unit>
@PUT("api/dashboard/plugin-providers")
suspend fun savePluginProviders(
@Body body: PluginProvidersPutRequest,
): Response<Unit>
@POST("api/dashboard/plugins/{name}/visibility")
suspend fun setPluginVisibility(
@Path("name", encoded = true) name: String,
@Body body: Map<String, Boolean>,
): Response<Unit>
@GET("api/messaging/platforms")
suspend fun getMessagingPlatforms(
@Query("profile") profile: String? = null,
@@ -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<String>,
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,
)
}
}
}
}
}
@@ -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<PluginInfo> = emptyList(),
val orphanPlugins: List<PluginInfo> = 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<ProviderOption> = emptyList(),
val contextEngine: String = "compressor",
val contextOptions: List<ProviderOption> = 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) }
}