feat: slash command autocomplete, dynamic theme colors, interactive model selection & drawer icons

This commit is contained in:
Hy4ri
2026-06-16 18:15:01 +03:00
parent c06e8417db
commit 3830236585
5 changed files with 314 additions and 67 deletions
@@ -4,8 +4,26 @@ import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.safeDrawingPadding import androidx.compose.foundation.layout.safeDrawingPadding
import androidx.compose.foundation.rememberScrollState import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.verticalScroll 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.DrawerValue
import androidx.compose.material3.HorizontalDivider import androidx.compose.material3.HorizontalDivider
import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.ModalDrawerSheet import androidx.compose.material3.ModalDrawerSheet
import androidx.compose.material3.ModalNavigationDrawer import androidx.compose.material3.ModalNavigationDrawer
@@ -82,6 +100,7 @@ fun MainNavigation() {
) )
HorizontalDivider() HorizontalDivider()
NavigationDrawerItem( NavigationDrawerItem(
icon = { Icon(Icons.Filled.Chat, contentDescription = null) },
label = { Text("Chat") }, label = { Text("Chat") },
selected = currentScreen == ChatScreen, selected = currentScreen == ChatScreen,
onClick = { onClick = {
@@ -91,6 +110,7 @@ fun MainNavigation() {
modifier = Modifier.padding(horizontal = 12.dp, vertical = 4.dp), modifier = Modifier.padding(horizontal = 12.dp, vertical = 4.dp),
) )
NavigationDrawerItem( NavigationDrawerItem(
icon = { Icon(Icons.Filled.Extension, contentDescription = null) },
label = { Text("Skills") }, label = { Text("Skills") },
selected = currentScreen == SkillsScreen, selected = currentScreen == SkillsScreen,
onClick = { onClick = {
@@ -100,6 +120,7 @@ fun MainNavigation() {
modifier = Modifier.padding(horizontal = 12.dp, vertical = 4.dp), modifier = Modifier.padding(horizontal = 12.dp, vertical = 4.dp),
) )
NavigationDrawerItem( NavigationDrawerItem(
icon = { Icon(Icons.Filled.Schedule, contentDescription = null) },
label = { Text("Cron Jobs") }, label = { Text("Cron Jobs") },
selected = currentScreen == CronJobsScreen, selected = currentScreen == CronJobsScreen,
onClick = { onClick = {
@@ -109,6 +130,7 @@ fun MainNavigation() {
modifier = Modifier.padding(horizontal = 12.dp, vertical = 4.dp), modifier = Modifier.padding(horizontal = 12.dp, vertical = 4.dp),
) )
NavigationDrawerItem( NavigationDrawerItem(
icon = { Icon(Icons.Filled.AccountCircle, contentDescription = null) },
label = { Text("Profiles") }, label = { Text("Profiles") },
selected = currentScreen == ProfilesScreen, selected = currentScreen == ProfilesScreen,
onClick = { onClick = {
@@ -118,6 +140,7 @@ fun MainNavigation() {
modifier = Modifier.padding(horizontal = 12.dp, vertical = 4.dp), modifier = Modifier.padding(horizontal = 12.dp, vertical = 4.dp),
) )
NavigationDrawerItem( NavigationDrawerItem(
icon = { Icon(Icons.Filled.Build, contentDescription = null) },
label = { Text("Toolsets") }, label = { Text("Toolsets") },
selected = currentScreen == ToolsetsScreen, selected = currentScreen == ToolsetsScreen,
onClick = { onClick = {
@@ -127,6 +150,7 @@ fun MainNavigation() {
modifier = Modifier.padding(horizontal = 12.dp, vertical = 4.dp), modifier = Modifier.padding(horizontal = 12.dp, vertical = 4.dp),
) )
NavigationDrawerItem( NavigationDrawerItem(
icon = { Icon(Icons.Filled.Star, contentDescription = null) },
label = { Text("Achievements") }, label = { Text("Achievements") },
selected = currentScreen == AchievementsScreen, selected = currentScreen == AchievementsScreen,
onClick = { onClick = {
@@ -136,6 +160,7 @@ fun MainNavigation() {
modifier = Modifier.padding(horizontal = 12.dp, vertical = 4.dp), modifier = Modifier.padding(horizontal = 12.dp, vertical = 4.dp),
) )
NavigationDrawerItem( NavigationDrawerItem(
icon = { Icon(Icons.Filled.Devices, contentDescription = null) },
label = { Text("Pairing") }, label = { Text("Pairing") },
selected = currentScreen == PairingScreen, selected = currentScreen == PairingScreen,
onClick = { onClick = {
@@ -145,6 +170,7 @@ fun MainNavigation() {
modifier = Modifier.padding(horizontal = 12.dp, vertical = 4.dp), modifier = Modifier.padding(horizontal = 12.dp, vertical = 4.dp),
) )
NavigationDrawerItem( NavigationDrawerItem(
icon = { Icon(Icons.Filled.Code, contentDescription = null) },
label = { Text("Config") }, label = { Text("Config") },
selected = currentScreen == ConfigScreen, selected = currentScreen == ConfigScreen,
onClick = { onClick = {
@@ -154,6 +180,7 @@ fun MainNavigation() {
modifier = Modifier.padding(horizontal = 12.dp, vertical = 4.dp), modifier = Modifier.padding(horizontal = 12.dp, vertical = 4.dp),
) )
NavigationDrawerItem( NavigationDrawerItem(
icon = { Icon(Icons.Filled.Dns, contentDescription = null) },
label = { Text("MCP Servers") }, label = { Text("MCP Servers") },
selected = currentScreen == McpServersScreen, selected = currentScreen == McpServersScreen,
onClick = { onClick = {
@@ -163,6 +190,7 @@ fun MainNavigation() {
modifier = Modifier.padding(horizontal = 12.dp, vertical = 4.dp), modifier = Modifier.padding(horizontal = 12.dp, vertical = 4.dp),
) )
NavigationDrawerItem( NavigationDrawerItem(
icon = { Icon(Icons.Filled.CallSplit, contentDescription = null) },
label = { Text("Webhooks") }, label = { Text("Webhooks") },
selected = currentScreen == WebhooksScreen, selected = currentScreen == WebhooksScreen,
onClick = { onClick = {
@@ -172,6 +200,7 @@ fun MainNavigation() {
modifier = Modifier.padding(horizontal = 12.dp, vertical = 4.dp), modifier = Modifier.padding(horizontal = 12.dp, vertical = 4.dp),
) )
NavigationDrawerItem( NavigationDrawerItem(
icon = { Icon(Icons.Filled.Settings, contentDescription = null) },
label = { Text("Models") }, label = { Text("Models") },
selected = currentScreen == ModelScreen, selected = currentScreen == ModelScreen,
onClick = { onClick = {
@@ -181,6 +210,7 @@ fun MainNavigation() {
modifier = Modifier.padding(horizontal = 12.dp, vertical = 4.dp), modifier = Modifier.padding(horizontal = 12.dp, vertical = 4.dp),
) )
NavigationDrawerItem( NavigationDrawerItem(
icon = { Icon(Icons.Filled.PlayArrow, contentDescription = null) },
label = { Text("Gateway") }, label = { Text("Gateway") },
selected = currentScreen == GatewayScreen, selected = currentScreen == GatewayScreen,
onClick = { onClick = {
@@ -190,6 +220,7 @@ fun MainNavigation() {
modifier = Modifier.padding(horizontal = 12.dp, vertical = 4.dp), modifier = Modifier.padding(horizontal = 12.dp, vertical = 4.dp),
) )
NavigationDrawerItem( NavigationDrawerItem(
icon = { Icon(Icons.Filled.List, contentDescription = null) },
label = { Text("Logs") }, label = { Text("Logs") },
selected = currentScreen == LogsScreen, selected = currentScreen == LogsScreen,
onClick = { onClick = {
@@ -199,6 +230,7 @@ fun MainNavigation() {
modifier = Modifier.padding(horizontal = 12.dp, vertical = 4.dp), modifier = Modifier.padding(horizontal = 12.dp, vertical = 4.dp),
) )
NavigationDrawerItem( NavigationDrawerItem(
icon = { Icon(Icons.Filled.Extension, contentDescription = null) },
label = { Text("Plugins") }, label = { Text("Plugins") },
selected = currentScreen == PluginsScreen, selected = currentScreen == PluginsScreen,
onClick = { onClick = {
@@ -208,6 +240,7 @@ fun MainNavigation() {
modifier = Modifier.padding(horizontal = 12.dp, vertical = 4.dp), modifier = Modifier.padding(horizontal = 12.dp, vertical = 4.dp),
) )
NavigationDrawerItem( NavigationDrawerItem(
icon = { Icon(Icons.Filled.Chat, contentDescription = null) },
label = { Text("Channels") }, label = { Text("Channels") },
selected = currentScreen == ChannelsScreen, selected = currentScreen == ChannelsScreen,
onClick = { onClick = {
@@ -217,6 +250,7 @@ fun MainNavigation() {
modifier = Modifier.padding(horizontal = 12.dp, vertical = 4.dp), modifier = Modifier.padding(horizontal = 12.dp, vertical = 4.dp),
) )
NavigationDrawerItem( NavigationDrawerItem(
icon = { Icon(Icons.Filled.Key, contentDescription = null) },
label = { Text("Keys") }, label = { Text("Keys") },
selected = currentScreen == KeysScreen, selected = currentScreen == KeysScreen,
onClick = { onClick = {
@@ -226,6 +260,7 @@ fun MainNavigation() {
modifier = Modifier.padding(horizontal = 12.dp, vertical = 4.dp), modifier = Modifier.padding(horizontal = 12.dp, vertical = 4.dp),
) )
NavigationDrawerItem( NavigationDrawerItem(
icon = { Icon(Icons.Filled.Info, contentDescription = null) },
label = { Text("System") }, label = { Text("System") },
selected = currentScreen == SystemScreen, selected = currentScreen == SystemScreen,
onClick = { onClick = {
@@ -235,6 +270,7 @@ fun MainNavigation() {
modifier = Modifier.padding(horizontal = 12.dp, vertical = 4.dp), modifier = Modifier.padding(horizontal = 12.dp, vertical = 4.dp),
) )
NavigationDrawerItem( NavigationDrawerItem(
icon = { Icon(Icons.Filled.Dashboard, contentDescription = null) },
label = { Text("Kanban") }, label = { Text("Kanban") },
selected = currentScreen == KanbanScreen, selected = currentScreen == KanbanScreen,
onClick = { onClick = {
@@ -1,12 +1,16 @@
package com.m57.hermescontrol.theme package com.m57.hermescontrol.theme
import android.os.Build
import androidx.compose.foundation.isSystemInDarkTheme import androidx.compose.foundation.isSystemInDarkTheme
import androidx.compose.material3.MaterialTheme import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.darkColorScheme import androidx.compose.material3.darkColorScheme
import androidx.compose.material3.dynamicDarkColorScheme
import androidx.compose.material3.dynamicLightColorScheme
import androidx.compose.material3.lightColorScheme import androidx.compose.material3.lightColorScheme
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.compositionLocalOf import androidx.compose.runtime.compositionLocalOf
import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.Color
import androidx.compose.ui.platform.LocalContext
enum class ThemePreference { SYSTEM, LIGHT, DARK } enum class ThemePreference { SYSTEM, LIGHT, DARK }
@@ -68,7 +72,15 @@ fun HermesControlTheme(
ThemePreference.DARK -> true 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( MaterialTheme(
colorScheme = colorScheme, colorScheme = colorScheme,
@@ -468,6 +468,80 @@ private fun ChatInputBar(
), ),
elevation = CardDefaults.cardElevation(8.dp), elevation = CardDefaults.cardElevation(8.dp),
) { ) {
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( Row(
modifier = modifier =
Modifier Modifier
@@ -539,6 +613,7 @@ private fun ChatInputBar(
} }
} }
} }
}
@Composable @Composable
private fun ClarifyDialog( private fun ClarifyDialog(
@@ -2,6 +2,7 @@ package com.m57.hermescontrol.ui.model
import android.widget.Toast import android.widget.Toast
import androidx.compose.animation.AnimatedVisibility import androidx.compose.animation.AnimatedVisibility
import androidx.compose.foundation.BorderStroke
import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column 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.LazyColumn
import androidx.compose.foundation.lazy.items import androidx.compose.foundation.lazy.items
import androidx.compose.material.icons.Icons 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.Menu
import androidx.compose.material.icons.filled.Refresh import androidx.compose.material.icons.filled.Refresh
import androidx.compose.material3.Card import androidx.compose.material3.Card
@@ -196,10 +198,64 @@ fun ModelScreen(
) )
} else { } else {
models.forEach { model -> models.forEach { model ->
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(
text = "• $model", text = model,
style = MaterialTheme.typography.bodyMedium, style = MaterialTheme.typography.bodyMedium,
modifier = Modifier.padding(vertical = 4.dp), 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,
) )
} }
} }
@@ -213,3 +269,6 @@ fun ModelScreen(
} }
} }
} }
}
}
}
@@ -3,6 +3,8 @@ package com.m57.hermescontrol.ui.model
import androidx.lifecycle.ViewModel import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope import androidx.lifecycle.viewModelScope
import com.m57.hermescontrol.data.model.ModelProvider 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 com.m57.hermescontrol.data.remote.ApiClient
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.MutableStateFlow
@@ -15,6 +17,7 @@ import kotlinx.coroutines.withContext
data class ModelUiState( data class ModelUiState(
val isLoading: Boolean = false, val isLoading: Boolean = false,
val providers: List<ModelProvider> = emptyList(), val providers: List<ModelProvider> = emptyList(),
val activeProfile: ProfileInfo? = null,
val errorMessage: String? = null, val errorMessage: String? = null,
val toastMessage: String? = null, val toastMessage: String? = null,
) )
@@ -31,11 +34,30 @@ class ModelViewModel : ViewModel() {
withContext(Dispatchers.IO) { withContext(Dispatchers.IO) {
ApiClient.hermesApi.getModelOptions() ApiClient.hermesApi.getModelOptions()
} }
val activeProfileNameRes =
withContext(Dispatchers.IO) {
ApiClient.hermesApi.getActiveProfile()
}
val profilesRes =
withContext(Dispatchers.IO) {
ApiClient.hermesApi.getProfiles()
}
if (response.isSuccessful) { 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 { _uiState.update {
it.copy( it.copy(
isLoading = false, isLoading = false,
providers = response.body()?.providers.orEmpty(), providers = response.body()?.providers.orEmpty(),
activeProfile = activeProfile,
) )
} }
} else { } 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() { fun clearToast() {
_uiState.update { it.copy(toastMessage = null) } _uiState.update { it.copy(toastMessage = null) }
} }