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

This commit is contained in:
Hy4ri committed 2026-06-16 18:15:01 +03:00
1 parent c06e8417db
commit 3830236585
5 files changed
+314 -67

No files matched your search

@@ -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 = {
@@ -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,
@@ -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",
)
}
}
}
}
@@ -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,
)
}
}
}
}
}
}
@@ -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<ModelProvider> = 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) }
}