diff --git a/.gitignore b/.gitignore index eefa4a5f..65f8279b 100644 --- a/.gitignore +++ b/.gitignore @@ -27,3 +27,4 @@ ktlint # direnv .direnv/ +hermes-chat-redesign.html diff --git a/app/src/androidTest/java/com/m57/hermescontrol/ui/chat/ChatScreenTest.kt b/app/src/androidTest/java/com/m57/hermescontrol/ui/chat/ChatScreenTest.kt index 81b02d89..03f2d37a 100644 --- a/app/src/androidTest/java/com/m57/hermescontrol/ui/chat/ChatScreenTest.kt +++ b/app/src/androidTest/java/com/m57/hermescontrol/ui/chat/ChatScreenTest.kt @@ -68,9 +68,10 @@ class ChatScreenTest { composeTestRule.onNodeWithTag("mic_button").assertIsDisplayed() composeTestRule.onNodeWithTag("send_button").assertDoesNotExist() - // Verify entering text switches mic button to send button + // Verify entering text shows send button in input row + // Mic stays visible in toolbar row (new two-row layout) composeTestRule.onNodeWithTag("chat_input").performTextInput("Hello Hermes") composeTestRule.onNodeWithTag("send_button").assertIsDisplayed() - composeTestRule.onNodeWithTag("mic_button").assertDoesNotExist() + composeTestRule.onNodeWithTag("mic_button").assertIsDisplayed() } } 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 f2cd3acb..ec9ce0ba 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 @@ -536,6 +536,11 @@ fun ChatScreen( onImageTap = { filePickerLauncher.launch("image/*") }, onFileTap = { filePickerLauncher.launch("*/*") }, onRemoveAttachment = viewModel::removeAttachment, + // Composer toolbar wiring (PR 1) + currentSessionModel = state.currentSessionModel, + reasoningLevel = state.reasoningLevel, + onModelTap = { viewModel.openModelPicker() }, + onReasoningTap = { level -> viewModel.setReasoningLevel(level) }, ) } diff --git a/app/src/main/java/com/m57/hermescontrol/ui/chat/ChatViewModel.kt b/app/src/main/java/com/m57/hermescontrol/ui/chat/ChatViewModel.kt index d780acec..bc8e8ea0 100644 --- a/app/src/main/java/com/m57/hermescontrol/ui/chat/ChatViewModel.kt +++ b/app/src/main/java/com/m57/hermescontrol/ui/chat/ChatViewModel.kt @@ -90,6 +90,8 @@ data class ChatUiState( val modelPickerLoading: Boolean = false, // Current session's active model label (provider/model), shown in the chip val currentSessionModel: String? = null, + // Reasoning effort level for the current session + val reasoningLevel: String? = null, // Attachment state val pendingAttachments: List = emptyList(), // Reaction animation — set when a reaction WS event arrives, auto-clears @@ -231,7 +233,12 @@ class ChatViewModel( connectWebSocket(setLoading = false) viewModelScope.launch { wsClient.events.collect { event -> - handleWsEvent(event) + try { + handleWsEvent(event) + } catch (e: Exception) { + android.util.Log.e("ChatVM", "Uncaught in event loop", e) + _uiState.update { it.copy(isLoading = false) } + } } } // B7 (Jun 30 2026, kanban t_connection_loading): clear loading state on connection failure or status change @@ -376,6 +383,33 @@ class ChatViewModel( handleGatewayReady() } + is WsEvent.SessionInfo -> { + // Session info pushed by backend when config changes + // (model switch, reasoning level, etc.) + val info = event.data + if (info != null) { + val model = info["model"] as? String + val provider = info["provider"] as? String + val reasoningEffort = info["reasoning_effort"] as? String + _uiState.update { state -> + state.copy( + currentSessionModel = + if (model != null && provider != null) { + "$provider/$model" + } else { + model ?: state.currentSessionModel + }, + reasoningLevel = + if (reasoningEffort.isNullOrEmpty()) { + null + } else { + reasoningEffort + }, + ) + } + } + } + is WsEvent.MessageToken -> { streamingController.handleMessageToken(event) } @@ -559,6 +593,12 @@ class ChatViewModel( (resultMap?.get("resumed") as? String) ?: _uiState.value.currentSessionId + // Parse session info from backend — model, provider, reasoning_effort + val infoMap = resultMap?.get("info") as? Map + val model = infoMap?.get("model") as? String + val provider = infoMap?.get("provider") as? String + val reasoningEffort = infoMap?.get("reasoning_effort") as? String + // B8 (Jun 20 2026, kanban t_session_resume): do NOT reload // cached messages here — switchSession() already did so before // the WS round-trip. Calling loadCachedMessages() here would @@ -568,6 +608,18 @@ class ChatViewModel( it.copy( isLoading = false, currentSessionId = sessionId, + currentSessionModel = + if (model != null && provider != null) { + "$provider/$model" + } else { + model ?: it.currentSessionModel + }, + reasoningLevel = + if (reasoningEffort.isNullOrEmpty()) { + null + } else { + reasoningEffort + }, ) } // Mirror the active runtime session id app-wide (issue #532). @@ -1069,6 +1121,8 @@ class ChatViewModel( } } + private var sessionCreateCounter = 0L + fun createNewSession(setLoading: Boolean = true) { _uiState.update { it.copy( @@ -1086,6 +1140,18 @@ class ChatViewModel( onSent = { id -> trackRequest(id, WsMethods.SESSION_CREATE) }, ) } + // B7 safety timeout: clear loading state if RPC response never arrives + if (setLoading && !isTestEnvironment()) { + val generation = ++sessionCreateCounter + viewModelScope.launch { + delay(10_000L) + // Only clear if no newer session creation has started — prevents a + // stale timeout from wiping the loading flag of a subsequent request. + if (generation == sessionCreateCounter && _uiState.value.isLoading) { + _uiState.update { it.copy(isLoading = false) } + } + } + } } fun loadSessions() { @@ -1244,6 +1310,32 @@ class ChatViewModel( handleSlashCommand("/model $model --provider $provider --session") } + /** + * Set the reasoning effort level for the current session. + * + * Updates the UI optimistically and sends a `config.set` RPC to the + * backend. The level applies per-session via the runtime session ID. + * If [level] is null it resets to the model's default. + * + * @param level One of "low", "medium", "high", or null for default. + */ + fun setReasoningLevel(level: String?) { + _uiState.update { it.copy(reasoningLevel = level) } + val sessionId = runtimeSessionId ?: return + if (level == null) return // null = model default, no need to send WS + viewModelScope.launch(Dispatchers.IO) { + wsClient.send( + WsMethods.CONFIG_SET, + mapOf( + "key" to "reasoning", + "value" to level, + "session_id" to sessionId, + ), + onSent = { id -> trackRequest(id, WsMethods.CONFIG_SET) }, + ) + } + } + fun switchSession(sessionId: String) { if (sessionId == _uiState.value.currentSessionId) return diff --git a/app/src/main/java/com/m57/hermescontrol/ui/chat/components/ChatComposer.kt b/app/src/main/java/com/m57/hermescontrol/ui/chat/components/ChatComposer.kt index cc819ab6..6ce60c3d 100644 --- a/app/src/main/java/com/m57/hermescontrol/ui/chat/components/ChatComposer.kt +++ b/app/src/main/java/com/m57/hermescontrol/ui/chat/components/ChatComposer.kt @@ -28,12 +28,9 @@ import androidx.compose.foundation.lazy.items import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.foundation.text.BasicTextField import androidx.compose.material.icons.Icons -import androidx.compose.material.icons.automirrored.filled.InsertDriveFile import androidx.compose.material.icons.automirrored.filled.Send -import androidx.compose.material.icons.filled.AttachFile import androidx.compose.material.icons.filled.Close -import androidx.compose.material.icons.filled.Mic -import androidx.compose.material.icons.filled.Stop +import androidx.compose.material.icons.filled.InsertDriveFile import androidx.compose.material3.DropdownMenu import androidx.compose.material3.DropdownMenuItem import androidx.compose.material3.Icon @@ -68,8 +65,8 @@ import com.m57.hermescontrol.data.ws.CommandCatalog import com.m57.hermescontrol.ui.chat.ChatInputPolicy /** - * The chat input bar with attach menu, slash-command dropdown, - * attachment preview chips, and mic/send/stop button swapping. + * The chat input bar with a two-row layout: input+send on top, + * and a toolbar with attach/model chip/reasoning chip/mic below. */ @Composable fun ChatInputBar( @@ -86,6 +83,11 @@ fun ChatInputBar( onImageTap: () -> Unit = {}, onFileTap: () -> Unit = {}, onRemoveAttachment: (Int) -> Unit = {}, + // NEW: composer toolbar wiring + currentSessionModel: String? = null, + reasoningLevel: String? = null, + onModelTap: () -> Unit = {}, + onReasoningTap: (String?) -> Unit = {}, ) { // Allow sending while the agent is mid-turn or awaiting approval: the // gateway's prompt.submit busy-input policy queues it as the next turn @@ -135,7 +137,7 @@ fun ChatInputBar( shape = RoundedCornerShape(12.dp), color = MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.5f), border = - androidx.compose.foundation.BorderStroke( + BorderStroke( 1.dp, MaterialTheme.colorScheme.outline.copy(alpha = 0.2f), ), @@ -182,6 +184,7 @@ fun ChatInputBar( } } + // ── TOP ROW: Input field with embedded send button ── Row( modifier = Modifier @@ -189,66 +192,6 @@ fun ChatInputBar( .padding(horizontal = 8.dp, vertical = 2.dp), verticalAlignment = Alignment.CenterVertically, ) { - Box { - // Single attach button that opens a dropdown menu - IconButton( - onClick = { showAttachmentMenu = true }, - enabled = isConnected, - modifier = Modifier.size(36.dp), - ) { - Icon( - imageVector = Icons.Default.AttachFile, - contentDescription = stringResource(R.string.chat_attach_desc), - tint = MaterialTheme.colorScheme.onSurfaceVariant, - ) - } - - DropdownMenu( - expanded = showAttachmentMenu, - onDismissRequest = { showAttachmentMenu = false }, - ) { - DropdownMenuItem( - text = { Text("Camera") }, - onClick = { - showAttachmentMenu = false - onCameraTap() - }, - leadingIcon = { - Text( - text = "📷", - fontSize = 18.sp, - ) - }, - ) - DropdownMenuItem( - text = { Text("Image") }, - onClick = { - showAttachmentMenu = false - onImageTap() - }, - leadingIcon = { - Text( - text = "🖼️", - fontSize = 18.sp, - ) - }, - ) - DropdownMenuItem( - text = { Text("File") }, - onClick = { - showAttachmentMenu = false - onFileTap() - }, - leadingIcon = { - Text( - text = "📄", - fontSize = 18.sp, - ) - }, - ) - } - } - val placeholderText = when { !isConnected -> { @@ -275,7 +218,7 @@ fun ChatInputBar( Modifier .weight(1f) .heightIn(min = 42.dp, max = 120.dp) - .padding(horizontal = 4.dp, vertical = 4.dp) + .padding(vertical = 4.dp) .onFocusChanged { isFocused = it.isFocused } .testTag("chat_input"), enabled = isConnected, @@ -302,98 +245,114 @@ fun ChatInputBar( color = MaterialTheme.colorScheme.surface, modifier = Modifier.fillMaxWidth(), ) { - Box( + Row( modifier = Modifier - .padding(horizontal = 12.dp, vertical = 9.dp) + .padding(start = 12.dp, end = 4.dp, top = 9.dp, bottom = 9.dp) .fillMaxWidth(), - contentAlignment = Alignment.CenterStart, + verticalAlignment = Alignment.CenterVertically, ) { - if (inputFieldValue.text.isEmpty()) { - Text( - text = placeholderText, - style = MaterialTheme.typography.bodyMedium, - color = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.6f), - ) + Box(modifier = Modifier.weight(1f)) { + if (inputFieldValue.text.isEmpty()) { + Text( + text = placeholderText, + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.6f), + ) + } + innerTextField() + } + + // Send button INSIDE the field + AnimatedContent( + targetState = canSend && inputFieldValue.text.isNotBlank(), + transitionSpec = { + (scaleIn(initialScale = 0.8f) + fadeIn()) + .togetherWith(scaleOut(targetScale = 0.8f) + fadeOut()) + }, + label = "send_toggle", + ) { showSend -> + if (showSend) { + IconButton( + onClick = onSend, + enabled = canSend, + colors = IconButtonDefaults.filledTonalIconButtonColors(), + modifier = + Modifier + .size(36.dp) + .testTag("send_button"), + ) { + Icon( + imageVector = Icons.AutoMirrored.Filled.Send, + contentDescription = stringResource(R.string.chat_send_desc), + ) + } + } } - innerTextField() } } }, ) + } - Spacer(modifier = Modifier.width(4.dp)) + // ── BOTTOM ROW: Toolbar ── + ComposerToolbar( + isConnected = isConnected, + currentSessionModel = currentSessionModel, + reasoningLevel = reasoningLevel, + isListening = isListening, + onAttachTap = { showAttachmentMenu = true }, + onModelTap = onModelTap, + onReasoningSelected = onReasoningTap, + onMicTap = onMicTap, + modifier = Modifier.testTag("chat_composer_toolbar"), + ) - // Mic / Stop / Send button — swaps based on input state - AnimatedContent( - targetState = - when { - isListening -> "listening" - inputFieldValue.text.isBlank() && pendingAttachments.isEmpty() -> "mic" - else -> "send" - }, - transitionSpec = { - (scaleIn(initialScale = 0.8f) + fadeIn()) - .togetherWith(scaleOut(targetScale = 0.8f) + fadeOut()) + // Attachment dropdown (anchored to the attach button in ComposerToolbar) + // Shown overlaid at the toolbar level + DropdownMenu( + expanded = showAttachmentMenu, + onDismissRequest = { showAttachmentMenu = false }, + ) { + DropdownMenuItem( + text = { Text("Camera") }, + onClick = { + showAttachmentMenu = false + onCameraTap() }, - label = "mic_send_toggle", - ) { buttonState -> - when (buttonState) { - "mic" -> { - IconButton( - onClick = onMicTap, - enabled = isConnected, - colors = IconButtonDefaults.filledTonalIconButtonColors(), - modifier = - Modifier - .size(36.dp) - .testTag("mic_button"), - ) { - Icon( - imageVector = Icons.Default.Mic, - contentDescription = stringResource(R.string.chat_mic_desc), - ) - } - } - - "listening" -> { - IconButton( - onClick = onMicTap, - modifier = - Modifier - .size(36.dp) - .testTag("mic_stop_button"), - colors = - IconButtonDefaults.filledTonalIconButtonColors( - containerColor = MaterialTheme.colorScheme.error, - contentColor = MaterialTheme.colorScheme.onError, - ), - ) { - Icon( - imageVector = Icons.Default.Stop, - contentDescription = "Stop listening", - ) - } - } - - else -> { - IconButton( - onClick = onSend, - enabled = canSend, - colors = IconButtonDefaults.filledTonalIconButtonColors(), - modifier = - Modifier - .size(36.dp) - .testTag("send_button"), - ) { - Icon( - imageVector = Icons.AutoMirrored.Filled.Send, - contentDescription = stringResource(R.string.chat_send_desc), - ) - } - } - } - } + leadingIcon = { + Text( + text = "📷", + fontSize = 18.sp, + ) + }, + ) + DropdownMenuItem( + text = { Text("Image") }, + onClick = { + showAttachmentMenu = false + onImageTap() + }, + leadingIcon = { + Text( + text = "🖼️", + fontSize = 18.sp, + ) + }, + ) + DropdownMenuItem( + text = { Text("File") }, + onClick = { + showAttachmentMenu = false + onFileTap() + }, + leadingIcon = { + Text( + text = "📄", + fontSize = 18.sp, + ) + }, + ) } } } @@ -401,38 +360,41 @@ fun ChatInputBar( } /** - * Compact chip showing a pending attachment with a remove button. + * Reusable attachment chip composable for showing a pending attachment + * with a remove button. */ @Composable fun AttachmentChip( attachment: Attachment, onRemove: () -> Unit, + modifier: Modifier = Modifier, ) { + val thumbnail = attachment.uri Surface( - shape = RoundedCornerShape(16.dp), - color = MaterialTheme.colorScheme.surfaceVariant, - border = BorderStroke(1.dp, MaterialTheme.colorScheme.outline.copy(alpha = 0.3f)), + modifier = modifier, + shape = RoundedCornerShape(8.dp), + tonalElevation = 2.dp, ) { Row( - modifier = Modifier.padding(start = 10.dp, end = 6.dp, top = 4.dp, bottom = 4.dp), + modifier = Modifier.padding(4.dp), verticalAlignment = Alignment.CenterVertically, ) { if (attachment.isImage) { AsyncImage( - model = attachment.uri, + model = thumbnail, contentDescription = attachment.name, modifier = Modifier - .size(20.dp) + .size(24.dp) .clip(RoundedCornerShape(4.dp)), contentScale = ContentScale.Crop, ) Spacer(modifier = Modifier.width(4.dp)) } else { Icon( - imageVector = Icons.AutoMirrored.Filled.InsertDriveFile, - contentDescription = null, - modifier = Modifier.size(16.dp), + imageVector = Icons.Default.InsertDriveFile, + contentDescription = attachment.name, + modifier = Modifier.size(24.dp), tint = MaterialTheme.colorScheme.onSurfaceVariant, ) Spacer(modifier = Modifier.width(4.dp)) @@ -445,15 +407,11 @@ fun AttachmentChip( modifier = Modifier.widthIn(max = 120.dp), ) Spacer(modifier = Modifier.width(4.dp)) - IconButton( - onClick = onRemove, - modifier = Modifier.size(18.dp), - ) { + IconButton(onClick = onRemove, modifier = Modifier.size(18.dp)) { Icon( imageVector = Icons.Default.Close, contentDescription = stringResource(R.string.chat_attach_remove_desc), modifier = Modifier.size(14.dp), - tint = MaterialTheme.colorScheme.onSurfaceVariant, ) } } diff --git a/app/src/main/java/com/m57/hermescontrol/ui/chat/components/ComposerToolbar.kt b/app/src/main/java/com/m57/hermescontrol/ui/chat/components/ComposerToolbar.kt new file mode 100644 index 00000000..5ff2c5c6 --- /dev/null +++ b/app/src/main/java/com/m57/hermescontrol/ui/chat/components/ComposerToolbar.kt @@ -0,0 +1,207 @@ +package com.m57.hermescontrol.ui.chat.components + +import androidx.compose.foundation.horizontalScroll +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.rememberScrollState +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.AttachFile +import androidx.compose.material.icons.filled.Mic +import androidx.compose.material.icons.filled.Stop +import androidx.compose.material3.DropdownMenu +import androidx.compose.material3.DropdownMenuItem +import androidx.compose.material3.FilterChip +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.IconButtonDefaults +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.testTag +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.unit.dp + +/** + * Bottom toolbar row for the chat composer. + * + * Layout: [📎 attach] [model chip] [reasoning chip] [←spacer→] [🎙 mic] + * + * The reasoning chip opens a dropdown menu to pick a level (instead of cycling). + */ +@Composable +fun ComposerToolbar( + isConnected: Boolean, + currentSessionModel: String?, + reasoningLevel: String?, + isListening: Boolean, + onAttachTap: () -> Unit, + onModelTap: () -> Unit, + onReasoningSelected: (String?) -> Unit, + onMicTap: () -> Unit, + modifier: Modifier = Modifier, +) { + var showReasoningMenu by remember { mutableStateOf(false) } + + Row( + modifier = + modifier + .fillMaxWidth() + .padding(horizontal = 8.dp, vertical = 4.dp) + .testTag("composer_toolbar"), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(4.dp), + ) { + // Attach button + IconButton( + onClick = onAttachTap, + enabled = isConnected, + modifier = Modifier.size(36.dp), + ) { + Icon( + imageVector = Icons.Default.AttachFile, + contentDescription = "Attach file", + tint = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + + // Model chip — fixed width, text scrolls if too long + FilterChip( + selected = currentSessionModel != null, + onClick = onModelTap, + label = { + Row( + modifier = Modifier.horizontalScroll(rememberScrollState()), + ) { + Text( + text = currentSessionModel ?: "Model", + style = MaterialTheme.typography.bodySmall, + maxLines = 1, + ) + } + }, + modifier = + Modifier + .width(120.dp) + .testTag("model_chip"), + ) + + // Reasoning chip with dropdown menu + Box { + FilterChip( + selected = reasoningLevel != null, + onClick = { showReasoningMenu = true }, + label = { + Text( + text = buildReasoningLabel(reasoningLevel), + style = MaterialTheme.typography.bodySmall, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + }, + modifier = Modifier.testTag("reasoning_chip"), + ) + + DropdownMenu( + expanded = showReasoningMenu, + onDismissRequest = { showReasoningMenu = false }, + ) { + val allLevels = + listOf( + "none" to "🧠 None", + "minimal" to "🧠 Minimal", + "low" to "🧠 Low", + "medium" to "🧠 Med", + "high" to "🧠 High", + "xhigh" to "🧠 XHigh", + "max" to "🧠 Max", + "ultra" to "🧠 Ultra", + ) + allLevels.forEach { (level, label) -> + DropdownMenuItem( + text = { + Text( + text = label, + fontWeight = + if (reasoningLevel == level) { + MaterialTheme.typography.bodyMedium.fontWeight + } else { + null + }, + color = + if (reasoningLevel == level) { + MaterialTheme.colorScheme.primary + } else { + MaterialTheme.colorScheme.onSurface + }, + ) + }, + onClick = { + showReasoningMenu = false + onReasoningSelected(level) + }, + ) + } + } + } + + Spacer(modifier = Modifier.weight(1f)) + + // Mic / Stop button + IconButton( + onClick = onMicTap, + enabled = isConnected, + colors = + if (isListening) { + IconButtonDefaults.filledTonalIconButtonColors( + containerColor = MaterialTheme.colorScheme.error, + contentColor = MaterialTheme.colorScheme.onError, + ) + } else { + IconButtonDefaults.filledTonalIconButtonColors() + }, + modifier = + Modifier + .size(36.dp) + .testTag(if (isListening) "mic_stop_button" else "mic_button"), + ) { + Icon( + imageVector = if (isListening) Icons.Default.Stop else Icons.Default.Mic, + contentDescription = if (isListening) "Stop listening" else "Mic", + ) + } + } +} + +/** + * Build a human-readable label from a reasoning effort level. + * + * @param level One of: "none", "minimal", "low", "medium", "high", + * "xhigh", "max", "ultra", or null for model default. + * @return Display string such as "🧠 None", "🧠 Low", "🧠 XHigh", "🧠 Ultra", etc. + */ +private fun buildReasoningLabel(level: String?): String { + return when (level) { + null -> "🧠 Med" + "none" -> "🧠 None" + "minimal" -> "🧠 Minimal" + "low" -> "🧠 Low" + "medium" -> "🧠 Med" + "high" -> "🧠 High" + "xhigh" -> "🧠 XHigh" + "max" -> "🧠 Max" + "ultra" -> "🧠 Ultra" + else -> "🧠 $level" + } +}