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

@@ -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) }
}