From 3983b9869d17fa39ec88b6df3afcd874f085e2b2 Mon Sep 17 00:00:00 2001 From: hymoon Date: Mon, 26 Jan 2026 14:34:47 -0600 Subject: [PATCH 1/7] Implement chat API and UI components for LLM interaction --- .../com/ronin/actions/BaseRoninAction.kt | 45 +- .../com/ronin/ui/ChatToolWindowFactory.kt | 613 +----------------- .../com/ronin/ui/chat/ChatToolWindow.kt | 419 ++++++++++++ .../com/ronin/ui/chat/api/MessageHandler.kt | 40 ++ .../kotlin/com/ronin/ui/chat/api/Models.kt | 40 ++ .../kotlin/com/ronin/ui/chat/api/RoninApi.kt | 159 +++++ .../com/ronin/ui/chat/api/TaskBuilder.kt | 61 ++ .../com/ronin/ui/chat/api/TaskExecutor.kt | 101 +++ .../ui/chat/components/ChatInputField.kt | 82 +++ .../ronin/ui/chat/components/ControlBar.kt | 102 +++ .../ronin/ui/chat/components/MessageBubble.kt | 158 +++++ .../ronin/ui/chat/components/TerminalBlock.kt | 67 ++ 12 files changed, 1269 insertions(+), 618 deletions(-) create mode 100644 src/main/kotlin/com/ronin/ui/chat/ChatToolWindow.kt create mode 100644 src/main/kotlin/com/ronin/ui/chat/api/MessageHandler.kt create mode 100644 src/main/kotlin/com/ronin/ui/chat/api/Models.kt create mode 100644 src/main/kotlin/com/ronin/ui/chat/api/RoninApi.kt create mode 100644 src/main/kotlin/com/ronin/ui/chat/api/TaskBuilder.kt create mode 100644 src/main/kotlin/com/ronin/ui/chat/api/TaskExecutor.kt create mode 100644 src/main/kotlin/com/ronin/ui/chat/components/ChatInputField.kt create mode 100644 src/main/kotlin/com/ronin/ui/chat/components/ControlBar.kt create mode 100644 src/main/kotlin/com/ronin/ui/chat/components/MessageBubble.kt create mode 100644 src/main/kotlin/com/ronin/ui/chat/components/TerminalBlock.kt diff --git a/src/main/kotlin/com/ronin/actions/BaseRoninAction.kt b/src/main/kotlin/com/ronin/actions/BaseRoninAction.kt index 8790c6b..d315ff5 100644 --- a/src/main/kotlin/com/ronin/actions/BaseRoninAction.kt +++ b/src/main/kotlin/com/ronin/actions/BaseRoninAction.kt @@ -3,15 +3,18 @@ package com.ronin.actions import com.intellij.openapi.actionSystem.AnAction import com.intellij.openapi.actionSystem.AnActionEvent import com.intellij.openapi.actionSystem.CommonDataKeys -import com.intellij.openapi.components.service import com.intellij.openapi.wm.ToolWindowManager -import com.ronin.service.LLMService -import com.ronin.ui.ChatToolWindowFactory -import com.ronin.MyBundle - +import com.ronin.ui.chat.ChatToolWindow +/** + * Base class for Ronin actions that work with selected code + * Refactored to use the new ChatToolWindow API + */ abstract class BaseRoninAction : AnAction() { + /** + * Generates the prompt based on the selected code + */ abstract fun getPrompt(code: String): String override fun update(e: AnActionEvent) { @@ -30,32 +33,12 @@ abstract class BaseRoninAction : AnAction() { // 1. Open the Tool Window if not open val toolWindow = ToolWindowManager.getInstance(project).getToolWindow("Ronin Chat") toolWindow?.show { - // 2. Append User's "pseudo-command" to chat - val chatWindow = ChatToolWindowFactory.ChatToolWindow.getInstance(project) - chatWindow?.appendMessage(MyBundle.message("toolwindow.you"), prompt) - - // 3. Call Service - com.intellij.openapi.application.ApplicationManager.getApplication().executeOnPooledThread { - val llmService = project.service() - val configService = project.service() - - // Gather Context - val activeFile = configService.getActiveFileContent() - val projectStructure = configService.getProjectStructure() - - val contextBuilder = StringBuilder() - if (activeFile != null) { - contextBuilder.append("Active File Content:\n```\n$activeFile\n```\n\n") - } - contextBuilder.append(projectStructure) - - val response = llmService.sendMessage(prompt, contextBuilder.toString(), emptyList()) - - // Parse and apply changes (side-effect) - com.ronin.service.ResponseParser.parseAndApply(response, project) - - // 4. Append Response - chatWindow?.appendMessage(MyBundle.message("toolwindow.ronin"), response) + // 2. Get ChatToolWindow instance and send message + val chatWindow = ChatToolWindow.getInstance(project) + if (chatWindow != null) { + // Use the new API to send the message + // This will handle everything: adding to UI, calling LLM, processing response, etc. + chatWindow.sendMessageProgrammatically(prompt) } } } diff --git a/src/main/kotlin/com/ronin/ui/ChatToolWindowFactory.kt b/src/main/kotlin/com/ronin/ui/ChatToolWindowFactory.kt index 60b0821..9552c42 100644 --- a/src/main/kotlin/com/ronin/ui/ChatToolWindowFactory.kt +++ b/src/main/kotlin/com/ronin/ui/ChatToolWindowFactory.kt @@ -1,41 +1,48 @@ package com.ronin.ui +import com.intellij.openapi.actionSystem.AnAction +import com.intellij.openapi.actionSystem.AnActionEvent +import com.intellij.openapi.actionSystem.DefaultActionGroup import com.intellij.openapi.project.Project import com.intellij.openapi.wm.ToolWindow import com.intellij.openapi.wm.ToolWindowFactory import com.intellij.ui.content.ContentFactory -import com.ronin.service.LLMService -import com.intellij.openapi.components.service -import com.intellij.openapi.application.ApplicationManager -import com.intellij.openapi.application.ReadAction -import com.ronin.MyBundle -import java.awt.BorderLayout -import javax.swing.* -import com.intellij.openapi.actionSystem.* -import com.intellij.openapi.fileChooser.FileSaverDescriptor -import com.intellij.openapi.fileChooser.FileChooserFactory -import com.intellij.openapi.vfs.VfsUtil -import com.fasterxml.jackson.databind.ObjectMapper -import com.fasterxml.jackson.databind.SerializationFeature -import com.intellij.ui.components.JBLabel -import java.io.File +import com.ronin.ui.chat.ChatToolWindow +/** + * Factory for creating the Ronin Chat tool window + * Simplified to focus only on initialization and toolbar actions + */ class ChatToolWindowFactory : ToolWindowFactory { - + override fun createToolWindowContent(project: Project, toolWindow: ToolWindow) { val chatToolWindow = ChatToolWindow(project) val contentFactory = ContentFactory.getInstance() val content = contentFactory.createContent(chatToolWindow.getContent(), "", false) toolWindow.contentManager.addContent(content) - // Add native actions to the tool window title bar + // Add toolbar actions + setupToolbarActions(toolWindow, chatToolWindow) + } + + private fun setupToolbarActions(toolWindow: ToolWindow, chatToolWindow: ChatToolWindow) { val actionGroup = DefaultActionGroup() - actionGroup.add(object : AnAction("Export Conversation...", "Save chat history to JSON", com.intellij.icons.AllIcons.Actions.Download) { + + actionGroup.add(object : AnAction( + "Export Conversation...", + "Save chat history to JSON", + com.intellij.icons.AllIcons.Actions.Download + ) { override fun actionPerformed(e: AnActionEvent) { chatToolWindow.exportConversation() } }) - actionGroup.add(object : AnAction("Clear Chat", "Reset conversation", com.intellij.icons.AllIcons.Actions.GC) { + + actionGroup.add(object : AnAction( + "Clear Chat", + "Reset conversation", + com.intellij.icons.AllIcons.Actions.GC + ) { override fun actionPerformed(e: AnActionEvent) { chatToolWindow.clearChat() } @@ -43,572 +50,4 @@ class ChatToolWindowFactory : ToolWindowFactory { toolWindow.setTitleActions(listOf(actionGroup)) } - - class ChatToolWindow(private val project: Project) { - private val mainPanel = JPanel(BorderLayout()) - - // Chat UI Components - private val chatPanel = JPanel() - private val scrollPane = com.intellij.ui.components.JBScrollPane(chatPanel) - - // New Input Area (Multi-line) - private val inputArea = com.intellij.ui.components.JBTextArea() - - // Chat History - private val messageHistory = mutableListOf>() - - // Controls - private val attachButton = JButton(com.intellij.icons.AllIcons.FileTypes.Any_type) - // Dynamic Action Button: Stop (when generating) / Reset (when idle) - private val actionButton = JButton(com.intellij.icons.AllIcons.Actions.Refresh) // Default to Reset/Refresh - private val modelComboBox = com.intellij.openapi.ui.ComboBox() - - // State - private var currentTask: java.util.concurrent.Future<*>? = null - private var isGenerating = false - - companion object { - private const val KEY = "RoninChatToolWindow" - - fun getInstance(project: Project): ChatToolWindow? { - val toolWindow = com.intellij.openapi.wm.ToolWindowManager.getInstance(project).getToolWindow("Ronin Chat") ?: return null - val content = toolWindow.contentManager.contents.firstOrNull() ?: return null - val component = content.component as? JComponent ?: return null - return component.getClientProperty(KEY) as? ChatToolWindow - } - } - - init { - mainPanel.putClientProperty(KEY, this) - val connection = ApplicationManager.getApplication().messageBus.connect(project) - connection.subscribe(com.ronin.settings.RoninSettingsNotifier.TOPIC, object : com.ronin.settings.RoninSettingsNotifier { - override fun settingsChanged(settings: com.ronin.settings.RoninSettingsState) { - updateModelList() - } - }) - - // ... (Layout code similar to before) - chatPanel.layout = BoxLayout(chatPanel, BoxLayout.Y_AXIS) - chatPanel.background = com.intellij.util.ui.UIUtil.getListBackground() - chatPanel.border = BorderFactory.createEmptyBorder(10, 10, 10, 10) - - val wrapperPanel = JPanel(BorderLayout()) - wrapperPanel.add(chatPanel, BorderLayout.NORTH) - wrapperPanel.background = com.intellij.util.ui.UIUtil.getListBackground() - scrollPane.setViewportView(wrapperPanel) - mainPanel.add(scrollPane, BorderLayout.CENTER) - - // Bottom Area - val bottomPanel = JPanel(BorderLayout()) - bottomPanel.border = BorderFactory.createEmptyBorder(5, 5, 5, 5) - - val controlBar = JPanel(BorderLayout()) - controlBar.border = BorderFactory.createEmptyBorder(0, 0, 5, 0) - - modelComboBox.isOpaque = false - - val leftControls = JPanel() - leftControls.layout = BoxLayout(leftControls, BoxLayout.X_AXIS) - leftControls.add(modelComboBox) - controlBar.add(leftControls, BorderLayout.WEST) - - val rightControls = JPanel() - rightControls.layout = BoxLayout(rightControls, BoxLayout.X_AXIS) - - // Configure Action Button - actionButton.toolTipText = "Reset Chat" - actionButton.addActionListener { - if (isGenerating) { - cancelGeneration() - } else { - clearChat() - } - } - // Fix icon size or remove border for cleaner look? - // actionButton.border = BorderFactory.createEmptyBorder() - - attachButton.toolTipText = "Attach Image" - attachButton.addActionListener { attachImage() } - - rightControls.add(actionButton) - rightControls.add(Box.createHorizontalStrut(5)) - rightControls.add(attachButton) - - controlBar.add(rightControls, BorderLayout.EAST) - - bottomPanel.add(controlBar, BorderLayout.NORTH) - - // ... (Input Area setup) - inputArea.rows = 1 - inputArea.lineWrap = true - inputArea.wrapStyleWord = true - inputArea.border = BorderFactory.createCompoundBorder( - BorderFactory.createLineBorder(java.awt.Color.GRAY), - BorderFactory.createEmptyBorder(5, 5, 5, 5) - ) - - inputArea.addKeyListener(object : java.awt.event.KeyAdapter() { - override fun keyPressed(e: java.awt.event.KeyEvent) { - if (e.keyCode == java.awt.event.KeyEvent.VK_ENTER) { - e.consume() // Always consume specific Enter actions to prevent double-handling - if (e.isShiftDown) { - inputArea.replaceSelection("\n") - } else { - sendMessage() - } - } - } - }) - - inputArea.document.addDocumentListener(object : javax.swing.event.DocumentListener { - fun updateHeight() { - val rows = inputArea.lineCount.coerceAtMost(10).coerceAtLeast(1) - inputArea.rows = rows - bottomPanel.revalidate() - } - override fun insertUpdate(e: javax.swing.event.DocumentEvent?) = updateHeight() - override fun removeUpdate(e: javax.swing.event.DocumentEvent?) = updateHeight() - override fun changedUpdate(e: javax.swing.event.DocumentEvent?) = updateHeight() - }) - - bottomPanel.add(inputArea, BorderLayout.CENTER) - mainPanel.add(bottomPanel, BorderLayout.SOUTH) - - updateModelList() - - modelComboBox.addActionListener { - val selected = modelComboBox.selectedItem as? String - if (selected != null) { - com.ronin.settings.RoninSettingsState.instance.model = selected - } - } - - // Load History - val sessionService = project.service() - val savedHistory = sessionService.getHistory() - if (savedHistory.isNotEmpty()) { - messageHistory.addAll(savedHistory) - for (msg in savedHistory) { - val role = if (msg["role"] == "user") MyBundle.message("toolwindow.you") else MyBundle.message("toolwindow.ronin") - appendMessage(role, msg["content"] ?: "") - } - } - - // Initial State Update - updateActionButtonState() - } - - private fun updateActionButtonState() { - SwingUtilities.invokeLater { - if (isGenerating) { - actionButton.icon = com.intellij.icons.AllIcons.Actions.Suspend - actionButton.toolTipText = "Stop Generation" - } else { - actionButton.icon = com.intellij.icons.AllIcons.Actions.Refresh // Or GC/Trash - actionButton.toolTipText = "Reset Chat (Keep Settings)" - } - } - } - - private fun setGenerating(generating: Boolean) { - isGenerating = generating - updateActionButtonState() - - SwingUtilities.invokeLater { - inputArea.isEnabled = !generating - if (!generating) { - inputArea.requestFocusInWindow() - } - } - } - - private fun cancelGeneration() { - if (isGenerating && currentTask != null) { - currentTask?.cancel(true) - setGenerating(false) - appendMessage("System", "🛑 Request cancelled by user.") - } - } - - fun updateModelList() { - val settings = com.ronin.settings.RoninSettingsState.instance - val provider = settings.provider - val currentModel = settings.model - - val initialModels = arrayOf(currentModel.ifBlank { "Loading..." }) - modelComboBox.model = DefaultComboBoxModel(initialModels) - modelComboBox.isEnabled = false - - val llmService = project.service() - - ApplicationManager.getApplication().executeOnPooledThread { - val modelsList = if (provider == "OpenAI") { - try { - val fetched = llmService.fetchAvailableModels(provider) - if (fetched.isNotEmpty()) fetched else llmService.getAvailableModels(provider) - } catch (e: Exception) { - llmService.getAvailableModels(provider) - } - } else { - llmService.getAvailableModels(provider) - } - - SwingUtilities.invokeLater { - val models = modelsList.toTypedArray() - val model = DefaultComboBoxModel(models) - modelComboBox.model = model - modelComboBox.isEnabled = true - - if (models.contains(currentModel)) { - modelComboBox.selectedItem = currentModel - } else if (models.isNotEmpty()) { - modelComboBox.selectedItem = models[0] - com.ronin.settings.RoninSettingsState.instance.model = models[0] - } - } - } - } - - private fun sendMessage() { - val text = inputArea.text.trim() - if (text.isNotBlank()) { - appendMessage(MyBundle.message("toolwindow.you"), text) - inputArea.text = "" - setGenerating(true) - - // --- SLASH COMMAND INTERCEPTION --- - if (text.startsWith("/")) { - val commandService = project.service() - val cmdResult = commandService.executeCommand(text) - - when (cmdResult) { - is com.ronin.service.CommandResult.Action -> { - appendMessage("System", cmdResult.message) - setGenerating(false) - return - } - is com.ronin.service.CommandResult.Error -> { - appendMessage("System", "❌ ${cmdResult.message}") - setGenerating(false) - return - } - is com.ronin.service.CommandResult.PromptInjection -> { - appendMessage("System", "🔄 Loaded command context...") - processUserMessage(cmdResult.prompt) - return - } - } - } - processUserMessage(text) - } - } - - private fun processUserMessage(text: String) { - val configService = project.service() - val activeFile = configService.getActiveFileContent() - - currentTask = ApplicationManager.getApplication().executeOnPooledThread { - try { - if (Thread.interrupted()) throw InterruptedException() - val llmService = project.service() - - val sessionService = project.service() - - // Add to history BEFORE sending to LLM - messageHistory.add(mapOf("role" to "user", "content" to text)) - sessionService.addMessage("user", text) - - val projectStructure = ReadAction.compute { - configService.getProjectStructure() - } - - if (Thread.interrupted()) throw InterruptedException() - val contextBuilder = StringBuilder() - if (activeFile != null) { - contextBuilder.append("Active File Content:\n```\n$activeFile\n```\n\n") - } - contextBuilder.append(projectStructure) - - val response = llmService.sendMessage(text, contextBuilder.toString(), ArrayList(messageHistory)) - if (Thread.interrupted()) throw InterruptedException() - - val result = com.ronin.service.ResponseParser.parseAndApply(response, project) - - if (!result.scratchpad.isNullOrBlank()) { - appendMessage("Ronin Thinking", result.scratchpad) - } - - val displayedMsg = if (result.toolOutput != null) "${result.text}\n\n${result.toolOutput}" else result.text - appendMessage(MyBundle.message("toolwindow.ronin"), displayedMsg) - - // History Cleanup: Save ONLY the agent text, not the potentially huge tool output - messageHistory.add(mapOf("role" to "assistant", "content" to result.text)) - sessionService.addMessage("assistant", result.text) - - val command = result.commandToRun - if (command != null) { - appendMessage("System", "Running command: `$command`...") - executeCommandChain(command) - } else if (result.requiresFollowUp) { - val followUpPrompt = "The action was completed. Here is the output:\n```\n${result.toolOutput ?: result.text}\n```\nAnalyze this and continue." - val followUpSummary = "File action complete." - handleFollowUp(followUpPrompt, followUpSummary) - } else { - setGenerating(false) - } - } catch (e: InterruptedException) { - setGenerating(false) - } catch (e: Exception) { - appendMessage("System", "Error: ${e.message}") - setGenerating(false) - } - } - } - - private fun executeCommandChain(command: String) { - val task = ApplicationManager.getApplication().executeOnPooledThread { - try { - val terminalService = project.service() - SwingUtilities.invokeLater { addTerminalBlock(command) } - - val outputBuffer = StringBuilder() - val lock = Any() - var updateScheduled = false - - val output = terminalService.runCommand(command) { line -> - synchronized(lock) { - outputBuffer.append(line) - if (!updateScheduled) { - updateScheduled = true - val timer = Timer(100) { - val textToAppend: String - synchronized(lock) { - textToAppend = outputBuffer.toString() - outputBuffer.clear() - updateScheduled = false - } - if (textToAppend.isNotEmpty()) appendToLastTerminalBlock(textToAppend) - } - timer.isRepeats = false - timer.start() - } - } - } - - SwingUtilities.invokeLater { - val remaining: String - synchronized(lock) { remaining = outputBuffer.toString() } - if (remaining.isNotEmpty()) appendToLastTerminalBlock(remaining) - appendToLastTerminalBlock("\n[Finished]") - val followUpPrompt = "The output of your last command [`$command`] was:\n```\n$output\n```\nAnalyze this output and decide on the next step." - val summary = "Command output received." - handleFollowUp(followUpPrompt, summary) - } - } catch (e: Exception) { - setGenerating(false) - } - } - currentTask = task - } - - fun appendMessage(role: String, message: String) { - SwingUtilities.invokeLater { - val isMe = role == MyBundle.message("toolwindow.you") || role.contains("You") - val isSystem = role == "System" - val isThinking = role == "Ronin Thinking" - val rowPanel = JPanel(java.awt.GridBagLayout()) - rowPanel.isOpaque = false - val c = java.awt.GridBagConstraints() - c.gridx = 0 - c.gridy = 0 - c.weightx = 1.0 - c.fill = java.awt.GridBagConstraints.HORIZONTAL - if (isMe) { - c.anchor = java.awt.GridBagConstraints.EAST - c.insets = java.awt.Insets(0, 50, 0, 0) - } else { - c.anchor = java.awt.GridBagConstraints.WEST - c.insets = java.awt.Insets(0, 0, 0, 50) - } - val textArea = DynamicTextArea(message) - textArea.lineWrap = true - textArea.wrapStyleWord = true - textArea.isEditable = false - textArea.isOpaque = true - if (isMe) { - textArea.background = java.awt.Color(0, 122, 255) - textArea.foreground = java.awt.Color.WHITE - } else if (isSystem) { - textArea.background = java.awt.Color(40, 44, 52) - textArea.foreground = java.awt.Color(171, 178, 191) - textArea.font = java.awt.Font("JetBrains Mono", java.awt.Font.PLAIN, 12) - } else if (isThinking) { - textArea.background = com.intellij.util.ui.UIUtil.getPanelBackground() - textArea.foreground = java.awt.Color(150, 150, 150) - textArea.font = java.awt.Font(textArea.font.name, java.awt.Font.ITALIC, 11) - textArea.border = BorderFactory.createLineBorder(java.awt.Color(200, 200, 200, 50), 1) - } else { - textArea.background = com.intellij.util.ui.UIUtil.getPanelBackground() - textArea.foreground = com.intellij.util.ui.UIUtil.getLabelForeground() - textArea.border = BorderFactory.createLineBorder(java.awt.Color.GRAY, 1) - } - textArea.border = BorderFactory.createCompoundBorder(textArea.border, BorderFactory.createEmptyBorder(8, 12, 8, 12)) - val bubbleWrapper = JPanel(BorderLayout()) - bubbleWrapper.isOpaque = false - bubbleWrapper.add(textArea, BorderLayout.CENTER) - rowPanel.add(bubbleWrapper, c) - chatPanel.add(rowPanel) - chatPanel.add(Box.createVerticalStrut(10)) - chatPanel.revalidate() - val bar = scrollPane.verticalScrollBar - bar.value = bar.maximum - } - } - - inner class DynamicTextArea(text: String) : JTextArea(text) { - override fun getPreferredSize(): java.awt.Dimension { - val d = super.getPreferredSize() - val viewport = scrollPane.viewport - if (viewport != null) { - val maxW = (viewport.width * 0.85).toInt() - if (maxW > 100 && d.width > maxW) return java.awt.Dimension(maxW, d.height) - } - return d - } - override fun getScrollableTracksViewportWidth(): Boolean = true - override fun setBounds(x: Int, y: Int, width: Int, height: Int) { - val viewport = scrollPane.viewport - var w = width - if (viewport != null) { - val maxW = (viewport.width * 0.85).toInt() - if (w > maxW) w = maxW - } - super.setBounds(x, y, w, height) - } - } - - private var lastTerminalArea: JTextArea? = null - - private fun addTerminalBlock(command: String) { - SwingUtilities.invokeLater { - val rowPanel = JPanel(java.awt.FlowLayout(java.awt.FlowLayout.LEFT)) - rowPanel.isOpaque = false - val termPanel = JPanel(BorderLayout()) - termPanel.background = java.awt.Color(30, 30, 30) - termPanel.border = BorderFactory.createLineBorder(java.awt.Color.GRAY) - val header = JLabel(" $> $command") - header.foreground = java.awt.Color.GREEN - header.font = java.awt.Font("JetBrains Mono", java.awt.Font.BOLD, 12) - header.border = BorderFactory.createEmptyBorder(5, 5, 5, 5) - termPanel.add(header, BorderLayout.NORTH) - val outputArea = JTextArea() - outputArea.background = java.awt.Color(30, 30, 30) - outputArea.foreground = java.awt.Color.LIGHT_GRAY - outputArea.font = java.awt.Font("JetBrains Mono", java.awt.Font.PLAIN, 12) - outputArea.isEditable = false - outputArea.columns = 50 - outputArea.rows = 5 - termPanel.add(outputArea, BorderLayout.CENTER) - lastTerminalArea = outputArea - rowPanel.add(termPanel) - chatPanel.add(rowPanel) - chatPanel.add(Box.createVerticalStrut(10)) - chatPanel.revalidate() - val bar = scrollPane.verticalScrollBar - bar.value = bar.maximum - } - } - - private fun appendToLastTerminalBlock(text: String) { - lastTerminalArea?.append(text) - chatPanel.revalidate() - val bar = scrollPane.verticalScrollBar - bar.value = bar.maximum - } - - private fun handleFollowUp(text: String, displayLabel: String) { - appendMessage(MyBundle.message("toolwindow.you") + " (Auto)", displayLabel) - currentTask = ApplicationManager.getApplication().executeOnPooledThread { - try { - val llmService = project.service() - val configService = project.service() - val activeFile = configService.getActiveFileContent() - val projectStructure = ReadAction.compute { configService.getProjectStructure() } - val contextBuilder = StringBuilder() - if (activeFile != null) contextBuilder.append("Active File:\n```\n$activeFile\n```\n\n") - contextBuilder.append(projectStructure) - val response = llmService.sendMessage(text, contextBuilder.toString(), ArrayList(messageHistory)) - if (Thread.interrupted()) throw InterruptedException() - - val sessionService = project.service() - // Add to history BEFORE LLM call for handleFollowUp too - messageHistory.add(mapOf("role" to "user", "content" to text)) - sessionService.addMessage("user", text) - - val result = com.ronin.service.ResponseParser.parseAndApply(response, project) - - if (!result.scratchpad.isNullOrBlank()) { - appendMessage("Ronin Thinking", result.scratchpad) - } - - val displayedMsg = if (result.toolOutput != null) "${result.text}\n\n${result.toolOutput}" else result.text - - SwingUtilities.invokeLater { - appendMessage(MyBundle.message("toolwindow.ronin"), displayedMsg) - - // History Cleanup - messageHistory.add(mapOf("role" to "assistant", "content" to result.text)) - sessionService.addMessage("assistant", result.text) - - val nextCommand = result.commandToRun - if (nextCommand != null) { - executeCommandChain(nextCommand) - } else if (result.requiresFollowUp) { - val followUpPrompt = "The action was completed. Here is the output:\n```\n${result.toolOutput ?: result.text}\n```\nAnalyze this and continue." - val followUpSummary = "File action complete." - handleFollowUp(followUpPrompt, followUpSummary) - } else { - setGenerating(false) - } - } - } catch (e: Exception) { setGenerating(false) } - } - } - - - fun exportConversation() { - val descriptor = FileSaverDescriptor("Export Conversation", "Save conversation history", "json") - val baseDir = VfsUtil.getUserHomeDir() - val wrapper = FileChooserFactory.getInstance().createSaveFileDialog(descriptor, project) - val fileWrapper = wrapper.save(baseDir, "ronin_chat_export.json") - - if (fileWrapper != null) { - val file = fileWrapper.file - try { - val mapper = ObjectMapper().enable(SerializationFeature.INDENT_OUTPUT) - mapper.writeValue(file, messageHistory) - appendMessage("System", "✅ Conversation exported to: ${file.absolutePath}") - } catch (e: Exception) { - appendMessage("System", "❌ Failed to export: ${e.message}") - } - } - } - - fun clearChat() { - SwingUtilities.invokeLater { - chatPanel.removeAll() - chatPanel.revalidate() - chatPanel.repaint() - messageHistory.clear() - project.service().clearSession() - } - } - - private fun attachImage() { appendMessage("System", "[Image attached]") } - - fun getContent(): JComponent { - updateModelList() - return mainPanel - } - } } diff --git a/src/main/kotlin/com/ronin/ui/chat/ChatToolWindow.kt b/src/main/kotlin/com/ronin/ui/chat/ChatToolWindow.kt new file mode 100644 index 0000000..30d7e19 --- /dev/null +++ b/src/main/kotlin/com/ronin/ui/chat/ChatToolWindow.kt @@ -0,0 +1,419 @@ +package com.ronin.ui.chat + +import com.intellij.openapi.application.ApplicationManager +import com.intellij.openapi.components.service +import com.intellij.openapi.project.Project +import com.intellij.ui.components.JBScrollPane +import com.intellij.util.ui.UIUtil +import com.ronin.MyBundle +import com.ronin.service.LLMService +import com.ronin.service.TerminalService +import com.ronin.settings.RoninSettingsNotifier +import com.ronin.settings.RoninSettingsState +import com.ronin.ui.chat.api.RoninApi +import com.ronin.ui.chat.components.ChatInputField +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 javax.swing.* + +/** + * Main chat tool window - simplified to focus on UI coordination + */ +class ChatToolWindow(private val project: Project) { + + private val mainPanel = JPanel(BorderLayout()) + private val chatPanel = JPanel() + private val scrollPane = JBScrollPane(chatPanel) + + // Components + private lateinit var inputField: ChatInputField + private lateinit var controlBar: ControlBar + + // API + private val api = RoninApi(project) + + // State + private val messageHistory = mutableListOf>() + private var isGenerating = false + private var lastTerminalBlock: TerminalBlock? = null + + companion object { + private const val KEY = "RoninChatToolWindow" + + fun getInstance(project: Project): ChatToolWindow? { + val toolWindow = com.intellij.openapi.wm.ToolWindowManager.getInstance(project) + .getToolWindow("Ronin Chat") ?: return null + val content = toolWindow.contentManager.contents.firstOrNull() ?: return null + val component = content.component as? JComponent ?: return null + return component.getClientProperty(KEY) as? ChatToolWindow + } + } + + init { + mainPanel.putClientProperty(KEY, this) + setupUI() + connectToSettings() + loadHistory() + } + + private fun setupUI() { + // Setup chat panel + chatPanel.layout = BoxLayout(chatPanel, BoxLayout.Y_AXIS) + chatPanel.background = UIUtil.getListBackground() + chatPanel.border = BorderFactory.createEmptyBorder(10, 10, 10, 10) + + val wrapperPanel = JPanel(BorderLayout()) + wrapperPanel.add(chatPanel, BorderLayout.NORTH) + wrapperPanel.background = UIUtil.getListBackground() + scrollPane.setViewportView(wrapperPanel) + mainPanel.add(scrollPane, BorderLayout.CENTER) + + // Setup bottom panel with controls and input + val bottomPanel = JPanel(BorderLayout()) + bottomPanel.border = BorderFactory.createEmptyBorder(5, 5, 5, 5) + + // Control bar + controlBar = ControlBar( + onActionButtonClick = { handleActionButtonClick() }, + onAttachClick = { handleAttachImage() }, + onModelChange = { model -> + RoninSettingsState.instance.model = model + } + ) + bottomPanel.add(controlBar, BorderLayout.NORTH) + + // Input field + inputField = ChatInputField(project) { message -> + handleSendMessage(message) + } + bottomPanel.add(inputField, BorderLayout.CENTER) + + mainPanel.add(bottomPanel, BorderLayout.SOUTH) + + // Load models + updateModelList() + } + + private fun connectToSettings() { + val connection = ApplicationManager.getApplication().messageBus.connect(project) + connection.subscribe(RoninSettingsNotifier.TOPIC, object : RoninSettingsNotifier { + override fun settingsChanged(settings: RoninSettingsState) { + updateModelList() + } + }) + } + + private fun loadHistory() { + val savedHistory = api.getSessionHistory() + if (savedHistory.isNotEmpty()) { + messageHistory.addAll(savedHistory) + for (msg in savedHistory) { + val role = if (msg["role"] == "user") { + MyBundle.message("toolwindow.you") + } else { + MyBundle.message("toolwindow.ronin") + } + addMessageBubble(role, msg["content"] ?: "") + } + } + } + + /** + * Handles sending a user message + */ + private fun handleSendMessage(message: String) { + addUserMessage(message) + messageHistory.add(mapOf("role" to "user", "content" to message)) + + setGenerating(true) + + api.sendUserMessage( + message = message, + history = messageHistory, + onThinking = { thinking -> + addThinkingMessage(thinking) + }, + onResponse = { role, response -> + messageHistory.add(mapOf("role" to role, "content" to response)) + addAssistantMessage(response) + }, + onCommand = { command -> + addSystemMessage("Running command: `$command`...") + executeCommand(command) + }, + onFollowUp = { prompt, summary -> + handleFollowUp(prompt, summary) + }, + onError = { error -> + addSystemMessage("❌ Error: $error") + }, + onComplete = { + setGenerating(false) + } + ) + } + + /** + * Handles follow-up messages + */ + private fun handleFollowUp(prompt: String, summary: String) { + addUserMessage("$summary (Auto)", isAuto = true) + messageHistory.add(mapOf("role" to "user", "content" to prompt)) + + api.sendFollowUpMessage( + prompt = prompt, + history = messageHistory, + onThinking = { thinking -> + addThinkingMessage(thinking) + }, + onResponse = { role, response -> + messageHistory.add(mapOf("role" to role, "content" to response)) + addAssistantMessage(response) + }, + onCommand = { command -> + addSystemMessage("Running command: `$command`...") + executeCommand(command) + }, + onFollowUp = { nextPrompt, nextSummary -> + handleFollowUp(nextPrompt, nextSummary) + }, + onError = { error -> + addSystemMessage("❌ Error: $error") + }, + onComplete = { + setGenerating(false) + } + ) + } + + /** + * Executes a terminal command + */ + private fun executeCommand(command: String) { + SwingUtilities.invokeLater { + val terminalBlock = TerminalBlock(command) + lastTerminalBlock = terminalBlock + + chatPanel.add(terminalBlock) + chatPanel.add(Box.createVerticalStrut(10)) + chatPanel.revalidate() + scrollToBottom() + } + + ApplicationManager.getApplication().executeOnPooledThread { + try { + val terminalService = project.service() + val outputBuffer = StringBuilder() + val lock = Any() + var updateScheduled = false + + val output = terminalService.runCommand(command) { line -> + synchronized(lock) { + outputBuffer.append(line) + if (!updateScheduled) { + updateScheduled = true + val timer = Timer(100) { + val textToAppend: String + synchronized(lock) { + textToAppend = outputBuffer.toString() + outputBuffer.clear() + updateScheduled = false + } + if (textToAppend.isNotEmpty()) { + lastTerminalBlock?.appendOutput(textToAppend) + } + } + timer.isRepeats = false + timer.start() + } + } + } + + SwingUtilities.invokeLater { + val remaining: String + synchronized(lock) { remaining = outputBuffer.toString() } + if (remaining.isNotEmpty()) { + lastTerminalBlock?.appendOutput(remaining) + } + lastTerminalBlock?.markFinished() + + // Continue with follow-up + val followUpPrompt = "The output of your last command [`$command`] was:\n```\n$output\n```\nAnalyze this output and decide on the next step." + val summary = "Command output received." + handleFollowUp(followUpPrompt, summary) + } + } catch (e: Exception) { + SwingUtilities.invokeLater { + addSystemMessage("❌ Command failed: ${e.message}") + setGenerating(false) + } + } + } + } + + /** + * Handles action button click (Stop/Reset) + */ + private fun handleActionButtonClick() { + if (isGenerating) { + if (api.cancelCurrentTask()) { + addSystemMessage("🛑 Request cancelled by user.") + setGenerating(false) + } + } else { + clearChat() + } + } + + /** + * Handles attach image button click + */ + private fun handleAttachImage() { + addSystemMessage("[Image attachment feature coming soon]") + } + + /** + * Updates the model list + */ + fun updateModelList() { + val settings = RoninSettingsState.instance + val provider = settings.provider + val currentModel = settings.model + + controlBar.setModelsLoading(true) + + val llmService = project.service() + + ApplicationManager.getApplication().executeOnPooledThread { + val modelsList = if (provider == "OpenAI") { + try { + val fetched = llmService.fetchAvailableModels(provider) + if (fetched.isNotEmpty()) fetched else llmService.getAvailableModels(provider) + } catch (e: Exception) { + llmService.getAvailableModels(provider) + } + } else { + llmService.getAvailableModels(provider) + } + + SwingUtilities.invokeLater { + val models = modelsList.toTypedArray() + controlBar.updateModels(models, currentModel) + controlBar.setModelsLoading(false) + } + } + } + + /** + * Clears the chat + */ + fun clearChat() { + SwingUtilities.invokeLater { + chatPanel.removeAll() + chatPanel.revalidate() + chatPanel.repaint() + messageHistory.clear() + api.clearSession() + } + } + + /** + * Exports the conversation to a file + */ + fun exportConversation() { + val descriptor = com.intellij.openapi.fileChooser.FileSaverDescriptor( + "Export Conversation", + "Save conversation history", + "json" + ) + val baseDir = com.intellij.openapi.vfs.VfsUtil.getUserHomeDir() + val wrapper = com.intellij.openapi.fileChooser.FileChooserFactory.getInstance() + .createSaveFileDialog(descriptor, project) + val fileWrapper = wrapper.save(baseDir, "ronin_chat_export.json") + + if (fileWrapper != null) { + val file = fileWrapper.file + try { + val mapper = com.fasterxml.jackson.databind.ObjectMapper() + .enable(com.fasterxml.jackson.databind.SerializationFeature.INDENT_OUTPUT) + mapper.writeValue(file, messageHistory) + addSystemMessage("✅ Conversation exported to: ${file.absolutePath}") + } catch (e: Exception) { + addSystemMessage("❌ Failed to export: ${e.message}") + } + } + } + + // UI Helper Methods + + private fun setGenerating(generating: Boolean) { + isGenerating = generating + controlBar.setGenerating(generating) + inputField.setEnabled(!generating) + } + + private fun addUserMessage(message: String, isAuto: Boolean = false) { + val label = if (isAuto) { + "${MyBundle.message("toolwindow.you")} (Auto)" + } else { + MyBundle.message("toolwindow.you") + } + addMessageBubble(label, message) + } + + private fun addAssistantMessage(message: String) { + addMessageBubble(MyBundle.message("toolwindow.ronin"), message) + } + + private fun addSystemMessage(message: String) { + addMessageBubble("System", message) + } + + private fun addThinkingMessage(message: String) { + addMessageBubble("Ronin Thinking", message) + } + + private fun addMessageBubble(role: String, message: String) { + SwingUtilities.invokeLater { + val isUser = role.contains(MyBundle.message("toolwindow.you")) || role.contains("You") + val isSystem = role == "System" + 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) + } + + chatPanel.add(bubble) + chatPanel.add(Box.createVerticalStrut(10)) + chatPanel.revalidate() + scrollToBottom() + } + } + + private fun scrollToBottom() { + val bar = scrollPane.verticalScrollBar + bar.value = bar.maximum + } + + /** + * Gets the main panel content + */ + fun getContent(): JComponent { + return mainPanel + } + + /** + * Sends a message programmatically (for use by actions/commands) + * This is a simplified interface for external callers that don't need + * to handle all the callbacks + */ + fun sendMessageProgrammatically(message: String) { + handleSendMessage(message) + } + +} \ No newline at end of file diff --git a/src/main/kotlin/com/ronin/ui/chat/api/MessageHandler.kt b/src/main/kotlin/com/ronin/ui/chat/api/MessageHandler.kt new file mode 100644 index 0000000..464c1cb --- /dev/null +++ b/src/main/kotlin/com/ronin/ui/chat/api/MessageHandler.kt @@ -0,0 +1,40 @@ +package com.ronin.ui.chat.api + +import com.intellij.openapi.components.service +import com.intellij.openapi.project.Project +import com.ronin.service.CommandResult +import com.ronin.service.CommandService + +/** + * Handles processing of messages and slash commands + */ +class MessageHandler(private val project: Project) { + + /** + * Processes a user message and determines if it's a slash command + * Returns null if it's a regular message, or a CommandResult if it's a slash command + */ + fun processSlashCommand(message: String): CommandResult? { + if (!message.startsWith("/")) { + return null + } + + val commandService = project.service() + return commandService.executeCommand(message) + } + + /** + * Parses the LLM response and extracts relevant information + */ + fun parseResponse(response: String): LLMResponse { + val result = com.ronin.service.ResponseParser.parseAndApply(response, project) + + return LLMResponse( + text = result.text, + scratchpad = result.scratchpad, + toolOutput = result.toolOutput, + commandToRun = result.commandToRun, + requiresFollowUp = result.requiresFollowUp + ) + } +} diff --git a/src/main/kotlin/com/ronin/ui/chat/api/Models.kt b/src/main/kotlin/com/ronin/ui/chat/api/Models.kt new file mode 100644 index 0000000..90be532 --- /dev/null +++ b/src/main/kotlin/com/ronin/ui/chat/api/Models.kt @@ -0,0 +1,40 @@ +package com.ronin.ui.chat.api + +/** + * Represents a task to be sent to the LLM + */ +data class LLMTask( + val message: String, + val context: String, + val history: List> +) + +/** + * Represents the response from the LLM after processing + */ +data class LLMResponse( + val text: String, + val scratchpad: String? = null, + val toolOutput: String? = null, + val commandToRun: String? = null, + val requiresFollowUp: Boolean = false +) + +/** + * Types of messages in the chat + */ +enum class MessageType { + USER, + ASSISTANT, + SYSTEM, + THINKING +} + +/** + * Represents a chat message + */ +data class ChatMessage( + val role: String, + val content: String, + val type: MessageType +) diff --git a/src/main/kotlin/com/ronin/ui/chat/api/RoninApi.kt b/src/main/kotlin/com/ronin/ui/chat/api/RoninApi.kt new file mode 100644 index 0000000..a281266 --- /dev/null +++ b/src/main/kotlin/com/ronin/ui/chat/api/RoninApi.kt @@ -0,0 +1,159 @@ +package com.ronin.ui.chat.api + +import com.intellij.openapi.components.service +import com.intellij.openapi.project.Project +import com.ronin.service.AgentSessionService +import com.ronin.service.CommandResult +import java.util.concurrent.Future + +/** + * Main API facade for chat operations. + * Coordinates TaskBuilder, TaskExecutor, and MessageHandler to provide + * a simple interface for the UI layer. + */ +class RoninApi(private val project: Project) { + + private val taskBuilder = TaskBuilder(project) + private val taskExecutor = TaskExecutor(project) + private val messageHandler = MessageHandler(project) + + private var currentTask: Future<*>? = null + val isExecuting: Boolean + get() = currentTask != null && !currentTask!!.isDone && !currentTask!!.isCancelled + + /** + * Sends a user message and handles the response through callbacks + */ + fun sendUserMessage( + message: String, + history: List>, + onThinking: (String) -> Unit, + onResponse: (String, String) -> Unit, // (role, message) + onCommand: (String) -> Unit, + onFollowUp: (String, String) -> Unit, + onError: (String) -> Unit, + onComplete: () -> Unit + ) { + // Check for slash commands first + val commandResult = messageHandler.processSlashCommand(message) + if (commandResult != null) { + handleCommandResult(commandResult, onResponse, onError, onComplete, message) + return + } + + // Add user message to history + val sessionService = project.service() + sessionService.addMessage("user", message) + + // Build and execute task + val task = taskBuilder.buildUserMessageTask(message, history) + executeTaskWithHistory(task, sessionService, onThinking, onResponse, onCommand, onFollowUp, onError, onComplete) + } + + /** + * Sends a follow-up message (e.g., after command execution) + */ + fun sendFollowUpMessage( + prompt: String, + history: List>, + onThinking: (String) -> Unit, + onResponse: (String, String) -> Unit, + onCommand: (String) -> Unit, + onFollowUp: (String, String) -> Unit, + onError: (String) -> Unit, + onComplete: () -> Unit + ) { + val sessionService = project.service() + sessionService.addMessage("user", prompt) + + val task = taskBuilder.buildFollowUpTask(prompt, history) + executeTaskWithHistory(task, sessionService, onThinking, onResponse, onCommand, onFollowUp, onError, onComplete) + } + + /** + * Cancels the current task if one is running + */ + fun cancelCurrentTask(): Boolean { + if (isExecuting) { + taskExecutor.cancelTask(currentTask) + currentTask = null + return true + } + return false + } + + /** + * Clears the session history + */ + fun clearSession() { + val sessionService = project.service() + sessionService.clearSession() + } + + /** + * Gets the saved session history + */ + fun getSessionHistory(): List> { + val sessionService = project.service() + return sessionService.getHistory() + } + + // Private helper methods + + private fun executeTaskWithHistory( + task: LLMTask, + sessionService: AgentSessionService, + onThinking: (String) -> Unit, + onResponse: (String, String) -> Unit, + onCommand: (String) -> Unit, + onFollowUp: (String, String) -> Unit, + onError: (String) -> Unit, + onComplete: () -> Unit + ) { + currentTask = taskExecutor.executeTask( + task = task, + onThinking = onThinking, + onResponse = { response -> + // Extract just the text part for history (not tool output) + val textOnly = if (response.contains("\n\n")) { + response.substringBefore("\n\n") + } else { + response + } + sessionService.addMessage("assistant", textOnly) + onResponse("assistant", response) + }, + onCommand = onCommand, + onFollowUp = onFollowUp, + onError = onError, + onComplete = { + currentTask = null + onComplete() + } + ) + } + + private fun handleCommandResult( + result: CommandResult, + onResponse: (String, String) -> Unit, + onError: (String) -> Unit, + onComplete: () -> Unit, + originalMessage: String + ) { + when (result) { + is CommandResult.Action -> { + onResponse("System", result.message) + onComplete() + } + is CommandResult.Error -> { + onError(result.message) + onComplete() + } + is CommandResult.PromptInjection -> { + onResponse("System", "🔄 Loaded command context...") + // The UI should handle re-sending with the injected prompt + onComplete() + } + } + } +} \ No newline at end of file diff --git a/src/main/kotlin/com/ronin/ui/chat/api/TaskBuilder.kt b/src/main/kotlin/com/ronin/ui/chat/api/TaskBuilder.kt new file mode 100644 index 0000000..87bdd6a --- /dev/null +++ b/src/main/kotlin/com/ronin/ui/chat/api/TaskBuilder.kt @@ -0,0 +1,61 @@ +package com.ronin.ui.chat.api + +import com.intellij.openapi.application.ReadAction +import com.intellij.openapi.components.service +import com.intellij.openapi.project.Project + +/** + * Builds LLM tasks with appropriate context without the UI needing to know the details + */ +class TaskBuilder(private val project: Project) { + + /** + * Builds a task for a user message with full project context + */ + fun buildUserMessageTask( + message: String, + history: List> + ): LLMTask { + val context = buildContext() + return LLMTask( + message = message, + context = context, + history = history + ) + } + + /** + * Builds a task for a follow-up message (e.g., after command execution) + */ + fun buildFollowUpTask( + prompt: String, + history: List> + ): LLMTask { + val context = buildContext() + return LLMTask( + message = prompt, + context = context, + history = history + ) + } + + /** + * Builds the context string including active file and project structure + */ + private fun buildContext(): String { + val configService = project.service() + val activeFile = configService.getActiveFileContent() + + val projectStructure = ReadAction.compute { + configService.getProjectStructure() + } + + val contextBuilder = StringBuilder() + if (activeFile != null) { + contextBuilder.append("Active File Content:\n```\n$activeFile\n```\n\n") + } + contextBuilder.append(projectStructure) + + return contextBuilder.toString() + } +} diff --git a/src/main/kotlin/com/ronin/ui/chat/api/TaskExecutor.kt b/src/main/kotlin/com/ronin/ui/chat/api/TaskExecutor.kt new file mode 100644 index 0000000..513f650 --- /dev/null +++ b/src/main/kotlin/com/ronin/ui/chat/api/TaskExecutor.kt @@ -0,0 +1,101 @@ +package com.ronin.ui.chat.api + +import com.intellij.openapi.application.ApplicationManager +import com.intellij.openapi.components.service +import com.intellij.openapi.project.Project +import com.ronin.service.LLMService +import java.util.concurrent.Future + +/** + * Executes LLM tasks asynchronously with callbacks for different stages + */ +class TaskExecutor(private val project: Project) { + + /** + * Executes an LLM task asynchronously + * + * @param task The LLM task to execute + * @param onThinking Callback when the LLM is "thinking" (scratchpad content) + * @param onResponse Callback when the main response is received + * @param onCommand Callback when a command needs to be executed + * @param onFollowUp Callback when a follow-up is required (prompt, summary) + * @param onError Callback when an error occurs + * @param onComplete Callback when execution is complete (success or failure) + * @return Future that can be used to cancel the task + */ + fun executeTask( + task: LLMTask, + onThinking: (String) -> Unit, + onResponse: (String) -> Unit, + onCommand: (String) -> Unit, + onFollowUp: (String, String) -> Unit, + onError: (String) -> Unit, + onComplete: () -> Unit + ): Future<*> { + val llmService = project.service() + val messageHandler = MessageHandler(project) + + return ApplicationManager.getApplication().executeOnPooledThread { + try { + if (Thread.interrupted()) { + throw InterruptedException() + } + + // Send message to LLM + val response = llmService.sendMessage( + task.message, + task.context, + ArrayList(task.history) + ) + + if (Thread.interrupted()) { + throw InterruptedException() + } + + // Parse response + val llmResponse = messageHandler.parseResponse(response) + + // Handle scratchpad/thinking + if (!llmResponse.scratchpad.isNullOrBlank()) { + onThinking(llmResponse.scratchpad) + } + + // Handle main response + val displayText = if (llmResponse.toolOutput != null) { + "${llmResponse.text}\n\n${llmResponse.toolOutput}" + } else { + llmResponse.text + } + onResponse(displayText) + + // Handle follow-up actions + when { + llmResponse.commandToRun != null -> { + onCommand(llmResponse.commandToRun) + } + llmResponse.requiresFollowUp -> { + val followUpPrompt = "The action was completed. Here is the output:\n```\n${llmResponse.toolOutput ?: llmResponse.text}\n```\nAnalyze this and continue." + val followUpSummary = "File action complete." + onFollowUp(followUpPrompt, followUpSummary) + } + else -> { + onComplete() + } + } + + } catch (e: InterruptedException) { + onComplete() + } catch (e: Exception) { + onError(e.message ?: "Unknown error") + onComplete() + } + } + } + + /** + * Cancels a running task + */ + fun cancelTask(future: Future<*>?) { + future?.cancel(true) + } +} diff --git a/src/main/kotlin/com/ronin/ui/chat/components/ChatInputField.kt b/src/main/kotlin/com/ronin/ui/chat/components/ChatInputField.kt new file mode 100644 index 0000000..a5bf3ab --- /dev/null +++ b/src/main/kotlin/com/ronin/ui/chat/components/ChatInputField.kt @@ -0,0 +1,82 @@ +package com.ronin.ui.chat.components + +import com.intellij.openapi.editor.event.DocumentEvent +import com.intellij.openapi.editor.event.DocumentListener +import com.intellij.openapi.fileTypes.PlainTextFileType +import com.intellij.openapi.project.Project +import com.intellij.ui.EditorTextField +import java.awt.event.KeyAdapter +import java.awt.event.KeyEvent +import javax.swing.BorderFactory + +/** + * Improved chat input field using EditorTextField for better text handling + */ +class ChatInputField( + private val project: Project, + private val onSendMessage: (String) -> Unit +) : EditorTextField(project, PlainTextFileType.INSTANCE) { + + init { + setOneLineMode(false) + setPlaceholder("Type your message... (Enter to send, Shift+Enter for new line)") + + border = BorderFactory.createCompoundBorder( + BorderFactory.createLineBorder(java.awt.Color.GRAY), + BorderFactory.createEmptyBorder(5, 5, 5, 5) + ) + + // Handle Enter key for sending + addKeyListener() + + // Auto-adjust height based on content + addDocumentListener() + } + + private fun addKeyListener() { + addSettingsProvider { editor -> + editor.contentComponent.addKeyListener(object : KeyAdapter() { + override fun keyPressed(e: KeyEvent) { + if (e.keyCode == KeyEvent.VK_ENTER) { + if (!e.isShiftDown) { + e.consume() + sendMessage() + } + } + } + }) + } + } + + private fun addDocumentListener() { + addDocumentListener(object : DocumentListener { + override fun documentChanged(event: DocumentEvent) { + // Update preferred height based on line count + val lineCount = document.lineCount.coerceIn(1, 10) + preferredSize = java.awt.Dimension( + preferredSize.width, + lineCount * 20 + 10 // Approximate line height + ) + revalidate() + } + }) + } + + private fun sendMessage() { + val message = text.trim() + if (message.isNotBlank()) { + onSendMessage(message) + text = "" + } + } + + /** + * Enables or disables the input field + */ + override fun setEnabled(enabled: Boolean) { + isEnabled = enabled + if (enabled) { + requestFocusInWindow() + } + } +} diff --git a/src/main/kotlin/com/ronin/ui/chat/components/ControlBar.kt b/src/main/kotlin/com/ronin/ui/chat/components/ControlBar.kt new file mode 100644 index 0000000..c33b4b2 --- /dev/null +++ b/src/main/kotlin/com/ronin/ui/chat/components/ControlBar.kt @@ -0,0 +1,102 @@ +package com.ronin.ui.chat.components + +import com.intellij.icons.AllIcons +import com.intellij.openapi.ui.ComboBox +import java.awt.BorderLayout +import javax.swing.BorderFactory +import javax.swing.Box +import javax.swing.BoxLayout +import javax.swing.JButton +import javax.swing.JPanel +import javax.swing.DefaultComboBoxModel + +/** + * Control bar with model selector and action buttons + */ +class ControlBar( + private val onActionButtonClick: () -> Unit, + private val onAttachClick: () -> Unit, + private val onModelChange: (String) -> Unit +) : JPanel(BorderLayout()) { + + val modelComboBox = ComboBox() + private val actionButton = JButton(AllIcons.Actions.Refresh) + private val attachButton = JButton(AllIcons.FileTypes.Any_type) + + private var isGenerating = false + + init { + border = BorderFactory.createEmptyBorder(0, 0, 5, 0) + createControls() + } + + private fun createControls() { + modelComboBox.isOpaque = false + modelComboBox.addActionListener { + val selected = modelComboBox.selectedItem as? String + if (selected != null) { + onModelChange(selected) + } + } + + val leftControls = JPanel() + leftControls.layout = BoxLayout(leftControls, BoxLayout.X_AXIS) + leftControls.add(modelComboBox) + add(leftControls, BorderLayout.WEST) + + val rightControls = JPanel() + rightControls.layout = BoxLayout(rightControls, BoxLayout.X_AXIS) + + actionButton.toolTipText = "Reset Chat" + actionButton.addActionListener { onActionButtonClick() } + + attachButton.toolTipText = "Attach Image" + attachButton.addActionListener { onAttachClick() } + + rightControls.add(actionButton) + rightControls.add(Box.createHorizontalStrut(5)) + rightControls.add(attachButton) + + add(rightControls, BorderLayout.EAST) + } + + /** + * Updates the model list + */ + fun updateModels(models: Array, selectedModel: String?) { + val model = DefaultComboBoxModel(models) + modelComboBox.model = model + + if (selectedModel != null && models.contains(selectedModel)) { + modelComboBox.selectedItem = selectedModel + } else if (models.isNotEmpty()) { + modelComboBox.selectedItem = models[0] + } + } + + /** + * Sets whether models are being loaded + */ + fun setModelsLoading(loading: Boolean) { + if (loading) { + modelComboBox.model = DefaultComboBoxModel(arrayOf("Loading...")) + modelComboBox.isEnabled = false + } else { + modelComboBox.isEnabled = true + } + } + + /** + * Updates the state of the action button based on generation status + */ + fun setGenerating(generating: Boolean) { + isGenerating = generating + if (generating) { + actionButton.icon = AllIcons.Actions.Suspend + actionButton.toolTipText = "Stop Generation" + } else { + actionButton.icon = AllIcons.Actions.Refresh + actionButton.toolTipText = "Reset Chat (Keep Settings)" + } + } +} diff --git a/src/main/kotlin/com/ronin/ui/chat/components/MessageBubble.kt b/src/main/kotlin/com/ronin/ui/chat/components/MessageBubble.kt new file mode 100644 index 0000000..bf2e96e --- /dev/null +++ b/src/main/kotlin/com/ronin/ui/chat/components/MessageBubble.kt @@ -0,0 +1,158 @@ +package com.ronin.ui.chat.components + +import com.intellij.util.ui.UIUtil +import com.ronin.MyBundle +import java.awt.BorderLayout +import java.awt.Color +import java.awt.Font +import java.awt.GridBagConstraints +import java.awt.GridBagLayout +import java.awt.Insets +import javax.swing.BorderFactory +import javax.swing.JPanel +import javax.swing.JTextArea +import javax.swing.JComponent + +/** + * Represents different types of chat messages + */ +enum class BubbleType { + USER, + ASSISTANT, + SYSTEM, + THINKING +} + +/** + * A message bubble component for the chat interface + */ +class MessageBubble private constructor( + private val type: BubbleType, + private val message: String, + private val maxWidth: Int +) : JPanel(GridBagLayout()) { + + init { + isOpaque = false + createBubble() + } + + private fun createBubble() { + val c = GridBagConstraints() + c.gridx = 0 + c.gridy = 0 + 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) + } + } + + val textArea = createTextArea() + val bubbleWrapper = JPanel(BorderLayout()) + bubbleWrapper.isOpaque = false + bubbleWrapper.add(textArea, BorderLayout.CENTER) + + add(bubbleWrapper, c) + } + + private fun createTextArea(): JTextArea { + val textArea = DynamicTextArea(message, maxWidth) + textArea.lineWrap = true + textArea.wrapStyleWord = true + textArea.isEditable = false + textArea.isOpaque = true + + when (type) { + BubbleType.USER -> { + textArea.background = Color(0, 122, 255) + textArea.foreground = Color.WHITE + } + BubbleType.SYSTEM -> { + textArea.background = Color(40, 44, 52) + textArea.foreground = Color(171, 178, 191) + textArea.font = Font("JetBrains Mono", Font.PLAIN, 12) + } + BubbleType.THINKING -> { + textArea.background = UIUtil.getPanelBackground() + textArea.foreground = Color(150, 150, 150) + textArea.font = Font(textArea.font.name, Font.ITALIC, 11) + textArea.border = BorderFactory.createLineBorder(Color(200, 200, 200, 50), 1) + } + BubbleType.ASSISTANT -> { + textArea.background = UIUtil.getPanelBackground() + textArea.foreground = UIUtil.getLabelForeground() + textArea.border = BorderFactory.createLineBorder(Color.GRAY, 1) + } + } + + textArea.border = BorderFactory.createCompoundBorder( + textArea.border, + BorderFactory.createEmptyBorder(8, 12, 8, 12) + ) + + 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) + } + + /** + * Creates an assistant message bubble + */ + fun createAssistantMessage(message: String, maxWidth: Int = 600): JComponent { + return MessageBubble(BubbleType.ASSISTANT, message, maxWidth) + } + + /** + * Creates a system message bubble + */ + fun createSystemMessage(message: String, maxWidth: Int = 600): JComponent { + return MessageBubble(BubbleType.SYSTEM, message, maxWidth) + } + + /** + * Creates a thinking message bubble + */ + fun createThinkingMessage(message: String, maxWidth: Int = 600): JComponent { + return MessageBubble(BubbleType.THINKING, message, maxWidth) + } + } +} diff --git a/src/main/kotlin/com/ronin/ui/chat/components/TerminalBlock.kt b/src/main/kotlin/com/ronin/ui/chat/components/TerminalBlock.kt new file mode 100644 index 0000000..931f9e8 --- /dev/null +++ b/src/main/kotlin/com/ronin/ui/chat/components/TerminalBlock.kt @@ -0,0 +1,67 @@ +package com.ronin.ui.chat.components + +import java.awt.BorderLayout +import java.awt.Color +import java.awt.FlowLayout +import java.awt.Font +import javax.swing.BorderFactory +import javax.swing.JLabel +import javax.swing.JPanel +import javax.swing.JTextArea + +/** + * Terminal block component for displaying command output + */ +class TerminalBlock(private val command: String) : JPanel(FlowLayout(FlowLayout.LEFT)) { + + private val termPanel = JPanel(BorderLayout()) + private val outputArea = JTextArea() + + init { + isOpaque = false + createTerminalPanel() + } + + private fun createTerminalPanel() { + termPanel.background = Color(30, 30, 30) + termPanel.border = BorderFactory.createLineBorder(Color.GRAY) + + // Header with command + val header = JLabel(" \$ $command") + header.foreground = Color.GREEN + header.font = Font("JetBrains Mono", Font.BOLD, 12) + header.border = BorderFactory.createEmptyBorder(5, 5, 5, 5) + termPanel.add(header, BorderLayout.NORTH) + + // Output area + outputArea.background = Color(30, 30, 30) + outputArea.foreground = Color.LIGHT_GRAY + outputArea.font = Font("JetBrains Mono", Font.PLAIN, 12) + outputArea.isEditable = false + outputArea.columns = 50 + outputArea.rows = 5 + termPanel.add(outputArea, BorderLayout.CENTER) + + add(termPanel) + } + + /** + * Appends text to the output area + */ + fun appendOutput(text: String) { + outputArea.append(text) + outputArea.caretPosition = outputArea.document.length + } + + /** + * Marks the command as finished + */ + fun markFinished() { + appendOutput("\n[Finished]") + } + + /** + * Gets the current output text + */ + fun getOutput(): String = outputArea.text +} From 246606879683d5f6bc6abab992e33be42dfc50f6 Mon Sep 17 00:00:00 2001 From: hymoon Date: Mon, 26 Jan 2026 15:50:01 -0600 Subject: [PATCH 2/7] Implement chat API and UI components for LLM interaction --- .../com/ronin/ui/chat/ChatToolWindow.kt | 9 +- .../com/ronin/ui/chat/api/TaskExecutor.kt | 3 +- .../ui/chat/components/ChatInputField.kt | 133 ++++++++++-------- .../ronin/ui/chat/components/ControlBar.kt | 39 +++-- 4 files changed, 108 insertions(+), 76 deletions(-) diff --git a/src/main/kotlin/com/ronin/ui/chat/ChatToolWindow.kt b/src/main/kotlin/com/ronin/ui/chat/ChatToolWindow.kt index 30d7e19..8ec5d75 100644 --- a/src/main/kotlin/com/ronin/ui/chat/ChatToolWindow.kt +++ b/src/main/kotlin/com/ronin/ui/chat/ChatToolWindow.kt @@ -46,7 +46,7 @@ class ChatToolWindow(private val project: Project) { val toolWindow = com.intellij.openapi.wm.ToolWindowManager.getInstance(project) .getToolWindow("Ronin Chat") ?: return null val content = toolWindow.contentManager.contents.firstOrNull() ?: return null - val component = content.component as? JComponent ?: return null + val component = content.component return component.getClientProperty(KEY) as? ChatToolWindow } } @@ -91,8 +91,7 @@ class ChatToolWindow(private val project: Project) { bottomPanel.add(inputField, BorderLayout.CENTER) mainPanel.add(bottomPanel, BorderLayout.SOUTH) - - // Load models + updateModelList() } @@ -290,8 +289,8 @@ class ChatToolWindow(private val project: Project) { val modelsList = if (provider == "OpenAI") { try { val fetched = llmService.fetchAvailableModels(provider) - if (fetched.isNotEmpty()) fetched else llmService.getAvailableModels(provider) - } catch (e: Exception) { + fetched.ifEmpty { llmService.getAvailableModels(provider) } + } catch (_: Exception) { llmService.getAvailableModels(provider) } } else { diff --git a/src/main/kotlin/com/ronin/ui/chat/api/TaskExecutor.kt b/src/main/kotlin/com/ronin/ui/chat/api/TaskExecutor.kt index 513f650..01fce6d 100644 --- a/src/main/kotlin/com/ronin/ui/chat/api/TaskExecutor.kt +++ b/src/main/kotlin/com/ronin/ui/chat/api/TaskExecutor.kt @@ -40,8 +40,7 @@ class TaskExecutor(private val project: Project) { if (Thread.interrupted()) { throw InterruptedException() } - - // Send message to LLM + val response = llmService.sendMessage( task.message, task.context, 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 a5bf3ab..17d520a 100644 --- a/src/main/kotlin/com/ronin/ui/chat/components/ChatInputField.kt +++ b/src/main/kotlin/com/ronin/ui/chat/components/ChatInputField.kt @@ -1,82 +1,103 @@ package com.ronin.ui.chat.components +import com.intellij.openapi.actionSystem.AnActionEvent +import com.intellij.openapi.actionSystem.CustomShortcutSet +import com.intellij.openapi.application.ApplicationManager import com.intellij.openapi.editor.event.DocumentEvent import com.intellij.openapi.editor.event.DocumentListener -import com.intellij.openapi.fileTypes.PlainTextFileType +import com.intellij.openapi.project.DumbAwareAction import com.intellij.openapi.project.Project import com.intellij.ui.EditorTextField -import java.awt.event.KeyAdapter +import com.intellij.ui.JBColor +import com.intellij.util.ui.JBUI +import java.awt.Dimension import java.awt.event.KeyEvent -import javax.swing.BorderFactory +import javax.swing.KeyStroke +import javax.swing.ScrollPaneConstants +import javax.swing.SwingUtilities -/** - * Improved chat input field using EditorTextField for better text handling - */ class ChatInputField( - private val project: Project, + project: Project, private val onSendMessage: (String) -> Unit -) : EditorTextField(project, PlainTextFileType.INSTANCE) { - +) : EditorTextField(project, com.intellij.openapi.fileTypes.PlainTextFileType.INSTANCE) { + + private val MAX_VISIBLE_LINES = 15 + private val MIN_HEIGHT = 100 + init { setOneLineMode(false) - setPlaceholder("Type your message... (Enter to send, Shift+Enter for new line)") - - border = BorderFactory.createCompoundBorder( - BorderFactory.createLineBorder(java.awt.Color.GRAY), - BorderFactory.createEmptyBorder(5, 5, 5, 5) + setPlaceholder("Type a message... (Enter to send, Shift+Enter for newline)") + + border = JBUI.Borders.compound( + JBUI.Borders.customLine(JBColor.border(), 1), + JBUI.Borders.empty(4) ) - - // Handle Enter key for sending - addKeyListener() - - // Auto-adjust height based on content - addDocumentListener() - } - - private fun addKeyListener() { + addSettingsProvider { editor -> - editor.contentComponent.addKeyListener(object : KeyAdapter() { - override fun keyPressed(e: KeyEvent) { - if (e.keyCode == KeyEvent.VK_ENTER) { - if (!e.isShiftDown) { - e.consume() - sendMessage() - } - } - } - }) + editor.settings.apply { + isUseSoftWraps = true + isLineNumbersShown = false + isFoldingOutlineShown = false + isRightMarginShown = false + isVirtualSpace = false + isAdditionalPageAtBottom = false + } + editor.scrollPane.verticalScrollBarPolicy = ScrollPaneConstants.VERTICAL_SCROLLBAR_AS_NEEDED + editor.scrollPane.border = JBUI.Borders.empty() + editor.backgroundColor = JBColor.namedColor("Editor.background", JBColor.WHITE) } - } - - private fun addDocumentListener() { + addDocumentListener(object : DocumentListener { override fun documentChanged(event: DocumentEvent) { - // Update preferred height based on line count - val lineCount = document.lineCount.coerceIn(1, 10) - preferredSize = java.awt.Dimension( - preferredSize.width, - lineCount * 20 + 10 // Approximate line height - ) - revalidate() + SwingUtilities.invokeLater { + revalidate() + repaint() + } } }) + + val sendAction = object : DumbAwareAction() { + override fun actionPerformed(e: AnActionEvent) { + sendMessage() + } + } + sendAction.registerCustomShortcutSet( + CustomShortcutSet(KeyStroke.getKeyStroke(KeyEvent.VK_ENTER, 0)), + this + ) } - + + /** + * SOLUCIÓN TÉCNICA: + * Sobrescribimos getPreferredSize. Esta es la fuente de la verdad para los Layout Managers. + * Cada vez que Swing recalcula el layout (llamado por revalidate), entra aquí. + */ + override fun getPreferredSize(): Dimension { + val d = super.getPreferredSize() + + val editor = this.editor ?: return d + + val lineHeight = editor.lineHeight + val lineCount = document.lineCount.coerceAtLeast(1) + + val linesToShow = lineCount.coerceAtMost(MAX_VISIBLE_LINES) + + val insets = insets + val contentHeight = (linesToShow * lineHeight) + insets.top + insets.bottom + 4 + + return Dimension(d.width, contentHeight.coerceAtLeast(MIN_HEIGHT)) + } + private fun sendMessage() { val message = text.trim() if (message.isNotBlank()) { onSendMessage(message) - text = "" - } - } - - /** - * Enables or disables the input field - */ - override fun setEnabled(enabled: Boolean) { - isEnabled = enabled - if (enabled) { - requestFocusInWindow() + ApplicationManager.getApplication().invokeLater { + text = "" + // Forzar revalidación al limpiar para volver al tamaño mínimo + revalidate() + repaint() + } } } -} +} \ No newline at end of file diff --git a/src/main/kotlin/com/ronin/ui/chat/components/ControlBar.kt b/src/main/kotlin/com/ronin/ui/chat/components/ControlBar.kt index c33b4b2..4a7c7d7 100644 --- a/src/main/kotlin/com/ronin/ui/chat/components/ControlBar.kt +++ b/src/main/kotlin/com/ronin/ui/chat/components/ControlBar.kt @@ -2,7 +2,10 @@ package com.ronin.ui.chat.components import com.intellij.icons.AllIcons import com.intellij.openapi.ui.ComboBox +import com.intellij.util.ui.JBUI import java.awt.BorderLayout +import java.awt.GridBagConstraints +import java.awt.GridBagLayout import javax.swing.BorderFactory import javax.swing.Box import javax.swing.BoxLayout @@ -17,7 +20,7 @@ class ControlBar( private val onActionButtonClick: () -> Unit, private val onAttachClick: () -> Unit, private val onModelChange: (String) -> Unit -) : JPanel(BorderLayout()) { +) : JPanel(GridBagLayout()) { val modelComboBox = ComboBox() private val actionButton = JButton(AllIcons.Actions.Refresh) @@ -26,7 +29,7 @@ class ControlBar( private var isGenerating = false init { - border = BorderFactory.createEmptyBorder(0, 0, 5, 0) + border = JBUI.Borders.emptyBottom(5) createControls() } @@ -38,26 +41,36 @@ class ControlBar( onModelChange(selected) } } - - val leftControls = JPanel() - leftControls.layout = BoxLayout(leftControls, BoxLayout.X_AXIS) - leftControls.add(modelComboBox) - add(leftControls, BorderLayout.WEST) - + val gbc = GridBagConstraints() + + gbc.gridx = 0 + gbc.gridy = 0 + gbc.weightx = 1.0 + gbc.fill = GridBagConstraints.HORIZONTAL + gbc.anchor = GridBagConstraints.WEST + add(modelComboBox, gbc) + val rightControls = JPanel() rightControls.layout = BoxLayout(rightControls, BoxLayout.X_AXIS) - + actionButton.toolTipText = "Reset Chat" actionButton.addActionListener { onActionButtonClick() } - + attachButton.toolTipText = "Attach Image" attachButton.addActionListener { onAttachClick() } - + rightControls.add(actionButton) rightControls.add(Box.createHorizontalStrut(5)) rightControls.add(attachButton) - - add(rightControls, BorderLayout.EAST) + + gbc.gridx = 1 + gbc.gridy = 0 + gbc.weightx = 0.0 + gbc.fill = GridBagConstraints.NONE + gbc.anchor = GridBagConstraints.EAST + gbc.insets = JBUI.insetsLeft(10) + + add(rightControls, gbc) } /** From e3d0f944201f552d0e0d95de4f66a2de050a19c7 Mon Sep 17 00:00:00 2001 From: hymoon Date: Mon, 26 Jan 2026 15:57:25 -0600 Subject: [PATCH 3/7] Implement chat API and UI components for LLM interaction --- src/main/kotlin/com/ronin/ui/chat/ChatToolWindow.kt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/main/kotlin/com/ronin/ui/chat/ChatToolWindow.kt b/src/main/kotlin/com/ronin/ui/chat/ChatToolWindow.kt index 8ec5d75..ea3ab50 100644 --- a/src/main/kotlin/com/ronin/ui/chat/ChatToolWindow.kt +++ b/src/main/kotlin/com/ronin/ui/chat/ChatToolWindow.kt @@ -279,7 +279,7 @@ class ChatToolWindow(private val project: Project) { fun updateModelList() { val settings = RoninSettingsState.instance val provider = settings.provider - val currentModel = settings.model + val currentModel = settings.activeStance controlBar.setModelsLoading(true) From c4bd630156eb9c16c69c00fdff73870e48f38286 Mon Sep 17 00:00:00 2001 From: hymoon Date: Mon, 26 Jan 2026 16:49:44 -0600 Subject: [PATCH 4/7] Implement chat API and UI components for LLM interaction --- .../com/ronin/ui/chat/ChatToolWindow.kt | 28 +++----- .../com/ronin/ui/chat/api/TaskBuilder.kt | 4 +- .../com/ronin/ui/chat/api/TaskExecutor.kt | 69 ++++++++++++------- 3 files changed, 57 insertions(+), 44 deletions(-) diff --git a/src/main/kotlin/com/ronin/ui/chat/ChatToolWindow.kt b/src/main/kotlin/com/ronin/ui/chat/ChatToolWindow.kt index ea3ab50..b696b05 100644 --- a/src/main/kotlin/com/ronin/ui/chat/ChatToolWindow.kt +++ b/src/main/kotlin/com/ronin/ui/chat/ChatToolWindow.kt @@ -6,7 +6,6 @@ import com.intellij.openapi.project.Project import com.intellij.ui.components.JBScrollPane import com.intellij.util.ui.UIUtil import com.ronin.MyBundle -import com.ronin.service.LLMService import com.ronin.service.TerminalService import com.ronin.settings.RoninSettingsNotifier import com.ronin.settings.RoninSettingsState @@ -79,7 +78,7 @@ class ChatToolWindow(private val project: Project) { onActionButtonClick = { handleActionButtonClick() }, onAttachClick = { handleAttachImage() }, onModelChange = { model -> - RoninSettingsState.instance.model = model + RoninSettingsState.instance.activeStance = model } ) bottomPanel.add(controlBar, BorderLayout.NORTH) @@ -147,6 +146,7 @@ class ChatToolWindow(private val project: Project) { }, onError = { error -> addSystemMessage("❌ Error: $error") + setGenerating(false) }, onComplete = { setGenerating(false) @@ -180,6 +180,7 @@ class ChatToolWindow(private val project: Project) { }, onError = { error -> addSystemMessage("❌ Error: $error") + setGenerating(false) }, onComplete = { setGenerating(false) @@ -274,32 +275,21 @@ class ChatToolWindow(private val project: Project) { } /** - * Updates the model list + * Updates the stance list (replaced old model list) */ fun updateModelList() { val settings = RoninSettingsState.instance - val provider = settings.provider - val currentModel = settings.activeStance + val currentStance = settings.activeStance controlBar.setModelsLoading(true) - val llmService = project.service() - ApplicationManager.getApplication().executeOnPooledThread { - val modelsList = if (provider == "OpenAI") { - try { - val fetched = llmService.fetchAvailableModels(provider) - fetched.ifEmpty { llmService.getAvailableModels(provider) } - } catch (_: Exception) { - llmService.getAvailableModels(provider) - } - } else { - llmService.getAvailableModels(provider) - } + // Get all available stances + val stanceNames = settings.stances.map { it.name } SwingUtilities.invokeLater { - val models = modelsList.toTypedArray() - controlBar.updateModels(models, currentModel) + val stances = stanceNames.toTypedArray() + controlBar.updateModels(stances, currentStance) controlBar.setModelsLoading(false) } } diff --git a/src/main/kotlin/com/ronin/ui/chat/api/TaskBuilder.kt b/src/main/kotlin/com/ronin/ui/chat/api/TaskBuilder.kt index 87bdd6a..717bda0 100644 --- a/src/main/kotlin/com/ronin/ui/chat/api/TaskBuilder.kt +++ b/src/main/kotlin/com/ronin/ui/chat/api/TaskBuilder.kt @@ -3,6 +3,7 @@ package com.ronin.ui.chat.api import com.intellij.openapi.application.ReadAction import com.intellij.openapi.components.service import com.intellij.openapi.project.Project +import com.intellij.util.concurrency.AppExecutorUtil /** * Builds LLM tasks with appropriate context without the UI needing to know the details @@ -44,8 +45,9 @@ class TaskBuilder(private val project: Project) { */ private fun buildContext(): String { val configService = project.service() + val activeFile = configService.getActiveFileContent() - + val projectStructure = ReadAction.compute { configService.getProjectStructure() } diff --git a/src/main/kotlin/com/ronin/ui/chat/api/TaskExecutor.kt b/src/main/kotlin/com/ronin/ui/chat/api/TaskExecutor.kt index 01fce6d..8c1a086 100644 --- a/src/main/kotlin/com/ronin/ui/chat/api/TaskExecutor.kt +++ b/src/main/kotlin/com/ronin/ui/chat/api/TaskExecutor.kt @@ -8,6 +8,7 @@ import java.util.concurrent.Future /** * Executes LLM tasks asynchronously with callbacks for different stages + * All callbacks are executed on the EDT to ensure thread safety */ class TaskExecutor(private val project: Project) { @@ -15,12 +16,12 @@ class TaskExecutor(private val project: Project) { * Executes an LLM task asynchronously * * @param task The LLM task to execute - * @param onThinking Callback when the LLM is "thinking" (scratchpad content) - * @param onResponse Callback when the main response is received - * @param onCommand Callback when a command needs to be executed - * @param onFollowUp Callback when a follow-up is required (prompt, summary) - * @param onError Callback when an error occurs - * @param onComplete Callback when execution is complete (success or failure) + * @param onThinking Callback when the LLM is "thinking" (scratchpad content) - RUNS ON EDT + * @param onResponse Callback when the main response is received - RUNS ON EDT + * @param onCommand Callback when a command needs to be executed - RUNS ON EDT + * @param onFollowUp Callback when a follow-up is required (prompt, summary) - RUNS ON EDT + * @param onError Callback when an error occurs - RUNS ON EDT + * @param onComplete Callback when execution is complete (success or failure) - RUNS ON EDT * @return Future that can be used to cancel the task */ fun executeTask( @@ -50,43 +51,54 @@ class TaskExecutor(private val project: Project) { if (Thread.interrupted()) { throw InterruptedException() } - - // Parse response + val llmResponse = messageHandler.parseResponse(response) - - // Handle scratchpad/thinking - if (!llmResponse.scratchpad.isNullOrBlank()) { - onThinking(llmResponse.scratchpad) - } - - // Handle main response + val displayText = if (llmResponse.toolOutput != null) { "${llmResponse.text}\n\n${llmResponse.toolOutput}" } else { llmResponse.text } - onResponse(displayText) - - // Handle follow-up actions + + if (!llmResponse.scratchpad.isNullOrBlank()) { + runOnEdt { + onThinking(llmResponse.scratchpad) + } + } + + runOnEdt { + onResponse(displayText) + } + when { llmResponse.commandToRun != null -> { - onCommand(llmResponse.commandToRun) + runOnEdt { + onCommand(llmResponse.commandToRun) + } } llmResponse.requiresFollowUp -> { val followUpPrompt = "The action was completed. Here is the output:\n```\n${llmResponse.toolOutput ?: llmResponse.text}\n```\nAnalyze this and continue." val followUpSummary = "File action complete." - onFollowUp(followUpPrompt, followUpSummary) + runOnEdt { + onFollowUp(followUpPrompt, followUpSummary) + } } else -> { - onComplete() + runOnEdt { + onComplete() + } } } } catch (e: InterruptedException) { - onComplete() + runOnEdt { + onComplete() + } } catch (e: Exception) { - onError(e.message ?: "Unknown error") - onComplete() + runOnEdt { + onError(e.message ?: "Unknown error") + onComplete() + } } } } @@ -97,4 +109,13 @@ class TaskExecutor(private val project: Project) { fun cancelTask(future: Future<*>?) { future?.cancel(true) } + + /** + * Executes an action on the EDT (Event Dispatch Thread) + * This is required for any UI updates in IntelliJ Platform + */ + private fun runOnEdt(action: () -> Unit) { + ApplicationManager.getApplication().invokeLater(action) + } + } From 34948a67fa36678469dcbe570a21935be1a85a36 Mon Sep 17 00:00:00 2001 From: hymoon Date: Mon, 26 Jan 2026 17:00:00 -0600 Subject: [PATCH 5/7] refactor: Simplify chat message handling and clean up UI interaction logic --- src/main/kotlin/com/ronin/actions/BaseRoninAction.kt | 4 ---- src/main/kotlin/com/ronin/ui/chat/api/RoninApi.kt | 1 - 2 files changed, 5 deletions(-) diff --git a/src/main/kotlin/com/ronin/actions/BaseRoninAction.kt b/src/main/kotlin/com/ronin/actions/BaseRoninAction.kt index d315ff5..b85560d 100644 --- a/src/main/kotlin/com/ronin/actions/BaseRoninAction.kt +++ b/src/main/kotlin/com/ronin/actions/BaseRoninAction.kt @@ -30,14 +30,10 @@ abstract class BaseRoninAction : AnAction() { val prompt = getPrompt(selectedText) - // 1. Open the Tool Window if not open val toolWindow = ToolWindowManager.getInstance(project).getToolWindow("Ronin Chat") toolWindow?.show { - // 2. Get ChatToolWindow instance and send message val chatWindow = ChatToolWindow.getInstance(project) if (chatWindow != null) { - // Use the new API to send the message - // This will handle everything: adding to UI, calling LLM, processing response, etc. chatWindow.sendMessageProgrammatically(prompt) } } diff --git a/src/main/kotlin/com/ronin/ui/chat/api/RoninApi.kt b/src/main/kotlin/com/ronin/ui/chat/api/RoninApi.kt index a281266..dfc55a5 100644 --- a/src/main/kotlin/com/ronin/ui/chat/api/RoninApi.kt +++ b/src/main/kotlin/com/ronin/ui/chat/api/RoninApi.kt @@ -151,7 +151,6 @@ class RoninApi(private val project: Project) { } is CommandResult.PromptInjection -> { onResponse("System", "🔄 Loaded command context...") - // The UI should handle re-sending with the injected prompt onComplete() } } From 521f97590b4b3eaca1c8a4e2bb29bef09d128df2 Mon Sep 17 00:00:00 2001 From: hymoon Date: Mon, 26 Jan 2026 19:13:51 -0600 Subject: [PATCH 6/7] refactor: Remove unnecessary comments and improve layout handling in ChatInputField --- .../kotlin/com/ronin/ui/chat/components/ChatInputField.kt | 6 ------ 1 file changed, 6 deletions(-) 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 17d520a..d32eaa8 100644 --- a/src/main/kotlin/com/ronin/ui/chat/components/ChatInputField.kt +++ b/src/main/kotlin/com/ronin/ui/chat/components/ChatInputField.kt @@ -67,11 +67,6 @@ class ChatInputField( ) } - /** - * SOLUCIÓN TÉCNICA: - * Sobrescribimos getPreferredSize. Esta es la fuente de la verdad para los Layout Managers. - * Cada vez que Swing recalcula el layout (llamado por revalidate), entra aquí. - */ override fun getPreferredSize(): Dimension { val d = super.getPreferredSize() @@ -94,7 +89,6 @@ class ChatInputField( onSendMessage(message) ApplicationManager.getApplication().invokeLater { text = "" - // Forzar revalidación al limpiar para volver al tamaño mínimo revalidate() repaint() } From c1c0c43f55f63440f2056a3262033ed985d6865a Mon Sep 17 00:00:00 2001 From: hymoon Date: Tue, 27 Jan 2026 09:24:28 -0600 Subject: [PATCH 7/7] refactor: Clean up error messages and enhance context building in TaskBuilder --- .../com/ronin/ui/chat/ChatToolWindow.kt | 2 +- .../kotlin/com/ronin/ui/chat/api/RoninApi.kt | 7 +- .../com/ronin/ui/chat/api/TaskBuilder.kt | 111 ++++++++++++++++-- 3 files changed, 103 insertions(+), 17 deletions(-) diff --git a/src/main/kotlin/com/ronin/ui/chat/ChatToolWindow.kt b/src/main/kotlin/com/ronin/ui/chat/ChatToolWindow.kt index b696b05..af5d7b3 100644 --- a/src/main/kotlin/com/ronin/ui/chat/ChatToolWindow.kt +++ b/src/main/kotlin/com/ronin/ui/chat/ChatToolWindow.kt @@ -179,7 +179,7 @@ class ChatToolWindow(private val project: Project) { handleFollowUp(nextPrompt, nextSummary) }, onError = { error -> - addSystemMessage("❌ Error: $error") + addSystemMessage("Error: $error") setGenerating(false) }, onComplete = { diff --git a/src/main/kotlin/com/ronin/ui/chat/api/RoninApi.kt b/src/main/kotlin/com/ronin/ui/chat/api/RoninApi.kt index dfc55a5..39a7206 100644 --- a/src/main/kotlin/com/ronin/ui/chat/api/RoninApi.kt +++ b/src/main/kotlin/com/ronin/ui/chat/api/RoninApi.kt @@ -40,12 +40,10 @@ class RoninApi(private val project: Project) { handleCommandResult(commandResult, onResponse, onError, onComplete, message) return } - - // Add user message to history + val sessionService = project.service() sessionService.addMessage("user", message) - - // Build and execute task + val task = taskBuilder.buildUserMessageTask(message, history) executeTaskWithHistory(task, sessionService, onThinking, onResponse, onCommand, onFollowUp, onError, onComplete) } @@ -114,7 +112,6 @@ class RoninApi(private val project: Project) { task = task, onThinking = onThinking, onResponse = { response -> - // Extract just the text part for history (not tool output) val textOnly = if (response.contains("\n\n")) { response.substringBefore("\n\n") } else { diff --git a/src/main/kotlin/com/ronin/ui/chat/api/TaskBuilder.kt b/src/main/kotlin/com/ronin/ui/chat/api/TaskBuilder.kt index 717bda0..20d655d 100644 --- a/src/main/kotlin/com/ronin/ui/chat/api/TaskBuilder.kt +++ b/src/main/kotlin/com/ronin/ui/chat/api/TaskBuilder.kt @@ -1,12 +1,18 @@ package com.ronin.ui.chat.api import com.intellij.openapi.application.ReadAction -import com.intellij.openapi.components.service +import com.intellij.openapi.fileEditor.FileEditorManager +import com.intellij.openapi.fileEditor.FileDocumentManager import com.intellij.openapi.project.Project -import com.intellij.util.concurrency.AppExecutorUtil +import com.intellij.openapi.roots.ProjectRootManager +import com.intellij.openapi.vfs.VirtualFile +import com.intellij.psi.PsiManager /** * Builds LLM tasks with appropriate context without the UI needing to know the details + * + * THREADING: This class is designed to be called from background threads. + * The buildContext() method uses ReadAction.compute() to safely access project data. */ class TaskBuilder(private val project: Project) { @@ -42,22 +48,105 @@ class TaskBuilder(private val project: Project) { /** * Builds the context string including active file and project structure + * + * THREADING: Uses ReadAction.compute() to safely access file content and project structure + * from a background thread. This is called from executeOnPooledThread in TaskExecutor. */ private fun buildContext(): String { - val configService = project.service() + return ReadAction.compute { + val contextBuilder = StringBuilder() - val activeFile = configService.getActiveFileContent() + val activeFileContent = getActiveFileContent() + if (activeFileContent != null) { + contextBuilder.append("Active File Content:\n```\n$activeFileContent\n```\n\n") + } - val projectStructure = ReadAction.compute { - configService.getProjectStructure() + val projectStructure = getProjectStructure() + contextBuilder.append(projectStructure) + + contextBuilder.toString() } + } + + /** + * Gets the content of the currently active file in the editor + * MUST be called inside ReadAction + */ + private fun getActiveFileContent(): String? { + val fileEditorManager = FileEditorManager.getInstance(project) + val selectedFiles = fileEditorManager.selectedFiles - val contextBuilder = StringBuilder() - if (activeFile != null) { - contextBuilder.append("Active File Content:\n```\n$activeFile\n```\n\n") + if (selectedFiles.isEmpty()) { + return null } - contextBuilder.append(projectStructure) - return contextBuilder.toString() + val activeFile = selectedFiles[0] + val document = FileDocumentManager.getInstance().getDocument(activeFile) ?: return null + + val fileName = activeFile.name + val filePath = activeFile.path + + return """ + File: $fileName + Path: $filePath + + ${document.text} + """.trimIndent() + } + + /** + * Gets the project structure as a string representation + * MUST be called inside ReadAction + */ + private fun getProjectStructure(): String { + val builder = StringBuilder() + builder.append("Project Structure:\n\n") + + val basePath = project.basePath + if (basePath != null) { + builder.append("Base Path: $basePath\n\n") + } + + val projectRootManager = ProjectRootManager.getInstance(project) + val contentRoots = projectRootManager.contentRoots + + if (contentRoots.isNotEmpty()) { + builder.append("Content Roots:\n") + contentRoots.forEach { root -> + builder.append("- ${root.path}\n") + appendFileTree(root, builder, 1, maxDepth = 2) + } + } + + return builder.toString() + } + + /** + * Recursively appends file tree structure + * MUST be called inside ReadAction + */ + private fun appendFileTree( + file: VirtualFile, + builder: StringBuilder, + depth: Int, + maxDepth: Int + ) { + if (depth > maxDepth) return + + val indent = " ".repeat(depth) + val children = file.children ?: return + + for (child in children) { + if (child.isDirectory && child.name in listOf(".git", ".idea", "node_modules", "build", ".gradle", "out")) { + continue + } + + if (child.isDirectory) { + builder.append("$indent├── ${child.name}/\n") + appendFileTree(child, builder, depth + 1, maxDepth) + } else { + builder.append("$indent├── ${child.name}\n") + } + } } }