From 38302365850d00b8b85847e087488dd60f111c17 Mon Sep 17 00:00:00 2001 From: Hy4ri Date: Tue, 16 Jun 2026 18:15:01 +0300 Subject: [PATCH] feat: slash command autocomplete, dynamic theme colors, interactive model selection & drawer icons --- .../java/com/m57/hermescontrol/Navigation.kt | 36 ++++ .../java/com/m57/hermescontrol/theme/Theme.kt | 14 +- .../m57/hermescontrol/ui/chat/ChatScreen.kt | 197 ++++++++++++------ .../m57/hermescontrol/ui/model/ModelScreen.kt | 69 +++++- .../hermescontrol/ui/model/ModelViewModel.kt | 65 ++++++ 5 files changed, 314 insertions(+), 67 deletions(-) diff --git a/app/src/main/java/com/m57/hermescontrol/Navigation.kt b/app/src/main/java/com/m57/hermescontrol/Navigation.kt index 256e2721..c1995954 100644 --- a/app/src/main/java/com/m57/hermescontrol/Navigation.kt +++ b/app/src/main/java/com/m57/hermescontrol/Navigation.kt @@ -4,8 +4,26 @@ import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.safeDrawingPadding import androidx.compose.foundation.rememberScrollState import androidx.compose.foundation.verticalScroll +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.AccountCircle +import androidx.compose.material.icons.filled.Build +import androidx.compose.material.icons.filled.CallSplit +import androidx.compose.material.icons.filled.Chat +import androidx.compose.material.icons.filled.Code +import androidx.compose.material.icons.filled.Dashboard +import androidx.compose.material.icons.filled.Devices +import androidx.compose.material.icons.filled.Dns +import androidx.compose.material.icons.filled.Extension +import androidx.compose.material.icons.filled.Info +import androidx.compose.material.icons.filled.Key +import androidx.compose.material.icons.filled.List +import androidx.compose.material.icons.filled.PlayArrow +import androidx.compose.material.icons.filled.Schedule +import androidx.compose.material.icons.filled.Settings +import androidx.compose.material.icons.filled.Star import androidx.compose.material3.DrawerValue import androidx.compose.material3.HorizontalDivider +import androidx.compose.material3.Icon import androidx.compose.material3.MaterialTheme import androidx.compose.material3.ModalDrawerSheet import androidx.compose.material3.ModalNavigationDrawer @@ -82,6 +100,7 @@ fun MainNavigation() { ) HorizontalDivider() NavigationDrawerItem( + icon = { Icon(Icons.Filled.Chat, contentDescription = null) }, label = { Text("Chat") }, selected = currentScreen == ChatScreen, onClick = { @@ -91,6 +110,7 @@ fun MainNavigation() { modifier = Modifier.padding(horizontal = 12.dp, vertical = 4.dp), ) NavigationDrawerItem( + icon = { Icon(Icons.Filled.Extension, contentDescription = null) }, label = { Text("Skills") }, selected = currentScreen == SkillsScreen, onClick = { @@ -100,6 +120,7 @@ fun MainNavigation() { modifier = Modifier.padding(horizontal = 12.dp, vertical = 4.dp), ) NavigationDrawerItem( + icon = { Icon(Icons.Filled.Schedule, contentDescription = null) }, label = { Text("Cron Jobs") }, selected = currentScreen == CronJobsScreen, onClick = { @@ -109,6 +130,7 @@ fun MainNavigation() { modifier = Modifier.padding(horizontal = 12.dp, vertical = 4.dp), ) NavigationDrawerItem( + icon = { Icon(Icons.Filled.AccountCircle, contentDescription = null) }, label = { Text("Profiles") }, selected = currentScreen == ProfilesScreen, onClick = { @@ -118,6 +140,7 @@ fun MainNavigation() { modifier = Modifier.padding(horizontal = 12.dp, vertical = 4.dp), ) NavigationDrawerItem( + icon = { Icon(Icons.Filled.Build, contentDescription = null) }, label = { Text("Toolsets") }, selected = currentScreen == ToolsetsScreen, onClick = { @@ -127,6 +150,7 @@ fun MainNavigation() { modifier = Modifier.padding(horizontal = 12.dp, vertical = 4.dp), ) NavigationDrawerItem( + icon = { Icon(Icons.Filled.Star, contentDescription = null) }, label = { Text("Achievements") }, selected = currentScreen == AchievementsScreen, onClick = { @@ -136,6 +160,7 @@ fun MainNavigation() { modifier = Modifier.padding(horizontal = 12.dp, vertical = 4.dp), ) NavigationDrawerItem( + icon = { Icon(Icons.Filled.Devices, contentDescription = null) }, label = { Text("Pairing") }, selected = currentScreen == PairingScreen, onClick = { @@ -145,6 +170,7 @@ fun MainNavigation() { modifier = Modifier.padding(horizontal = 12.dp, vertical = 4.dp), ) NavigationDrawerItem( + icon = { Icon(Icons.Filled.Code, contentDescription = null) }, label = { Text("Config") }, selected = currentScreen == ConfigScreen, onClick = { @@ -154,6 +180,7 @@ fun MainNavigation() { modifier = Modifier.padding(horizontal = 12.dp, vertical = 4.dp), ) NavigationDrawerItem( + icon = { Icon(Icons.Filled.Dns, contentDescription = null) }, label = { Text("MCP Servers") }, selected = currentScreen == McpServersScreen, onClick = { @@ -163,6 +190,7 @@ fun MainNavigation() { modifier = Modifier.padding(horizontal = 12.dp, vertical = 4.dp), ) NavigationDrawerItem( + icon = { Icon(Icons.Filled.CallSplit, contentDescription = null) }, label = { Text("Webhooks") }, selected = currentScreen == WebhooksScreen, onClick = { @@ -172,6 +200,7 @@ fun MainNavigation() { modifier = Modifier.padding(horizontal = 12.dp, vertical = 4.dp), ) NavigationDrawerItem( + icon = { Icon(Icons.Filled.Settings, contentDescription = null) }, label = { Text("Models") }, selected = currentScreen == ModelScreen, onClick = { @@ -181,6 +210,7 @@ fun MainNavigation() { modifier = Modifier.padding(horizontal = 12.dp, vertical = 4.dp), ) NavigationDrawerItem( + icon = { Icon(Icons.Filled.PlayArrow, contentDescription = null) }, label = { Text("Gateway") }, selected = currentScreen == GatewayScreen, onClick = { @@ -190,6 +220,7 @@ fun MainNavigation() { modifier = Modifier.padding(horizontal = 12.dp, vertical = 4.dp), ) NavigationDrawerItem( + icon = { Icon(Icons.Filled.List, contentDescription = null) }, label = { Text("Logs") }, selected = currentScreen == LogsScreen, onClick = { @@ -199,6 +230,7 @@ fun MainNavigation() { modifier = Modifier.padding(horizontal = 12.dp, vertical = 4.dp), ) NavigationDrawerItem( + icon = { Icon(Icons.Filled.Extension, contentDescription = null) }, label = { Text("Plugins") }, selected = currentScreen == PluginsScreen, onClick = { @@ -208,6 +240,7 @@ fun MainNavigation() { modifier = Modifier.padding(horizontal = 12.dp, vertical = 4.dp), ) NavigationDrawerItem( + icon = { Icon(Icons.Filled.Chat, contentDescription = null) }, label = { Text("Channels") }, selected = currentScreen == ChannelsScreen, onClick = { @@ -217,6 +250,7 @@ fun MainNavigation() { modifier = Modifier.padding(horizontal = 12.dp, vertical = 4.dp), ) NavigationDrawerItem( + icon = { Icon(Icons.Filled.Key, contentDescription = null) }, label = { Text("Keys") }, selected = currentScreen == KeysScreen, onClick = { @@ -226,6 +260,7 @@ fun MainNavigation() { modifier = Modifier.padding(horizontal = 12.dp, vertical = 4.dp), ) NavigationDrawerItem( + icon = { Icon(Icons.Filled.Info, contentDescription = null) }, label = { Text("System") }, selected = currentScreen == SystemScreen, onClick = { @@ -235,6 +270,7 @@ fun MainNavigation() { modifier = Modifier.padding(horizontal = 12.dp, vertical = 4.dp), ) NavigationDrawerItem( + icon = { Icon(Icons.Filled.Dashboard, contentDescription = null) }, label = { Text("Kanban") }, selected = currentScreen == KanbanScreen, onClick = { diff --git a/app/src/main/java/com/m57/hermescontrol/theme/Theme.kt b/app/src/main/java/com/m57/hermescontrol/theme/Theme.kt index 3c5b844f..ea1d89b3 100644 --- a/app/src/main/java/com/m57/hermescontrol/theme/Theme.kt +++ b/app/src/main/java/com/m57/hermescontrol/theme/Theme.kt @@ -1,12 +1,16 @@ package com.m57.hermescontrol.theme +import android.os.Build import androidx.compose.foundation.isSystemInDarkTheme import androidx.compose.material3.MaterialTheme import androidx.compose.material3.darkColorScheme +import androidx.compose.material3.dynamicDarkColorScheme +import androidx.compose.material3.dynamicLightColorScheme import androidx.compose.material3.lightColorScheme import androidx.compose.runtime.Composable import androidx.compose.runtime.compositionLocalOf import androidx.compose.ui.graphics.Color +import androidx.compose.ui.platform.LocalContext enum class ThemePreference { SYSTEM, LIGHT, DARK } @@ -68,7 +72,15 @@ fun HermesControlTheme( ThemePreference.DARK -> true } - val colorScheme = if (darkTheme) HermesDarkColorScheme else HermesLightColorScheme + val context = LocalContext.current + val dynamicColor = Build.VERSION.SDK_INT >= Build.VERSION_CODES.S + val colorScheme = + when { + dynamicColor && darkTheme -> dynamicDarkColorScheme(context) + dynamicColor && !darkTheme -> dynamicLightColorScheme(context) + darkTheme -> HermesDarkColorScheme + else -> HermesLightColorScheme + } MaterialTheme( colorScheme = colorScheme, diff --git a/app/src/main/java/com/m57/hermescontrol/ui/chat/ChatScreen.kt b/app/src/main/java/com/m57/hermescontrol/ui/chat/ChatScreen.kt index 775ce7ac..c3023b03 100644 --- a/app/src/main/java/com/m57/hermescontrol/ui/chat/ChatScreen.kt +++ b/app/src/main/java/com/m57/hermescontrol/ui/chat/ChatScreen.kt @@ -468,71 +468,146 @@ private fun ChatInputBar( ), elevation = CardDefaults.cardElevation(8.dp), ) { - Row( - modifier = - Modifier - .fillMaxWidth() - .padding(horizontal = 12.dp, vertical = 8.dp), - verticalAlignment = Alignment.Bottom, - ) { - OutlinedTextField( - value = inputText, - onValueChange = onInputChange, + Column { + val commands = + listOf( + "/help", + "/status", + "/sessions", + "/stats", + "/system", + "/new", + "/stop", + "/interrupt", + ) + androidx.compose.animation.AnimatedVisibility( + visible = inputText.startsWith("/") && !inputText.contains(" "), + enter = androidx.compose.animation.fadeIn() + androidx.compose.animation.expandVertically(), + exit = androidx.compose.animation.fadeOut() + androidx.compose.animation.shrinkVertically(), + ) { + val filteredCommands = commands.filter { it.startsWith(inputText, ignoreCase = true) } + if (filteredCommands.isNotEmpty()) { + androidx.compose.material3.Surface( + modifier = + Modifier + .fillMaxWidth() + .padding(horizontal = 12.dp, vertical = 4.dp), + shape = RoundedCornerShape(12.dp), + color = MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.5f), + border = + androidx.compose.foundation.BorderStroke( + 1.dp, + MaterialTheme.colorScheme.outline.copy(alpha = 0.2f), + ), + ) { + LazyColumn( + modifier = Modifier.heightIn(max = 200.dp), + ) { + items(filteredCommands) { cmd -> + val description = + when (cmd) { + "/help" -> "Show help menu" + "/status" -> "Check gateway and platform status" + "/sessions" -> "List all chat sessions" + "/stats" -> "Check system resource usage (stats)" + "/system" -> "Check system resource usage (system)" + "/new" -> "Create a new chat session" + "/stop" -> "Interrupt the active run" + "/interrupt" -> "Interrupt the active run" + else -> "" + } + DropdownMenuItem( + text = { + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.SpaceBetween, + ) { + Text( + cmd, + fontWeight = androidx.compose.ui.text.font.FontWeight.Bold, + color = MaterialTheme.colorScheme.primary, + ) + Text( + description, + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + }, + onClick = { onInputChange(cmd) }, + ) + } + } + } + } + } + + Row( modifier = Modifier - .weight(1f) - .heightIn(max = 140.dp), - placeholder = { - Text( - if (!isConnected) { - "Not connected…" - } else if (isAgentTyping) { - "Waiting for response…" - } else { - "Type a message…" - }, - ) - }, - enabled = isConnected, - maxLines = 5, - shape = RoundedCornerShape(20.dp), - colors = - OutlinedTextFieldDefaults.colors( - focusedBorderColor = MaterialTheme.colorScheme.primary, - unfocusedBorderColor = MaterialTheme.colorScheme.outline.copy(alpha = 0.5f), - cursorColor = MaterialTheme.colorScheme.primary, - ), - ) + .fillMaxWidth() + .padding(horizontal = 12.dp, vertical = 8.dp), + verticalAlignment = Alignment.Bottom, + ) { + OutlinedTextField( + value = inputText, + onValueChange = onInputChange, + modifier = + Modifier + .weight(1f) + .heightIn(max = 140.dp), + placeholder = { + Text( + if (!isConnected) { + "Not connected…" + } else if (isAgentTyping) { + "Waiting for response…" + } else { + "Type a message…" + }, + ) + }, + enabled = isConnected, + maxLines = 5, + shape = RoundedCornerShape(20.dp), + colors = + OutlinedTextFieldDefaults.colors( + focusedBorderColor = MaterialTheme.colorScheme.primary, + unfocusedBorderColor = MaterialTheme.colorScheme.outline.copy(alpha = 0.5f), + cursorColor = MaterialTheme.colorScheme.primary, + ), + ) - Spacer(modifier = Modifier.width(8.dp)) + Spacer(modifier = Modifier.width(8.dp)) - if (isAgentTyping) { - // Interrupt button - FilledTonalButton( - onClick = onInterrupt, - modifier = Modifier.size(48.dp), - shape = CircleShape, - contentPadding = PaddingValues(0.dp), - ) { - Icon( - imageVector = Icons.Filled.Stop, - contentDescription = "Interrupt", - tint = MaterialTheme.colorScheme.error, - ) - } - } else { - // Send button - FilledTonalButton( - onClick = onSend, - enabled = canSend, - modifier = Modifier.size(48.dp), - shape = CircleShape, - contentPadding = PaddingValues(0.dp), - ) { - Icon( - imageVector = Icons.AutoMirrored.Filled.Send, - contentDescription = "Send", - ) + if (isAgentTyping) { + // Interrupt button + FilledTonalButton( + onClick = onInterrupt, + modifier = Modifier.size(48.dp), + shape = CircleShape, + contentPadding = PaddingValues(0.dp), + ) { + Icon( + imageVector = Icons.Filled.Stop, + contentDescription = "Interrupt", + tint = MaterialTheme.colorScheme.error, + ) + } + } else { + // Send button + FilledTonalButton( + onClick = onSend, + enabled = canSend, + modifier = Modifier.size(48.dp), + shape = CircleShape, + contentPadding = PaddingValues(0.dp), + ) { + Icon( + imageVector = Icons.AutoMirrored.Filled.Send, + contentDescription = "Send", + ) + } } } } diff --git a/app/src/main/java/com/m57/hermescontrol/ui/model/ModelScreen.kt b/app/src/main/java/com/m57/hermescontrol/ui/model/ModelScreen.kt index 308d1d5a..48c79392 100644 --- a/app/src/main/java/com/m57/hermescontrol/ui/model/ModelScreen.kt +++ b/app/src/main/java/com/m57/hermescontrol/ui/model/ModelScreen.kt @@ -2,6 +2,7 @@ package com.m57.hermescontrol.ui.model import android.widget.Toast import androidx.compose.animation.AnimatedVisibility +import androidx.compose.foundation.BorderStroke import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column @@ -15,6 +16,7 @@ import androidx.compose.foundation.layout.padding import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.items import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.Check import androidx.compose.material.icons.filled.Menu import androidx.compose.material.icons.filled.Refresh import androidx.compose.material3.Card @@ -196,11 +198,68 @@ fun ModelScreen( ) } else { models.forEach { model -> - Text( - text = "• $model", - style = MaterialTheme.typography.bodyMedium, - modifier = Modifier.padding(vertical = 4.dp), - ) + val isActive = + state.activeProfile?.provider == provider.slug && + state.activeProfile?.model == model + Card( + onClick = { + viewModel.selectModel(provider.slug, model) + }, + modifier = + Modifier + .fillMaxWidth() + .padding(vertical = 4.dp), + colors = + CardDefaults.cardColors( + containerColor = + if (isActive) { + MaterialTheme.colorScheme.primaryContainer.copy( + alpha = 0.3f, + ) + } else { + MaterialTheme.colorScheme.surface + }, + ), + border = + BorderStroke( + width = 1.dp, + color = + if (isActive) { + MaterialTheme.colorScheme.primary + } else { + MaterialTheme.colorScheme.outline.copy(alpha = 0.2f) + }, + ), + ) { + Row( + modifier = + Modifier + .fillMaxWidth() + .padding(horizontal = 16.dp, vertical = 12.dp), + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically, + ) { + Text( + text = model, + style = MaterialTheme.typography.bodyMedium, + fontWeight = + if (isActive) FontWeight.Bold else FontWeight.Normal, + color = + if (isActive) { + MaterialTheme.colorScheme.onPrimaryContainer + } else { + MaterialTheme.colorScheme.onSurface + }, + ) + if (isActive) { + Icon( + imageVector = Icons.Filled.Check, + contentDescription = "Active Model", + tint = MaterialTheme.colorScheme.primary, + ) + } + } + } } } } diff --git a/app/src/main/java/com/m57/hermescontrol/ui/model/ModelViewModel.kt b/app/src/main/java/com/m57/hermescontrol/ui/model/ModelViewModel.kt index 6509358a..39b60119 100644 --- a/app/src/main/java/com/m57/hermescontrol/ui/model/ModelViewModel.kt +++ b/app/src/main/java/com/m57/hermescontrol/ui/model/ModelViewModel.kt @@ -3,6 +3,8 @@ package com.m57.hermescontrol.ui.model import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope import com.m57.hermescontrol.data.model.ModelProvider +import com.m57.hermescontrol.data.model.ProfileInfo +import com.m57.hermescontrol.data.model.UpdateProfileModelRequest import com.m57.hermescontrol.data.remote.ApiClient import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.flow.MutableStateFlow @@ -15,6 +17,7 @@ import kotlinx.coroutines.withContext data class ModelUiState( val isLoading: Boolean = false, val providers: List = emptyList(), + val activeProfile: ProfileInfo? = null, val errorMessage: String? = null, val toastMessage: String? = null, ) @@ -31,11 +34,30 @@ class ModelViewModel : ViewModel() { withContext(Dispatchers.IO) { ApiClient.hermesApi.getModelOptions() } + val activeProfileNameRes = + withContext(Dispatchers.IO) { + ApiClient.hermesApi.getActiveProfile() + } + val profilesRes = + withContext(Dispatchers.IO) { + ApiClient.hermesApi.getProfiles() + } + if (response.isSuccessful) { + val activeName = + if (activeProfileNameRes.isSuccessful) activeProfileNameRes.body()?.active else null + val activeProfile = + if (profilesRes.isSuccessful && activeName != null) { + profilesRes.body()?.profiles?.find { it.name == activeName } + } else { + null + } + _uiState.update { it.copy( isLoading = false, providers = response.body()?.providers.orEmpty(), + activeProfile = activeProfile, ) } } else { @@ -57,6 +79,49 @@ class ModelViewModel : ViewModel() { } } + fun selectModel( + providerSlug: String, + modelName: String, + ) { + viewModelScope.launch { + try { + val activeProfileNameRes = + withContext(Dispatchers.IO) { + ApiClient.hermesApi.getActiveProfile() + } + if (!activeProfileNameRes.isSuccessful) { + _uiState.update { + it.copy( + toastMessage = "Failed to fetch active profile: HTTP ${activeProfileNameRes.code()}", + ) + } + return@launch + } + val activeProfileName = activeProfileNameRes.body()?.active + if (activeProfileName == null) { + _uiState.update { it.copy(toastMessage = "No active profile found") } + return@launch + } + + val updateRes = + withContext(Dispatchers.IO) { + ApiClient.hermesApi.updateProfileModel( + activeProfileName, + UpdateProfileModelRequest(providerSlug, modelName), + ) + } + if (updateRes.isSuccessful) { + _uiState.update { it.copy(toastMessage = "Successfully set model to $modelName") } + loadModelOptions() + } else { + _uiState.update { it.copy(toastMessage = "Failed to set model: HTTP ${updateRes.code()}") } + } + } catch (e: Exception) { + _uiState.update { it.copy(toastMessage = "Failed to set model: ${e.message}") } + } + } + } + fun clearToast() { _uiState.update { it.copy(toastMessage = null) } }