diff --git a/.gitignore b/.gitignore index 6bca7c1..8e7a657 100644 --- a/.gitignore +++ b/.gitignore @@ -5,4 +5,4 @@ .kotlin .qodana build -src/main/resources/ronin-profile.json \ No newline at end of file +src/main/resources/stances \ No newline at end of file diff --git a/src/main/kotlin/com/ronin/service/LLMService.kt b/src/main/kotlin/com/ronin/service/LLMService.kt index 12c0d5a..acb6e8e 100644 --- a/src/main/kotlin/com/ronin/service/LLMService.kt +++ b/src/main/kotlin/com/ronin/service/LLMService.kt @@ -14,102 +14,11 @@ class LLMServiceImpl(private val project: Project) : LLMService { override fun sendMessage(prompt: String, context: String?, history: List>, images: List): String { val settings = com.ronin.settings.RoninSettingsState.instance - val activeStanceName = settings.activeStance val stance = settings.stances.find { it.name == activeStanceName } ?: throw IllegalStateException("Active stance '$activeStanceName' not found in configuration.") - val systemPrompt = """ - You are Ronin, engaging in the stance of: "${stance.name}". - ${stance.systemPrompt} - - **ENVIRONMENT:** - - You are working in a Bazel-based monorepo. - - Scope: ${stance.scope} - - Allowed Tools: ${stance.allowedTools} - - Execution Command: ${stance.executionCommand} - - - **CORE PROTOCOL (Thought-Action):** - You must always "think" before you act. Your response must follow this strict XML format: - - - 1. Analyze the user request and file context. - 2. Plan your specific edits or actions. - 3. Verify your plan against project rules. - - - - - - ARG_VALUE - - - - **CRITICAL RULES:** - 1. **MANDATORY EXECUTION**: You MUST output an `` block in every single turn. - 2. **NO OPEN LOOPS**: If you are just replying to the user (no code action), you MUST use `task_complete` with your message as `content`. - 3. **ANTI-STALLING**: Do NOT stop at ``. If you stop, the system hangs. You must proceed to ``. - 4. **AUTOMATION FAILURE**: If you output only analysis, the automation fails. - 5. **EXECUTION AUTHORITY**: If the user asks to "run the app" or "start the server", you MUST use the `run_command` tool with the configured **Execution Command** below. - 6. **NO HALLUCINATIONS**: Do NOT invent new command names like "run_application". Only use the commands listed below. - - **CONFIGURATION:** - - **Execution Command** (Use with `run_command`): `${stance.executionCommand}` - - **AVAILABLE COMMANDS:** - - 1. `read_code`: Inspect files. - - libs/core/utils.py - 1 - 100 - - - 2. `write_code`: Modify files. Use CDATA for content to avoid escaping issues. - - libs/core/utils.py - - 10 - 15 - - - - OR - - - ... - - - - - - 3. `run_command`: Execute shell commands. - - ./gradlew build - - - 4. `task_complete`: Signal completion. - - I have finished the task. - - - **INSTRUCTIONS:** - - **MODE: VIBE CODING (Architect/Editor)**: - - **Self-Healing**: If you make a mistake, analyze it in and fix it in the next turn. - - **Context Echo**: After editing, the tool returns the result. READ IT. - - **SAFE EDITING**: - - Use `write_code` with `start_line`/`end_line` whenever possible. - - **STRICT ANTI-REPETITION**: If a command fails, do not retry blindly. Read the file, understand the state, then fix. - - User Request: $prompt - - (REMINDER: You MUST end your response with an block containing a command. Do not just analyze.) - Context: $context - """.trimIndent() + val systemPrompt = stance.systemPrompt.trimIndent() if (stance.provider == "OpenAI") { var apiKey = com.ronin.settings.CredentialHelper.getApiKey(stance.credentialId) @@ -132,12 +41,10 @@ class LLMServiceImpl(private val project: Project) : LLMService { private fun sendOpenAIRequest(systemPrompt: String, history: List>, model: String, apiKey: String, enforceJson: Boolean): String { - // Prune history to manage token limits (approx heuristic) val prunedHistory = pruneHistory(history, 20) val jsonBody = createOpenAIRequestBody(model, systemPrompt, prunedHistory, enforceJson) - // Custom endpoint handling for specific internal/preview models val endpoint = if (model.contains("codex") || model.contains("gpt-5.1")) { "https://api.openai.com/v1/responses" } else { diff --git a/src/main/kotlin/com/ronin/settings/RoninSettingsConfigurable.kt b/src/main/kotlin/com/ronin/settings/RoninSettingsConfigurable.kt index ac732fd..845e1ac 100644 --- a/src/main/kotlin/com/ronin/settings/RoninSettingsConfigurable.kt +++ b/src/main/kotlin/com/ronin/settings/RoninSettingsConfigurable.kt @@ -27,16 +27,12 @@ class RoninSettingsConfigurable : Configurable { private val descriptionField = JBTextField() private val providerComboBox = ComboBox(arrayOf("OpenAI", "Anthropic", "Google", "Kimi", "Minimax", "Ollama")) private val modelField = JBTextField() - private val scopeField = JBTextField() private val credentialIdField = JBTextField() private val apiKeyField = JBPasswordField() // Used to update key - private val executionCommandField = JBTextField() private val systemPromptField = JBTextArea(5, 40) // Global Fields private val ollamaBaseUrlField = JBTextField() - private val allowedToolsField = JBTextField() - private val coreWorkflowField = JBTextArea(5, 40) // Local State private var localStances = mutableListOf() @@ -48,8 +44,6 @@ class RoninSettingsConfigurable : Configurable { override fun createComponent(): JComponent? { systemPromptField.lineWrap = true systemPromptField.wrapStyleWord = true - coreWorkflowField.lineWrap = true - coreWorkflowField.wrapStyleWord = true // Top Bar: Selector + Buttons val topPanel = JPanel(java.awt.FlowLayout(java.awt.FlowLayout.LEFT)) @@ -65,10 +59,8 @@ class RoninSettingsConfigurable : Configurable { .addLabeledComponent("Description:", descriptionField) .addLabeledComponent("Provider:", providerComboBox) .addLabeledComponent("Model:", modelField) - .addLabeledComponent("Scope (Tip: Use bazel targets like //core/...):", scopeField) .addLabeledComponent("Credential ID:", credentialIdField) .addLabeledComponent("Update API Key (Leave empty to keep):", apiKeyField) - .addLabeledComponent("Execution Command:", executionCommandField) .addLabeledComponent("System Prompt:", JBScrollPane(systemPromptField)) .addSeparator() .panel @@ -78,8 +70,6 @@ class RoninSettingsConfigurable : Configurable { .addSeparator() .addSeparator() .addLabeledComponent(MyBundle.message("settings.ollama_url"), ollamaBaseUrlField) - .addLabeledComponent(MyBundle.message("settings.allowed_tools"), allowedToolsField) - .addLabeledComponent(MyBundle.message("settings.core_workflow"), JBScrollPane(coreWorkflowField)) .addComponentFillVertically(JPanel(), 0) .panel @@ -103,15 +93,11 @@ class RoninSettingsConfigurable : Configurable { descriptionField.isEditable = false providerComboBox.isEnabled = false modelField.isEditable = false - scopeField.isEditable = false credentialIdField.isEditable = false apiKeyField.isEnabled = false - executionCommandField.isEditable = false systemPromptField.isEditable = false ollamaBaseUrlField.isEditable = false - allowedToolsField.isEditable = false - coreWorkflowField.isEditable = false topPanel.add(JLabel("(Locked by Admin)")) } @@ -180,10 +166,7 @@ class RoninSettingsConfigurable : Configurable { descriptionField.text = s.description providerComboBox.selectedItem = s.provider modelField.text = s.model - scopeField.text = s.scope credentialIdField.text = s.credentialId - executionCommandField.text = s.executionCommand - allowedToolsField.text = s.allowedTools // Load apiKeyField.text = "" // Always clear password field on load systemPromptField.text = s.systemPrompt } @@ -193,10 +176,7 @@ class RoninSettingsConfigurable : Configurable { s.description = descriptionField.text s.provider = providerComboBox.selectedItem as? String ?: "OpenAI" s.model = modelField.text - s.scope = scopeField.text s.credentialId = credentialIdField.text - s.executionCommand = executionCommandField.text - s.allowedTools = allowedToolsField.text // Save s.systemPrompt = systemPromptField.text val newKey = String(apiKeyField.password) @@ -209,10 +189,7 @@ class RoninSettingsConfigurable : Configurable { nameField.text = "" descriptionField.text = "" modelField.text = "" - scopeField.text = "" credentialIdField.text = "" - executionCommandField.text = "" - allowedToolsField.text = "" // Clear apiKeyField.text = "" systemPromptField.text = "" } @@ -226,7 +203,6 @@ class RoninSettingsConfigurable : Configurable { } if (ollamaBaseUrlField.text != settings.ollamaBaseUrl) return true - if (coreWorkflowField.text != settings.coreWorkflow) return true if (localStances != settings.stances) return true if (tempKeyUpdates.isNotEmpty()) return true @@ -240,8 +216,6 @@ class RoninSettingsConfigurable : Configurable { val settings = RoninSettingsState.instance settings.ollamaBaseUrl = ollamaBaseUrlField.text - // settings.allowedTools removed - settings.coreWorkflow = coreWorkflowField.text val activeStanceId = settings.stances.find { it.name == settings.activeStance }?.id @@ -275,8 +249,6 @@ class RoninSettingsConfigurable : Configurable { override fun reset() { val settings = RoninSettingsState.instance ollamaBaseUrlField.text = settings.ollamaBaseUrl - // allowedToolsField reset removed (handled by loadStanceToForm) - coreWorkflowField.text = settings.coreWorkflow localStances.clear() for (s in settings.stances) { diff --git a/src/main/kotlin/com/ronin/settings/RoninSettingsState.kt b/src/main/kotlin/com/ronin/settings/RoninSettingsState.kt index 7987cfa..beb718a 100644 --- a/src/main/kotlin/com/ronin/settings/RoninSettingsState.kt +++ b/src/main/kotlin/com/ronin/settings/RoninSettingsState.kt @@ -23,11 +23,8 @@ class RoninSettingsState : PersistentStateComponent { var systemPrompt: String = "", var provider: String = "OpenAI", var model: String = "gpt-4o-mini", - var scope: String = "General", var credentialId: String = "", - var executionCommand: String = "bazel run //project:app.binary", - var encryptedKey: String? = null, - var allowedTools: String = "git, podman, kubectl, argocd, aws, bazel" + var encryptedKey: String? = null ) var stances: MutableList = mutableListOf() @@ -35,27 +32,88 @@ class RoninSettingsState : PersistentStateComponent { var settingsEditable: Boolean = true var ollamaBaseUrl: String = "http://localhost:11434" - var coreWorkflow: String = """ - 1. **PLAN**: Analyze request. - 2. **EXECUTE**: Return the JSON with commands and edits. - 3. **VERIFY**: Check if the goal is achieved. Only run verification commands (test/build) if necessary to validate code changes. Do NOT verify simple info queries (e.g. pwd, ls). - """.trimIndent() init { - val profileResource = RoninSettingsState::class.java.getResource("/ronin-profile.json") - if (stances.isEmpty() && profileResource != null) { - try { - val content = profileResource.readText() - val profile = com.google.gson.Gson().fromJson(content, Profile::class.java) + loadStancesFromResources() + } + + private fun loadStancesFromResources() { + if (stances.isNotEmpty()) return + + try { + val url = RoninSettingsState::class.java.getResource("/stances") ?: return + + if (url.protocol == "file") { + val dir = java.io.File(url.toURI()) + val files = dir.listFiles { _, name -> name.endsWith(".md") } ?: return - this.settingsEditable = profile.settingsEditable + for (file in files) { + val content = file.readText() + val stance = parseStanceMarkdown(file.nameWithoutExtension, content) + if (stance != null) { + stances.add(stance) + } + } + } else if (url.protocol == "jar") { + val connection = url.openConnection() as java.net.JarURLConnection + val jarFile = connection.jarFile + val entries = jarFile.entries() - if (profile.stances.isNotEmpty()) { - stances.addAll(profile.stances) + while (entries.hasMoreElements()) { + val entry = entries.nextElement() + val name = entry.name + // Check if it is within our target directory and is a markdown file + if (name.startsWith("stances/") && name.endsWith(".md") && !entry.isDirectory) { + val filename = name.substringAfterLast('/') + val id = filename.substringBeforeLast('.') + + val inputStream = jarFile.getInputStream(entry) + val content = inputStream.bufferedReader().use { it.readText() } + val stance = parseStanceMarkdown(id, content) + if (stance != null) { + stances.add(stance) + } + } + } + } + } catch (e: Exception) { + e.printStackTrace() + } + } + + private fun parseStanceMarkdown(filenameId: String, content: String): Stance? { + try { + val parts = content.split("---", limit = 3) + if (parts.size < 3) return null // Invalid format + + val frontmatter = parts[1] + val systemPrompt = parts[2].trim() + + val stance = Stance(id = filenameId) + stance.systemPrompt = systemPrompt + + // Parse Frontmatter (Simple Key-Value) + frontmatter.lines().forEach { line -> + val trimmed = line.trim() + if (trimmed.isNotBlank() && trimmed.contains(":")) { + val split = trimmed.split(":", limit = 2) + val key = split[0].trim() + val value = split[1].trim() + + when (key) { + "name" -> stance.name = value + "description" -> stance.description = value + "provider" -> stance.provider = value + "model" -> stance.model = value + "credentialId" -> stance.credentialId = value + "encryptedKey" -> stance.encryptedKey = value + } } - } catch (e: Exception) { - e.printStackTrace() } + return stance + } catch (e: Exception) { + println("Ronin: Failed to parse stance file $filenameId: ${e.message}") + return null } } @@ -68,8 +126,6 @@ class RoninSettingsState : PersistentStateComponent { override fun loadState(state: RoninSettingsState) { this.ollamaBaseUrl = state.ollamaBaseUrl // allowedTools moved to Stance - this.coreWorkflow = state.coreWorkflow - this.activeStance = state.activeStance this.settingsEditable = state.settingsEditable diff --git a/src/main/kotlin/com/ronin/ui/chat/ChatToolWindow.kt b/src/main/kotlin/com/ronin/ui/chat/ChatToolWindow.kt index af5d7b3..1531916 100644 --- a/src/main/kotlin/com/ronin/ui/chat/ChatToolWindow.kt +++ b/src/main/kotlin/com/ronin/ui/chat/ChatToolWindow.kt @@ -15,6 +15,7 @@ import com.ronin.ui.chat.components.ControlBar import com.ronin.ui.chat.components.MessageBubble import com.ronin.ui.chat.components.TerminalBlock import java.awt.BorderLayout +import java.util.concurrent.Future import javax.swing.* /** @@ -37,6 +38,7 @@ class ChatToolWindow(private val project: Project) { private val messageHistory = mutableListOf>() private var isGenerating = false private var lastTerminalBlock: TerminalBlock? = null + private var currentCommandFuture: Future<*>? = null companion object { private const val KEY = "RoninChatToolWindow" @@ -202,7 +204,7 @@ class ChatToolWindow(private val project: Project) { scrollToBottom() } - ApplicationManager.getApplication().executeOnPooledThread { + currentCommandFuture = ApplicationManager.getApplication().executeOnPooledThread { try { val terminalService = project.service() val outputBuffer = StringBuilder() @@ -232,6 +234,9 @@ class ChatToolWindow(private val project: Project) { } SwingUtilities.invokeLater { + // If generation was cancelled, don't proceed with follow-up + if (!isGenerating) return@invokeLater + val remaining: String synchronized(lock) { remaining = outputBuffer.toString() } if (remaining.isNotEmpty()) { @@ -246,8 +251,10 @@ class ChatToolWindow(private val project: Project) { } } catch (e: Exception) { SwingUtilities.invokeLater { - addSystemMessage("❌ Command failed: ${e.message}") - setGenerating(false) + if (isGenerating) { + addSystemMessage("❌ Command failed: ${e.message}") + setGenerating(false) + } } } } @@ -258,10 +265,25 @@ class ChatToolWindow(private val project: Project) { */ private fun handleActionButtonClick() { if (isGenerating) { + var cancelled = false + + // Cancel API task if (api.cancelCurrentTask()) { + cancelled = true + } + + // Cancel running command + if (currentCommandFuture != null && !currentCommandFuture!!.isDone) { + currentCommandFuture?.cancel(true) + currentCommandFuture = null + cancelled = true + } + + if (cancelled) { addSystemMessage("🛑 Request cancelled by user.") - setGenerating(false) } + + setGenerating(false) } else { clearChat() } @@ -371,10 +393,10 @@ class ChatToolWindow(private val project: Project) { val isThinking = role == "Ronin Thinking" val bubble = when { - isUser -> MessageBubble.createUserMessage(message, scrollPane.viewport.width) - isSystem -> MessageBubble.createSystemMessage(message, scrollPane.viewport.width) - isThinking -> MessageBubble.createThinkingMessage(message, scrollPane.viewport.width) - else -> MessageBubble.createAssistantMessage(message, scrollPane.viewport.width) + isUser -> MessageBubble.createUserMessage(message) + isSystem -> MessageBubble.createSystemMessage(message) + isThinking -> MessageBubble.createThinkingMessage(message) + else -> MessageBubble.createAssistantMessage(message) } chatPanel.add(bubble) diff --git a/src/main/kotlin/com/ronin/ui/chat/components/ChatInputField.kt b/src/main/kotlin/com/ronin/ui/chat/components/ChatInputField.kt index d32eaa8..5d40318 100644 --- a/src/main/kotlin/com/ronin/ui/chat/components/ChatInputField.kt +++ b/src/main/kotlin/com/ronin/ui/chat/components/ChatInputField.kt @@ -68,19 +68,21 @@ class ChatInputField( } override fun getPreferredSize(): Dimension { - val d = super.getPreferredSize() + return ApplicationManager.getApplication().runWriteIntentReadAction { + val d = super.getPreferredSize() - val editor = this.editor ?: return d + val editor = this.editor ?: return@runWriteIntentReadAction d - val lineHeight = editor.lineHeight - val lineCount = document.lineCount.coerceAtLeast(1) + val lineHeight = editor.lineHeight + val lineCount = document.lineCount.coerceAtLeast(1) - val linesToShow = lineCount.coerceAtMost(MAX_VISIBLE_LINES) + val linesToShow = lineCount.coerceAtMost(MAX_VISIBLE_LINES) - val insets = insets - val contentHeight = (linesToShow * lineHeight) + insets.top + insets.bottom + 4 + val insets = insets + val contentHeight = (linesToShow * lineHeight) + insets.top + insets.bottom + 4 - return Dimension(d.width, contentHeight.coerceAtLeast(MIN_HEIGHT)) + Dimension(d.width, contentHeight.coerceAtLeast(MIN_HEIGHT)) + } } private fun sendMessage() { diff --git a/src/main/kotlin/com/ronin/ui/chat/components/MessageBubble.kt b/src/main/kotlin/com/ronin/ui/chat/components/MessageBubble.kt index bf2e96e..ca60502 100644 --- a/src/main/kotlin/com/ronin/ui/chat/components/MessageBubble.kt +++ b/src/main/kotlin/com/ronin/ui/chat/components/MessageBubble.kt @@ -28,8 +28,7 @@ enum class BubbleType { */ class MessageBubble private constructor( private val type: BubbleType, - private val message: String, - private val maxWidth: Int + private val message: String ) : JPanel(GridBagLayout()) { init { @@ -44,16 +43,9 @@ class MessageBubble private constructor( c.weightx = 1.0 c.fill = GridBagConstraints.HORIZONTAL - when (type) { - BubbleType.USER -> { - c.anchor = GridBagConstraints.EAST - c.insets = Insets(0, 50, 0, 0) - } - else -> { - c.anchor = GridBagConstraints.WEST - c.insets = Insets(0, 0, 0, 50) - } - } + // Make sure it fills the width + c.anchor = GridBagConstraints.CENTER + c.insets = Insets(0, 0, 0, 0) val textArea = createTextArea() val bubbleWrapper = JPanel(BorderLayout()) @@ -64,7 +56,7 @@ class MessageBubble private constructor( } private fun createTextArea(): JTextArea { - val textArea = DynamicTextArea(message, maxWidth) + val textArea = JTextArea(message) textArea.lineWrap = true textArea.wrapStyleWord = true textArea.isEditable = false @@ -101,58 +93,33 @@ class MessageBubble private constructor( return textArea } - /** - * Text area that dynamically adjusts its width - */ - private class DynamicTextArea(text: String, private val maxWidth: Int) : JTextArea(text) { - override fun getPreferredSize(): java.awt.Dimension { - val d = super.getPreferredSize() - val effectiveMaxWidth = (maxWidth * 0.85).toInt() - if (effectiveMaxWidth > 100 && d.width > effectiveMaxWidth) { - return java.awt.Dimension(effectiveMaxWidth, d.height) - } - return d - } - - override fun getScrollableTracksViewportWidth(): Boolean = true - - override fun setBounds(x: Int, y: Int, width: Int, height: Int) { - var w = width - val effectiveMaxWidth = (maxWidth * 0.85).toInt() - if (w > effectiveMaxWidth) { - w = effectiveMaxWidth - } - super.setBounds(x, y, w, height) - } - } - companion object { /** * Creates a user message bubble */ - fun createUserMessage(message: String, maxWidth: Int = 600): JComponent { - return MessageBubble(BubbleType.USER, message, maxWidth) + fun createUserMessage(message: String): JComponent { + return MessageBubble(BubbleType.USER, message) } /** * Creates an assistant message bubble */ - fun createAssistantMessage(message: String, maxWidth: Int = 600): JComponent { - return MessageBubble(BubbleType.ASSISTANT, message, maxWidth) + fun createAssistantMessage(message: String): JComponent { + return MessageBubble(BubbleType.ASSISTANT, message) } /** * Creates a system message bubble */ - fun createSystemMessage(message: String, maxWidth: Int = 600): JComponent { - return MessageBubble(BubbleType.SYSTEM, message, maxWidth) + fun createSystemMessage(message: String): JComponent { + return MessageBubble(BubbleType.SYSTEM, message) } /** * Creates a thinking message bubble */ - fun createThinkingMessage(message: String, maxWidth: Int = 600): JComponent { - return MessageBubble(BubbleType.THINKING, message, maxWidth) + fun createThinkingMessage(message: String): JComponent { + return MessageBubble(BubbleType.THINKING, message) } } } diff --git a/src/main/kotlin/com/ronin/ui/chat/components/TerminalBlock.kt b/src/main/kotlin/com/ronin/ui/chat/components/TerminalBlock.kt index 931f9e8..e742e46 100644 --- a/src/main/kotlin/com/ronin/ui/chat/components/TerminalBlock.kt +++ b/src/main/kotlin/com/ronin/ui/chat/components/TerminalBlock.kt @@ -12,7 +12,7 @@ import javax.swing.JTextArea /** * Terminal block component for displaying command output */ -class TerminalBlock(private val command: String) : JPanel(FlowLayout(FlowLayout.LEFT)) { +class TerminalBlock(private val command: String) : JPanel(BorderLayout()) { private val termPanel = JPanel(BorderLayout()) private val outputArea = JTextArea() @@ -38,11 +38,12 @@ class TerminalBlock(private val command: String) : JPanel(FlowLayout(FlowLayout. outputArea.foreground = Color.LIGHT_GRAY outputArea.font = Font("JetBrains Mono", Font.PLAIN, 12) outputArea.isEditable = false - outputArea.columns = 50 + outputArea.lineWrap = true + outputArea.wrapStyleWord = true outputArea.rows = 5 termPanel.add(outputArea, BorderLayout.CENTER) - add(termPanel) + add(termPanel, BorderLayout.CENTER) } /** @@ -64,4 +65,8 @@ class TerminalBlock(private val command: String) : JPanel(FlowLayout(FlowLayout. * Gets the current output text */ fun getOutput(): String = outputArea.text + + override fun getMaximumSize(): java.awt.Dimension { + return java.awt.Dimension(Int.MAX_VALUE, super.getPreferredSize().height) + } }