diff --git a/src/main/kotlin/com/ronin/actions/BaseRoninAction.kt b/src/main/kotlin/com/ronin/actions/BaseRoninAction.kt index 0765a97..ff4684f 100644 --- a/src/main/kotlin/com/ronin/actions/BaseRoninAction.kt +++ b/src/main/kotlin/com/ronin/actions/BaseRoninAction.kt @@ -3,11 +3,8 @@ 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 abstract class BaseRoninAction : AnAction() { @@ -27,34 +24,11 @@ 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. 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() - - // Gather Context - val activeFile = com.intellij.openapi.application.ReadAction.compute { - com.intellij.openapi.fileEditor.FileEditorManager.getInstance(project).selectedTextEditor?.document?.text - } - - val contextBuilder = StringBuilder() - if (activeFile != null) { - contextBuilder.append("Active File Content:\n```\n$activeFile\n```\n\n") - } - - 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) + val chatWindow = ChatToolWindow.getInstance(project) + if (chatWindow != null) { + chatWindow.sendMessageProgrammatically(prompt) } } } diff --git a/src/main/kotlin/com/ronin/ui/ChatToolWindowFactory.kt b/src/main/kotlin/com/ronin/ui/ChatToolWindowFactory.kt index c9daf44..39c7cad 100644 --- a/src/main/kotlin/com/ronin/ui/ChatToolWindowFactory.kt +++ b/src/main/kotlin/com/ronin/ui/ChatToolWindowFactory.kt @@ -1,24 +1,13 @@ 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 class ChatToolWindowFactory : ToolWindowFactory { @@ -28,14 +17,28 @@ class ChatToolWindowFactory : ToolWindowFactory { 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,549 +46,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) { - updateStanceList() - } - }) - - // ... (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) - - updateStanceList() - - modelComboBox.addActionListener { - val selected = modelComboBox.selectedItem as? String - if (selected != null) { - com.ronin.settings.RoninSettingsState.instance.activeStance = 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 updateStanceList() { - val settings = com.ronin.settings.RoninSettingsState.instance - val stances = settings.stances.map { it.name }.toTypedArray() - val currentStance = settings.activeStance - - SwingUtilities.invokeLater { - modelComboBox.model = DefaultComboBoxModel(stances) - modelComboBox.isEnabled = true - - if (stances.contains(currentStance)) { - modelComboBox.selectedItem = currentStance - } else if (stances.isNotEmpty()) { - modelComboBox.selectedItem = stances[0] - settings.activeStance = stances[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 activeFile = ReadAction.compute { - com.intellij.openapi.fileEditor.FileEditorManager.getInstance(project).selectedTextEditor?.document?.text - } - - 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) - - if (Thread.interrupted()) throw InterruptedException() - val contextBuilder = StringBuilder() - if (activeFile != null) { - contextBuilder.append("Active File Content:\n```\n$activeFile\n```\n\n") - } - - 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 activeFile = ReadAction.compute { - com.intellij.openapi.fileEditor.FileEditorManager.getInstance(project).selectedTextEditor?.document?.text - } - - val contextBuilder = StringBuilder() - if (activeFile != null) contextBuilder.append("Active File:\n```\n$activeFile\n```\n\n") - - 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 { - updateStanceList() - 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..af5d7b3 --- /dev/null +++ b/src/main/kotlin/com/ronin/ui/chat/ChatToolWindow.kt @@ -0,0 +1,408 @@ +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.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 + 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.activeStance = model + } + ) + bottomPanel.add(controlBar, BorderLayout.NORTH) + + // Input field + inputField = ChatInputField(project) { message -> + handleSendMessage(message) + } + bottomPanel.add(inputField, BorderLayout.CENTER) + + mainPanel.add(bottomPanel, BorderLayout.SOUTH) + + 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") + setGenerating(false) + }, + 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") + setGenerating(false) + }, + 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 stance list (replaced old model list) + */ + fun updateModelList() { + val settings = RoninSettingsState.instance + val currentStance = settings.activeStance + + controlBar.setModelsLoading(true) + + ApplicationManager.getApplication().executeOnPooledThread { + // Get all available stances + val stanceNames = settings.stances.map { it.name } + + SwingUtilities.invokeLater { + val stances = stanceNames.toTypedArray() + controlBar.updateModels(stances, currentStance) + 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..39a7206 --- /dev/null +++ b/src/main/kotlin/com/ronin/ui/chat/api/RoninApi.kt @@ -0,0 +1,155 @@ +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 + } + + val sessionService = project.service() + sessionService.addMessage("user", message) + + 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 -> + 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...") + 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..20d655d --- /dev/null +++ b/src/main/kotlin/com/ronin/ui/chat/api/TaskBuilder.kt @@ -0,0 +1,152 @@ +package com.ronin.ui.chat.api + +import com.intellij.openapi.application.ReadAction +import com.intellij.openapi.fileEditor.FileEditorManager +import com.intellij.openapi.fileEditor.FileDocumentManager +import com.intellij.openapi.project.Project +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) { + + /** + * 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 + * + * 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 { + return ReadAction.compute { + val contextBuilder = StringBuilder() + + val activeFileContent = getActiveFileContent() + if (activeFileContent != null) { + contextBuilder.append("Active File Content:\n```\n$activeFileContent\n```\n\n") + } + + 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 + + if (selectedFiles.isEmpty()) { + return null + } + + 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") + } + } + } +} 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..8c1a086 --- /dev/null +++ b/src/main/kotlin/com/ronin/ui/chat/api/TaskExecutor.kt @@ -0,0 +1,121 @@ +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 + * All callbacks are executed on the EDT to ensure thread safety + */ +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) - 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( + 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() + } + + val response = llmService.sendMessage( + task.message, + task.context, + ArrayList(task.history) + ) + + if (Thread.interrupted()) { + throw InterruptedException() + } + + val llmResponse = messageHandler.parseResponse(response) + + val displayText = if (llmResponse.toolOutput != null) { + "${llmResponse.text}\n\n${llmResponse.toolOutput}" + } else { + llmResponse.text + } + + if (!llmResponse.scratchpad.isNullOrBlank()) { + runOnEdt { + onThinking(llmResponse.scratchpad) + } + } + + runOnEdt { + onResponse(displayText) + } + + when { + llmResponse.commandToRun != null -> { + 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." + runOnEdt { + onFollowUp(followUpPrompt, followUpSummary) + } + } + else -> { + runOnEdt { + onComplete() + } + } + } + + } catch (e: InterruptedException) { + runOnEdt { + onComplete() + } + } catch (e: Exception) { + runOnEdt { + onError(e.message ?: "Unknown error") + onComplete() + } + } + } + } + + /** + * Cancels a running task + */ + 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) + } + +} 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..d32eaa8 --- /dev/null +++ b/src/main/kotlin/com/ronin/ui/chat/components/ChatInputField.kt @@ -0,0 +1,97 @@ +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.project.DumbAwareAction +import com.intellij.openapi.project.Project +import com.intellij.ui.EditorTextField +import com.intellij.ui.JBColor +import com.intellij.util.ui.JBUI +import java.awt.Dimension +import java.awt.event.KeyEvent +import javax.swing.KeyStroke +import javax.swing.ScrollPaneConstants +import javax.swing.SwingUtilities + +class ChatInputField( + project: Project, + private val onSendMessage: (String) -> Unit +) : EditorTextField(project, com.intellij.openapi.fileTypes.PlainTextFileType.INSTANCE) { + + private val MAX_VISIBLE_LINES = 15 + private val MIN_HEIGHT = 100 + + init { + setOneLineMode(false) + 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) + ) + + addSettingsProvider { editor -> + 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) + } + + addDocumentListener(object : DocumentListener { + override fun documentChanged(event: DocumentEvent) { + SwingUtilities.invokeLater { + revalidate() + repaint() + } + } + }) + + val sendAction = object : DumbAwareAction() { + override fun actionPerformed(e: AnActionEvent) { + sendMessage() + } + } + sendAction.registerCustomShortcutSet( + CustomShortcutSet(KeyStroke.getKeyStroke(KeyEvent.VK_ENTER, 0)), + this + ) + } + + 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) + ApplicationManager.getApplication().invokeLater { + text = "" + 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 new file mode 100644 index 0000000..4a7c7d7 --- /dev/null +++ b/src/main/kotlin/com/ronin/ui/chat/components/ControlBar.kt @@ -0,0 +1,115 @@ +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 +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(GridBagLayout()) { + + val modelComboBox = ComboBox() + private val actionButton = JButton(AllIcons.Actions.Refresh) + private val attachButton = JButton(AllIcons.FileTypes.Any_type) + + private var isGenerating = false + + init { + border = JBUI.Borders.emptyBottom(5) + createControls() + } + + private fun createControls() { + modelComboBox.isOpaque = false + modelComboBox.addActionListener { + val selected = modelComboBox.selectedItem as? String + if (selected != null) { + onModelChange(selected) + } + } + 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) + + 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) + } + + /** + * 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 +}