diff --git a/mpp-idea/src/main/kotlin/cc/unitmesh/devins/idea/editor/IdeaBottomToolbar.kt b/mpp-idea/src/main/kotlin/cc/unitmesh/devins/idea/editor/IdeaBottomToolbar.kt index 3469e91972..6af9bc6878 100644 --- a/mpp-idea/src/main/kotlin/cc/unitmesh/devins/idea/editor/IdeaBottomToolbar.kt +++ b/mpp-idea/src/main/kotlin/cc/unitmesh/devins/idea/editor/IdeaBottomToolbar.kt @@ -2,20 +2,27 @@ package cc.unitmesh.devins.idea.editor import androidx.compose.foundation.background import androidx.compose.foundation.layout.* +import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.runtime.* import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp +import cc.unitmesh.devins.idea.toolwindow.IdeaComposeIcons import cc.unitmesh.devins.ui.compose.theme.AutoDevColors +import cc.unitmesh.llm.NamedModelConfig import org.jetbrains.jewel.foundation.theme.JewelTheme import org.jetbrains.jewel.ui.component.* +import org.jetbrains.jewel.ui.component.Icon /** * Bottom toolbar for the input section. - * Provides send/stop buttons, @ trigger for agent completion, settings, and token info. - * + * Provides send/stop buttons, @ trigger for agent completion, / command trigger, model selector, settings, and token info. + * + * Layout: Workspace - Token Info - ModelSelector - @ Symbol - / Symbol - Settings - Send Button + * * Uses Jewel components for native IntelliJ IDEA look and feel. */ @Composable @@ -25,9 +32,15 @@ fun IdeaBottomToolbar( isExecuting: Boolean = false, onStopClick: () -> Unit = {}, onAtClick: () -> Unit = {}, + onSlashClick: () -> Unit = {}, onSettingsClick: () -> Unit = {}, workspacePath: String? = null, totalTokens: Int? = null, + // Model selector props + availableConfigs: List = emptyList(), + currentConfigName: String? = null, + onConfigSelect: (NamedModelConfig) -> Unit = {}, + onConfigureClick: () -> Unit = {}, modifier: Modifier = Modifier ) { Row( @@ -53,18 +66,19 @@ fun IdeaBottomToolbar( Box( modifier = Modifier - .background( - JewelTheme.globalColors.panelBackground.copy(alpha = 0.8f) - ) + .clip(RoundedCornerShape(4.dp)) + .background(JewelTheme.globalColors.panelBackground.copy(alpha = 0.8f)) .padding(horizontal = 8.dp, vertical = 4.dp) ) { Row( verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(4.dp) ) { - Text( - text = "📁", - style = JewelTheme.defaultTextStyle.copy(fontSize = 12.sp) + Icon( + imageVector = IdeaComposeIcons.Folder, + contentDescription = null, + tint = JewelTheme.globalColors.text.normal, + modifier = Modifier.size(12.dp) ) Text( text = projectName, @@ -79,6 +93,7 @@ fun IdeaBottomToolbar( if (totalTokens != null && totalTokens > 0) { Box( modifier = Modifier + .clip(RoundedCornerShape(4.dp)) .background(AutoDevColors.Blue.c400.copy(alpha = 0.2f)) .padding(horizontal = 8.dp, vertical = 4.dp) ) { @@ -107,13 +122,34 @@ fun IdeaBottomToolbar( horizontalArrangement = Arrangement.spacedBy(4.dp), verticalAlignment = Alignment.CenterVertically ) { + // Model selector + IdeaModelSelector( + availableConfigs = availableConfigs, + currentConfigName = currentConfigName, + onConfigSelect = onConfigSelect, + onConfigureClick = onConfigureClick + ) + // @ trigger button for agent completion IconButton( onClick = onAtClick, modifier = Modifier.size(32.dp) + ) { + Icon( + imageVector = IdeaComposeIcons.AlternateEmail, + contentDescription = "@ Agent", + tint = JewelTheme.globalColors.text.normal, + modifier = Modifier.size(18.dp) + ) + } + + // / trigger button for slash commands + IconButton( + onClick = onSlashClick, + modifier = Modifier.size(32.dp) ) { Text( - text = "@", + text = "/", style = JewelTheme.defaultTextStyle.copy( fontSize = 16.sp, fontWeight = FontWeight.Bold @@ -126,9 +162,11 @@ fun IdeaBottomToolbar( onClick = onSettingsClick, modifier = Modifier.size(32.dp) ) { - Text( - text = "⚙", - style = JewelTheme.defaultTextStyle.copy(fontSize = 14.sp) + Icon( + imageVector = IdeaComposeIcons.Settings, + contentDescription = "Settings", + tint = JewelTheme.globalColors.text.normal, + modifier = Modifier.size(16.dp) ) } @@ -138,12 +176,23 @@ fun IdeaBottomToolbar( onClick = onStopClick, modifier = Modifier.height(32.dp) ) { - Text( - text = "⏹ Stop", - style = JewelTheme.defaultTextStyle.copy( - color = AutoDevColors.Red.c400 + Row( + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(4.dp) + ) { + Icon( + imageVector = IdeaComposeIcons.Stop, + contentDescription = "Stop", + tint = AutoDevColors.Red.c400, + modifier = Modifier.size(14.dp) ) - ) + Text( + text = "Stop", + style = JewelTheme.defaultTextStyle.copy( + color = AutoDevColors.Red.c400 + ) + ) + } } } else { DefaultButton( @@ -151,10 +200,21 @@ fun IdeaBottomToolbar( enabled = sendEnabled, modifier = Modifier.height(32.dp) ) { - Text( - text = "Send", - style = JewelTheme.defaultTextStyle - ) + Row( + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(4.dp) + ) { + Icon( + imageVector = IdeaComposeIcons.Send, + contentDescription = "Send", + tint = JewelTheme.globalColors.text.normal, + modifier = Modifier.size(14.dp) + ) + Text( + text = "Send", + style = JewelTheme.defaultTextStyle + ) + } } } } diff --git a/mpp-idea/src/main/kotlin/cc/unitmesh/devins/idea/editor/IdeaInputSection.kt b/mpp-idea/src/main/kotlin/cc/unitmesh/devins/idea/editor/IdeaInputSection.kt index 8b20f968a2..856f154cf3 100644 --- a/mpp-idea/src/main/kotlin/cc/unitmesh/devins/idea/editor/IdeaInputSection.kt +++ b/mpp-idea/src/main/kotlin/cc/unitmesh/devins/idea/editor/IdeaInputSection.kt @@ -37,6 +37,7 @@ fun IdeaInputSection( onSend: (String) -> Unit, onStop: () -> Unit = {}, onAtClick: () -> Unit = {}, + onSlashClick: () -> Unit = {}, onSettingsClick: () -> Unit = {}, workspacePath: String? = null, totalTokens: Int? = null, @@ -124,6 +125,13 @@ fun IdeaInputSection( } onAtClick() }, + onSlashClick = { + // Insert / character and trigger slash commands + textFieldState.edit { + append("/") + } + onSlashClick() + }, onSettingsClick = onSettingsClick, workspacePath = workspacePath, totalTokens = totalTokens diff --git a/mpp-idea/src/main/kotlin/cc/unitmesh/devins/idea/editor/IdeaModelConfigDialog.kt b/mpp-idea/src/main/kotlin/cc/unitmesh/devins/idea/editor/IdeaModelConfigDialog.kt new file mode 100644 index 0000000000..d6a2fa535d --- /dev/null +++ b/mpp-idea/src/main/kotlin/cc/unitmesh/devins/idea/editor/IdeaModelConfigDialog.kt @@ -0,0 +1,447 @@ +package cc.unitmesh.devins.idea.editor + +import androidx.compose.foundation.background +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.* +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.foundation.text.input.TextFieldState +import androidx.compose.foundation.text.input.rememberTextFieldState +import androidx.compose.foundation.verticalScroll +import androidx.compose.runtime.* +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.input.key.Key +import androidx.compose.ui.input.key.key +import androidx.compose.ui.input.key.onKeyEvent +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import androidx.compose.ui.window.Dialog +import androidx.compose.ui.window.DialogProperties +import androidx.compose.ui.window.Popup +import androidx.compose.ui.window.PopupProperties +import cc.unitmesh.devins.idea.toolwindow.IdeaComposeIcons +import cc.unitmesh.llm.LLMProviderType +import cc.unitmesh.llm.ModelConfig +import cc.unitmesh.llm.ModelRegistry +import org.jetbrains.jewel.foundation.theme.JewelTheme +import org.jetbrains.jewel.ui.component.* +import org.jetbrains.jewel.ui.component.Icon +import org.jetbrains.jewel.ui.component.Text +import org.jetbrains.jewel.ui.component.TextField + +/** + * Dialog for configuring LLM model settings for IntelliJ IDEA plugin. + * Uses Jewel components for native IntelliJ look and feel. + */ +@Composable +fun IdeaModelConfigDialog( + currentConfig: ModelConfig, + currentConfigName: String? = null, + onDismiss: () -> Unit, + onSave: (configName: String, config: ModelConfig) -> Unit +) { + // Use TextFieldState for Jewel TextField + val configNameState = rememberTextFieldState(currentConfigName ?: "") + var provider by remember { mutableStateOf(currentConfig.provider) } + val modelNameState = rememberTextFieldState(currentConfig.modelName) + val apiKeyState = rememberTextFieldState(currentConfig.apiKey) + val temperatureState = rememberTextFieldState(currentConfig.temperature.toString()) + val maxTokensState = rememberTextFieldState(currentConfig.maxTokens.toString()) + val baseUrlState = rememberTextFieldState(currentConfig.baseUrl) + var showApiKey by remember { mutableStateOf(false) } + var providerExpanded by remember { mutableStateOf(false) } + var modelExpanded by remember { mutableStateOf(false) } + var showAdvanced by remember { mutableStateOf(false) } + + Dialog( + onDismissRequest = onDismiss, + properties = DialogProperties(usePlatformDefaultWidth = false) + ) { + Box( + modifier = Modifier + .width(500.dp) + .heightIn(max = 600.dp) + .clip(RoundedCornerShape(12.dp)) + .background(JewelTheme.globalColors.panelBackground) + .onKeyEvent { event -> + if (event.key == Key.Escape) { + onDismiss() + true + } else false + } + ) { + Column( + modifier = Modifier + .padding(24.dp) + .verticalScroll(rememberScrollState()) + ) { + // Title + Text( + text = "Model Configuration", + style = JewelTheme.defaultTextStyle.copy(fontSize = 18.sp) + ) + + Spacer(modifier = Modifier.height(16.dp)) + + // Config Name + IdeaConfigFormField(label = "Config Name") { + TextField( + state = configNameState, + placeholder = { Text("e.g., my-gpt4") }, + modifier = Modifier.fillMaxWidth() + ) + } + + Spacer(modifier = Modifier.height(12.dp)) + + // Provider Selector + IdeaConfigFormField(label = "Provider") { + IdeaProviderSelector( + provider = provider, + expanded = providerExpanded, + onExpandedChange = { providerExpanded = it }, + onProviderSelect = { selectedProvider -> + provider = selectedProvider + val defaultModels = ModelRegistry.getAvailableModels(selectedProvider) + if (defaultModels.isNotEmpty()) { + modelNameState.edit { replace(0, length, defaultModels[0]) } + } + if (selectedProvider == LLMProviderType.OLLAMA && baseUrlState.text.isEmpty()) { + baseUrlState.edit { replace(0, length, "http://localhost:11434") } + } + providerExpanded = false + } + ) + } + + Spacer(modifier = Modifier.height(12.dp)) + + // Model Name + val availableModels = remember(provider) { ModelRegistry.getAvailableModels(provider) } + IdeaConfigFormField(label = "Model") { + if (availableModels.isNotEmpty()) { + IdeaModelNameSelector( + modelNameState = modelNameState, + availableModels = availableModels, + expanded = modelExpanded, + onExpandedChange = { modelExpanded = it }, + onModelSelect = { selectedModel -> + modelNameState.edit { replace(0, length, selectedModel) } + modelExpanded = false + } + ) + } else { + TextField( + state = modelNameState, + placeholder = { Text("Enter model name") }, + modifier = Modifier.fillMaxWidth() + ) + } + } + + Spacer(modifier = Modifier.height(12.dp)) + + // API Key + IdeaConfigFormField(label = "API Key") { + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.spacedBy(8.dp), + verticalAlignment = Alignment.CenterVertically + ) { + TextField( + state = apiKeyState, + placeholder = { Text("Enter API key") }, + modifier = Modifier.weight(1f) + ) + IconButton( + onClick = { showApiKey = !showApiKey }, + modifier = Modifier.size(32.dp) + ) { + Icon( + imageVector = if (showApiKey) IdeaComposeIcons.VisibilityOff else IdeaComposeIcons.Visibility, + contentDescription = if (showApiKey) "Hide" else "Show", + tint = JewelTheme.globalColors.text.normal, + modifier = Modifier.size(18.dp) + ) + } + } + } + + // Base URL (for certain providers) + val needsBaseUrl = provider in listOf( + LLMProviderType.OLLAMA, LLMProviderType.GLM, LLMProviderType.QWEN, + LLMProviderType.KIMI, LLMProviderType.CUSTOM_OPENAI_BASE + ) + if (needsBaseUrl) { + Spacer(modifier = Modifier.height(12.dp)) + IdeaConfigFormField(label = "Base URL") { + TextField( + state = baseUrlState, + placeholder = { Text("e.g., http://localhost:11434") }, + modifier = Modifier.fillMaxWidth() + ) + } + } + + Spacer(modifier = Modifier.height(16.dp)) + + // Advanced Settings Toggle + Row( + modifier = Modifier + .fillMaxWidth() + .clickable { showAdvanced = !showAdvanced } + .padding(vertical = 8.dp), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(8.dp) + ) { + Icon( + imageVector = if (showAdvanced) IdeaComposeIcons.ExpandLess else IdeaComposeIcons.ExpandMore, + contentDescription = null, + tint = JewelTheme.globalColors.text.normal, + modifier = Modifier.size(20.dp) + ) + Text( + text = "Advanced Settings", + style = JewelTheme.defaultTextStyle.copy(fontSize = 14.sp) + ) + } + + if (showAdvanced) { + Spacer(modifier = Modifier.height(8.dp)) + + // Temperature + IdeaConfigFormField(label = "Temperature") { + TextField( + state = temperatureState, + placeholder = { Text("0.0 - 2.0") }, + modifier = Modifier.fillMaxWidth() + ) + } + + Spacer(modifier = Modifier.height(12.dp)) + + // Max Tokens + IdeaConfigFormField(label = "Max Tokens") { + TextField( + state = maxTokensState, + placeholder = { Text("e.g., 128000") }, + modifier = Modifier.fillMaxWidth() + ) + } + } + + Spacer(modifier = Modifier.height(24.dp)) + + // Action Buttons + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.End, + verticalAlignment = Alignment.CenterVertically + ) { + OutlinedButton(onClick = onDismiss) { + Text("Cancel") + } + Spacer(modifier = Modifier.width(12.dp)) + DefaultButton( + onClick = { + val modelName = modelNameState.text.toString() + val apiKey = apiKeyState.text.toString() + val config = ModelConfig( + provider = provider, + modelName = modelName, + apiKey = apiKey, + temperature = temperatureState.text.toString().toDoubleOrNull() ?: 0.0, + maxTokens = maxTokensState.text.toString().toIntOrNull() ?: 128000, + baseUrl = baseUrlState.text.toString() + ) + val configName = configNameState.text.toString() + val name = configName.ifEmpty { "${provider.name.lowercase()}-${modelName}" } + onSave(name, config) + }, + enabled = modelNameState.text.isNotEmpty() && apiKeyState.text.isNotEmpty() + ) { + Text("Save") + } + } + } + } + } +} + +/** + * Form field wrapper with label + */ +@Composable +private fun IdeaConfigFormField( + label: String, + content: @Composable () -> Unit +) { + Column(modifier = Modifier.fillMaxWidth()) { + Text( + text = label, + style = JewelTheme.defaultTextStyle.copy(fontSize = 13.sp), + modifier = Modifier.padding(bottom = 4.dp) + ) + content() + } +} + +/** + * Provider selector dropdown + */ +@Composable +private fun IdeaProviderSelector( + provider: LLMProviderType, + expanded: Boolean, + onExpandedChange: (Boolean) -> Unit, + onProviderSelect: (LLMProviderType) -> Unit +) { + Box { + OutlinedButton( + onClick = { onExpandedChange(true) }, + modifier = Modifier.fillMaxWidth() + ) { + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically + ) { + Text(provider.name) + Icon( + imageVector = IdeaComposeIcons.ArrowDropDown, + contentDescription = null, + tint = JewelTheme.globalColors.text.normal, + modifier = Modifier.size(16.dp) + ) + } + } + + if (expanded) { + Popup( + onDismissRequest = { onExpandedChange(false) }, + properties = PopupProperties(focusable = true) + ) { + Box( + modifier = Modifier + .widthIn(min = 200.dp, max = 300.dp) + .heightIn(max = 300.dp) + .clip(RoundedCornerShape(8.dp)) + .background(JewelTheme.globalColors.panelBackground) + .padding(4.dp) + ) { + Column(modifier = Modifier.verticalScroll(rememberScrollState())) { + LLMProviderType.entries.forEach { providerType -> + Row( + modifier = Modifier + .fillMaxWidth() + .clip(RoundedCornerShape(4.dp)) + .clickable { onProviderSelect(providerType) } + .padding(horizontal = 12.dp, vertical = 8.dp), + verticalAlignment = Alignment.CenterVertically + ) { + Text( + text = providerType.name, + style = JewelTheme.defaultTextStyle.copy(fontSize = 13.sp), + modifier = Modifier.weight(1f) + ) + if (providerType == provider) { + Icon( + imageVector = IdeaComposeIcons.Check, + contentDescription = "Selected", + tint = JewelTheme.globalColors.text.normal, + modifier = Modifier.size(16.dp) + ) + } + } + } + } + } + } + } + } +} + +/** + * Model name selector with dropdown for known models + */ +@Composable +private fun IdeaModelNameSelector( + modelNameState: TextFieldState, + availableModels: List, + expanded: Boolean, + onExpandedChange: (Boolean) -> Unit, + onModelSelect: (String) -> Unit +) { + val modelName = modelNameState.text.toString() + + Column { + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.spacedBy(8.dp), + verticalAlignment = Alignment.CenterVertically + ) { + TextField( + state = modelNameState, + placeholder = { Text("Enter or select model") }, + modifier = Modifier.weight(1f) + ) + IconButton( + onClick = { onExpandedChange(!expanded) }, + modifier = Modifier.size(32.dp) + ) { + Icon( + imageVector = IdeaComposeIcons.ArrowDropDown, + contentDescription = "Select model", + tint = JewelTheme.globalColors.text.normal, + modifier = Modifier.size(18.dp) + ) + } + } + + if (expanded) { + Popup( + onDismissRequest = { onExpandedChange(false) }, + properties = PopupProperties(focusable = true) + ) { + Box( + modifier = Modifier + .widthIn(min = 200.dp, max = 400.dp) + .heightIn(max = 250.dp) + .clip(RoundedCornerShape(8.dp)) + .background(JewelTheme.globalColors.panelBackground) + .padding(4.dp) + ) { + Column(modifier = Modifier.verticalScroll(rememberScrollState())) { + availableModels.forEach { model -> + Row( + modifier = Modifier + .fillMaxWidth() + .clip(RoundedCornerShape(4.dp)) + .clickable { onModelSelect(model) } + .padding(horizontal = 12.dp, vertical = 8.dp), + verticalAlignment = Alignment.CenterVertically + ) { + Text( + text = model, + style = JewelTheme.defaultTextStyle.copy(fontSize = 13.sp), + modifier = Modifier.weight(1f) + ) + if (model == modelName) { + Icon( + imageVector = IdeaComposeIcons.Check, + contentDescription = "Selected", + tint = JewelTheme.globalColors.text.normal, + modifier = Modifier.size(16.dp) + ) + } + } + } + } + } + } + } + } +} + diff --git a/mpp-idea/src/main/kotlin/cc/unitmesh/devins/idea/editor/IdeaModelSelector.kt b/mpp-idea/src/main/kotlin/cc/unitmesh/devins/idea/editor/IdeaModelSelector.kt new file mode 100644 index 0000000000..bd6f778d74 --- /dev/null +++ b/mpp-idea/src/main/kotlin/cc/unitmesh/devins/idea/editor/IdeaModelSelector.kt @@ -0,0 +1,193 @@ +package cc.unitmesh.devins.idea.editor + +import androidx.compose.foundation.background +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.* +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.foundation.verticalScroll +import androidx.compose.runtime.* +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import androidx.compose.ui.window.Popup +import androidx.compose.ui.window.PopupProperties +import cc.unitmesh.devins.idea.toolwindow.IdeaComposeIcons +import cc.unitmesh.llm.ModelConfig +import cc.unitmesh.llm.NamedModelConfig +import org.jetbrains.jewel.foundation.theme.JewelTheme +import org.jetbrains.jewel.ui.component.Divider +import org.jetbrains.jewel.ui.component.Icon +import org.jetbrains.jewel.ui.component.OutlinedButton +import org.jetbrains.jewel.ui.component.Text +import org.jetbrains.jewel.ui.Orientation + +/** + * Model selector for IntelliJ IDEA plugin. + * Provides a dropdown for selecting LLM models with a configure option. + * + * Uses Jewel components for native IntelliJ IDEA look and feel. + */ +@Composable +fun IdeaModelSelector( + availableConfigs: List, + currentConfigName: String?, + onConfigSelect: (NamedModelConfig) -> Unit, + onConfigureClick: () -> Unit, + modifier: Modifier = Modifier +) { + var expanded by remember { mutableStateOf(false) } + + val currentConfig = remember(currentConfigName, availableConfigs) { + availableConfigs.find { it.name == currentConfigName } + } + + val displayText = remember(currentConfig) { + currentConfig?.model ?: "Configure Model" + } + + Box(modifier = modifier) { + // Main selector button + OutlinedButton( + onClick = { expanded = true }, + modifier = Modifier.height(32.dp) + ) { + Row( + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(4.dp) + ) { + Text( + text = displayText, + style = JewelTheme.defaultTextStyle.copy(fontSize = 12.sp), + maxLines = 1 + ) + Icon( + imageVector = IdeaComposeIcons.ArrowDropDown, + contentDescription = null, + tint = JewelTheme.globalColors.text.normal, + modifier = Modifier.size(16.dp) + ) + } + } + + // Dropdown popup + if (expanded) { + Popup( + onDismissRequest = { expanded = false }, + properties = PopupProperties(focusable = true) + ) { + Box( + modifier = Modifier + .widthIn(min = 200.dp, max = 300.dp) + .clip(RoundedCornerShape(8.dp)) + .background(JewelTheme.globalColors.panelBackground) + .padding(4.dp) + ) { + Column( + modifier = Modifier.verticalScroll(rememberScrollState()) + ) { + if (availableConfigs.isNotEmpty()) { + availableConfigs.forEach { config -> + IdeaDropdownMenuItem( + text = "${config.provider} / ${config.model}", + isSelected = config.name == currentConfigName, + onClick = { + onConfigSelect(config) + expanded = false + } + ) + } + + Divider(Orientation.Horizontal, modifier = Modifier.fillMaxWidth().padding(vertical = 4.dp)) + } else { + IdeaDropdownMenuItem( + text = "No saved configs", + isSelected = false, + enabled = false, + onClick = {} + ) + + Divider(Orientation.Horizontal, modifier = Modifier.fillMaxWidth().padding(vertical = 4.dp)) + } + + // Configure button + IdeaDropdownMenuItem( + text = "Configure Model...", + isSelected = false, + leadingIcon = IdeaComposeIcons.Settings, + onClick = { + onConfigureClick() + expanded = false + } + ) + } + } + } + } + } +} + +/** + * Individual menu item for IdeaModelSelector dropdown. + */ +@Composable +private fun IdeaDropdownMenuItem( + text: String, + isSelected: Boolean, + onClick: () -> Unit, + modifier: Modifier = Modifier, + enabled: Boolean = true, + leadingIcon: androidx.compose.ui.graphics.vector.ImageVector? = null +) { + val backgroundColor = when { + !enabled -> JewelTheme.globalColors.panelBackground + isSelected -> JewelTheme.globalColors.panelBackground.copy(alpha = 0.6f) + else -> JewelTheme.globalColors.panelBackground + } + + val textColor = when { + !enabled -> JewelTheme.globalColors.text.normal.copy(alpha = 0.5f) + else -> JewelTheme.globalColors.text.normal + } + + Row( + modifier = modifier + .fillMaxWidth() + .clip(RoundedCornerShape(4.dp)) + .background(backgroundColor) + .then(if (enabled) Modifier.clickable(onClick = onClick) else Modifier) + .padding(horizontal = 12.dp, vertical = 8.dp), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(8.dp) + ) { + if (leadingIcon != null) { + Icon( + imageVector = leadingIcon, + contentDescription = null, + tint = textColor, + modifier = Modifier.size(16.dp) + ) + } + + Text( + text = text, + style = JewelTheme.defaultTextStyle.copy( + fontSize = 13.sp, + color = textColor + ), + modifier = Modifier.weight(1f) + ) + + if (isSelected) { + Icon( + imageVector = IdeaComposeIcons.Check, + contentDescription = "Selected", + tint = JewelTheme.globalColors.text.normal, + modifier = Modifier.size(16.dp) + ) + } + } +} + diff --git a/mpp-idea/src/main/kotlin/cc/unitmesh/devins/idea/toolwindow/IdeaAgentApp.kt b/mpp-idea/src/main/kotlin/cc/unitmesh/devins/idea/toolwindow/IdeaAgentApp.kt index c62b6058f0..576f997d10 100644 --- a/mpp-idea/src/main/kotlin/cc/unitmesh/devins/idea/toolwindow/IdeaAgentApp.kt +++ b/mpp-idea/src/main/kotlin/cc/unitmesh/devins/idea/toolwindow/IdeaAgentApp.kt @@ -12,6 +12,7 @@ import cc.unitmesh.devins.idea.editor.IdeaBottomToolbar import cc.unitmesh.devins.idea.editor.IdeaDevInInput import cc.unitmesh.devins.idea.editor.IdeaInputListener import cc.unitmesh.devins.idea.editor.IdeaInputTrigger +import cc.unitmesh.devins.idea.editor.IdeaModelConfigDialog import cc.unitmesh.devins.idea.toolwindow.codereview.IdeaCodeReviewContent import cc.unitmesh.devins.idea.toolwindow.codereview.IdeaCodeReviewViewModel import cc.unitmesh.devins.idea.toolwindow.header.IdeaAgentTabsHeader @@ -20,6 +21,8 @@ import cc.unitmesh.devins.idea.toolwindow.knowledge.IdeaKnowledgeViewModel import cc.unitmesh.devins.idea.toolwindow.status.IdeaToolLoadingStatusBar import cc.unitmesh.devins.idea.toolwindow.timeline.IdeaEmptyStateMessage import cc.unitmesh.devins.idea.toolwindow.timeline.IdeaTimelineContent +import cc.unitmesh.llm.ModelConfig +import cc.unitmesh.llm.NamedModelConfig import com.intellij.openapi.Disposable import com.intellij.openapi.editor.ex.EditorEx import com.intellij.openapi.project.Project @@ -56,8 +59,18 @@ fun IdeaAgentApp( val isExecuting by viewModel.isExecuting.collectAsState() val showConfigDialog by viewModel.showConfigDialog.collectAsState() val mcpPreloadingMessage by viewModel.mcpPreloadingMessage.collectAsState() + val configWrapper by viewModel.configWrapper.collectAsState() + val currentModelConfig by viewModel.currentModelConfig.collectAsState() val listState = rememberLazyListState() + // Get available configs and current config name + val availableConfigs = remember(configWrapper) { + configWrapper?.getAllConfigs() ?: emptyList() + } + val currentConfigName = remember(configWrapper) { + configWrapper?.getActiveName() + } + // Code Review ViewModel (created lazily when needed) var codeReviewViewModel by remember { mutableStateOf(null) } @@ -155,7 +168,13 @@ fun IdeaAgentApp( onSettingsClick = { viewModel.setShowConfigDialog(true) }, onAtClick = { // @ click triggers agent completion - placeholder for now - } + }, + availableConfigs = availableConfigs, + currentConfigName = currentConfigName, + onConfigSelect = { config -> + viewModel.setActiveConfig(config.name) + }, + onConfigureClick = { viewModel.setShowConfigDialog(true) } ) } @@ -165,6 +184,21 @@ fun IdeaAgentApp( modifier = Modifier.fillMaxWidth().padding(horizontal = 8.dp, vertical = 4.dp) ) } + + // Model Configuration Dialog + if (showConfigDialog) { + val dialogConfig = currentModelConfig ?: ModelConfig() + IdeaModelConfigDialog( + currentConfig = dialogConfig, + currentConfigName = currentConfigName, + onDismiss = { viewModel.setShowConfigDialog(false) }, + onSave = { name, config -> + val namedConfig = NamedModelConfig.fromModelConfig(name, config) + viewModel.saveModelConfig(namedConfig, setActive = true) + viewModel.setShowConfigDialog(false) + } + ) + } } /** @@ -178,6 +212,7 @@ fun IdeaAgentApp( * - Token usage display * - Settings access * - Stop/Send button based on execution state + * - Model selector for switching between LLM configurations */ @Composable private fun IdeaDevInInputArea( @@ -189,7 +224,11 @@ private fun IdeaDevInInputArea( workspacePath: String? = null, totalTokens: Int? = null, onSettingsClick: () -> Unit = {}, - onAtClick: () -> Unit = {} + onAtClick: () -> Unit = {}, + availableConfigs: List = emptyList(), + currentConfigName: String? = null, + onConfigSelect: (NamedModelConfig) -> Unit = {}, + onConfigureClick: () -> Unit = {} ) { var inputText by remember { mutableStateOf("") } var devInInput by remember { mutableStateOf(null) } @@ -266,9 +305,17 @@ private fun IdeaDevInInputArea( devInInput?.appendText("@") onAtClick() }, + onSlashClick = { + // Insert / at current cursor position to trigger slash commands + devInInput?.appendText("/") + }, onSettingsClick = onSettingsClick, workspacePath = workspacePath, - totalTokens = totalTokens + totalTokens = totalTokens, + availableConfigs = availableConfigs, + currentConfigName = currentConfigName, + onConfigSelect = onConfigSelect, + onConfigureClick = onConfigureClick ) } } diff --git a/mpp-idea/src/main/kotlin/cc/unitmesh/devins/idea/toolwindow/IdeaAgentViewModel.kt b/mpp-idea/src/main/kotlin/cc/unitmesh/devins/idea/toolwindow/IdeaAgentViewModel.kt index d4e8b3fd9f..d86dae858c 100644 --- a/mpp-idea/src/main/kotlin/cc/unitmesh/devins/idea/toolwindow/IdeaAgentViewModel.kt +++ b/mpp-idea/src/main/kotlin/cc/unitmesh/devins/idea/toolwindow/IdeaAgentViewModel.kt @@ -430,6 +430,21 @@ class IdeaAgentViewModel( _showConfigDialog.value = show } + /** + * Set the active configuration by name + */ + fun setActiveConfig(configName: String) { + coroutineScope.launch { + try { + ConfigManager.setActive(configName) + // Reload configuration after changing active config + reloadConfiguration() + } catch (e: Exception) { + renderer.renderError("Failed to set active configuration: ${e.message}") + } + } + } + /** * Check if configuration is valid for LLM calls */ diff --git a/mpp-idea/src/main/kotlin/cc/unitmesh/devins/idea/toolwindow/IdeaComposeIcons.kt b/mpp-idea/src/main/kotlin/cc/unitmesh/devins/idea/toolwindow/IdeaComposeIcons.kt index 4dbaf200a5..7170071a1b 100644 --- a/mpp-idea/src/main/kotlin/cc/unitmesh/devins/idea/toolwindow/IdeaComposeIcons.kt +++ b/mpp-idea/src/main/kotlin/cc/unitmesh/devins/idea/toolwindow/IdeaComposeIcons.kt @@ -512,5 +512,246 @@ object IdeaComposeIcons { } }.build() } + + /** + * Send icon (paper plane / arrow pointing right) + */ + val Send: ImageVector by lazy { + ImageVector.Builder( + name = "Send", + defaultWidth = 24.dp, + defaultHeight = 24.dp, + viewportWidth = 24f, + viewportHeight = 24f + ).apply { + path( + fill = SolidColor(Color.Black) + ) { + moveTo(2.01f, 21f) + lineTo(23f, 12f) + lineTo(2.01f, 3f) + lineTo(2f, 10f) + lineToRelative(15f, 2f) + lineToRelative(-15f, 2f) + close() + } + }.build() + } + + /** + * Folder icon + */ + val Folder: ImageVector by lazy { + ImageVector.Builder( + name = "Folder", + defaultWidth = 24.dp, + defaultHeight = 24.dp, + viewportWidth = 24f, + viewportHeight = 24f + ).apply { + path( + fill = SolidColor(Color.Black) + ) { + moveTo(10f, 4f) + lineTo(12f, 6f) + lineTo(20f, 6f) + curveToRelative(1.1f, 0f, 2f, 0.9f, 2f, 2f) + verticalLineToRelative(10f) + curveToRelative(0f, 1.1f, -0.9f, 2f, -2f, 2f) + lineTo(4f, 20f) + curveToRelative(-1.1f, 0f, -2f, -0.9f, -2f, -2f) + lineTo(2f, 6f) + curveToRelative(0f, -1.1f, 0.9f, -2f, 2f, -2f) + horizontalLineToRelative(6f) + close() + } + }.build() + } + + /** + * AlternateEmail icon (@ symbol) + */ + val AlternateEmail: ImageVector by lazy { + ImageVector.Builder( + name = "AlternateEmail", + defaultWidth = 24.dp, + defaultHeight = 24.dp, + viewportWidth = 24f, + viewportHeight = 24f + ).apply { + path( + fill = SolidColor(Color.Black) + ) { + moveTo(12f, 2f) + curveTo(6.48f, 2f, 2f, 6.48f, 2f, 12f) + reflectiveCurveToRelative(4.48f, 10f, 10f, 10f) + horizontalLineToRelative(5f) + verticalLineToRelative(-2f) + horizontalLineToRelative(-5f) + curveToRelative(-4.34f, 0f, -8f, -3.66f, -8f, -8f) + reflectiveCurveToRelative(3.66f, -8f, 8f, -8f) + reflectiveCurveToRelative(8f, 3.66f, 8f, 8f) + verticalLineToRelative(1.43f) + curveToRelative(0f, 0.79f, -0.71f, 1.57f, -1.5f, 1.57f) + reflectiveCurveToRelative(-1.5f, -0.78f, -1.5f, -1.57f) + lineTo(17f, 12f) + curveToRelative(0f, -2.76f, -2.24f, -5f, -5f, -5f) + reflectiveCurveToRelative(-5f, 2.24f, -5f, 5f) + reflectiveCurveToRelative(2.24f, 5f, 5f, 5f) + curveToRelative(1.38f, 0f, 2.64f, -0.56f, 3.54f, -1.47f) + curveToRelative(0.65f, 0.89f, 1.77f, 1.47f, 2.96f, 1.47f) + curveToRelative(1.97f, 0f, 3.5f, -1.6f, 3.5f, -3.57f) + lineTo(22f, 12f) + curveToRelative(0f, -5.52f, -4.48f, -10f, -10f, -10f) + close() + moveTo(12f, 15f) + curveToRelative(-1.66f, 0f, -3f, -1.34f, -3f, -3f) + reflectiveCurveToRelative(1.34f, -3f, 3f, -3f) + reflectiveCurveToRelative(3f, 1.34f, 3f, 3f) + reflectiveCurveToRelative(-1.34f, 3f, -3f, 3f) + close() + } + }.build() + } + + /** + * ArrowDropDown icon (dropdown arrow) + */ + val ArrowDropDown: ImageVector by lazy { + ImageVector.Builder( + name = "ArrowDropDown", + defaultWidth = 24.dp, + defaultHeight = 24.dp, + viewportWidth = 24f, + viewportHeight = 24f + ).apply { + path( + fill = SolidColor(Color.Black) + ) { + moveTo(7f, 10f) + lineToRelative(5f, 5f) + lineToRelative(5f, -5f) + close() + } + }.build() + } + + /** + * Check icon (checkmark) + */ + val Check: ImageVector by lazy { + ImageVector.Builder( + name = "Check", + defaultWidth = 24.dp, + defaultHeight = 24.dp, + viewportWidth = 24f, + viewportHeight = 24f + ).apply { + path( + fill = SolidColor(Color.Black) + ) { + moveTo(9f, 16.17f) + lineTo(4.83f, 12f) + lineToRelative(-1.42f, 1.41f) + lineTo(9f, 19f) + lineTo(21f, 7f) + lineToRelative(-1.41f, -1.41f) + close() + } + }.build() + } + + /** + * Visibility icon (eye open) + */ + val Visibility: ImageVector by lazy { + ImageVector.Builder( + name = "Visibility", + defaultWidth = 24.dp, + defaultHeight = 24.dp, + viewportWidth = 24f, + viewportHeight = 24f + ).apply { + path( + fill = SolidColor(Color.Black) + ) { + moveTo(12f, 4.5f) + curveTo(7f, 4.5f, 2.73f, 7.61f, 1f, 12f) + curveToRelative(1.73f, 4.39f, 6f, 7.5f, 11f, 7.5f) + reflectiveCurveToRelative(9.27f, -3.11f, 11f, -7.5f) + curveToRelative(-1.73f, -4.39f, -6f, -7.5f, -11f, -7.5f) + close() + moveTo(12f, 17f) + curveToRelative(-2.76f, 0f, -5f, -2.24f, -5f, -5f) + reflectiveCurveToRelative(2.24f, -5f, 5f, -5f) + reflectiveCurveToRelative(5f, 2.24f, 5f, 5f) + reflectiveCurveToRelative(-2.24f, 5f, -5f, 5f) + close() + moveTo(12f, 9f) + curveToRelative(-1.66f, 0f, -3f, 1.34f, -3f, 3f) + reflectiveCurveToRelative(1.34f, 3f, 3f, 3f) + reflectiveCurveToRelative(3f, -1.34f, 3f, -3f) + reflectiveCurveToRelative(-1.34f, -3f, -3f, -3f) + close() + } + }.build() + } + + /** + * VisibilityOff icon (eye closed) + */ + val VisibilityOff: ImageVector by lazy { + ImageVector.Builder( + name = "VisibilityOff", + defaultWidth = 24.dp, + defaultHeight = 24.dp, + viewportWidth = 24f, + viewportHeight = 24f + ).apply { + path( + fill = SolidColor(Color.Black) + ) { + moveTo(12f, 7f) + curveToRelative(2.76f, 0f, 5f, 2.24f, 5f, 5f) + curveToRelative(0f, 0.65f, -0.13f, 1.26f, -0.36f, 1.83f) + lineToRelative(2.92f, 2.92f) + curveToRelative(1.51f, -1.26f, 2.7f, -2.89f, 3.43f, -4.75f) + curveToRelative(-1.73f, -4.39f, -6f, -7.5f, -11f, -7.5f) + curveToRelative(-1.4f, 0f, -2.74f, 0.25f, -3.98f, 0.7f) + lineToRelative(2.16f, 2.16f) + curveTo(10.74f, 7.13f, 11.35f, 7f, 12f, 7f) + close() + moveTo(2f, 4.27f) + lineToRelative(2.28f, 2.28f) + lineToRelative(0.46f, 0.46f) + curveTo(3.08f, 8.3f, 1.78f, 10.02f, 1f, 12f) + curveToRelative(1.73f, 4.39f, 6f, 7.5f, 11f, 7.5f) + curveToRelative(1.55f, 0f, 3.03f, -0.3f, 4.38f, -0.84f) + lineToRelative(0.42f, 0.42f) + lineTo(19.73f, 22f) + lineTo(21f, 20.73f) + lineTo(3.27f, 3f) + lineTo(2f, 4.27f) + close() + moveTo(7.53f, 9.8f) + lineToRelative(1.55f, 1.55f) + curveToRelative(-0.05f, 0.21f, -0.08f, 0.43f, -0.08f, 0.65f) + curveToRelative(0f, 1.66f, 1.34f, 3f, 3f, 3f) + curveToRelative(0.22f, 0f, 0.44f, -0.03f, 0.65f, -0.08f) + lineToRelative(1.55f, 1.55f) + curveToRelative(-0.67f, 0.33f, -1.41f, 0.53f, -2.2f, 0.53f) + curveToRelative(-2.76f, 0f, -5f, -2.24f, -5f, -5f) + curveToRelative(0f, -0.79f, 0.2f, -1.53f, 0.53f, -2.2f) + close() + moveTo(11.84f, 9.02f) + lineToRelative(3.15f, 3.15f) + lineToRelative(0.02f, -0.16f) + curveToRelative(0f, -1.66f, -1.34f, -3f, -3f, -3f) + lineToRelative(-0.17f, 0.01f) + close() + } + }.build() + } + }