From 31dae80218c99c5b067aca80aa1a2de32ebd329c Mon Sep 17 00:00:00 2001 From: MischaPanch Date: Wed, 29 Oct 2025 17:47:34 +0100 Subject: [PATCH] Dashboard: possibility to add a language. Also specialize dashboard to jetbrains mode when it is active --- src/serena/agent.py | 9 + src/serena/dashboard.py | 90 +++++++ src/serena/ls_manager.py | 25 +- src/serena/project.py | 22 ++ src/serena/resources/dashboard/dashboard.css | 93 +++++++ src/serena/resources/dashboard/dashboard.js | 240 ++++++++++++++++++- src/serena/resources/dashboard/index.html | 43 +++- 7 files changed, 508 insertions(+), 14 deletions(-) diff --git a/src/serena/agent.py b/src/serena/agent.py index 14ec2d0a..796b8c93 100644 --- a/src/serena/agent.py +++ b/src/serena/agent.py @@ -557,6 +557,15 @@ class SerenaAgent: else: self.issue_task(task, name=task_name) + def remove_language(self, language: Language) -> None: + """ + Removes a language from the active project, shutting down the respective language server and updating the project configuration. + The removal is scheduled via the agent's task executor and executed asynchronously. + + :param language: the language to remove + """ + self.issue_task(lambda: self.get_active_project_or_raise().remove_language(language), name=f"RemoveLanguage:{language.value}") + def get_tool(self, tool_class: type[TTool]) -> TTool: return self._all_tools[tool_class] # type: ignore diff --git a/src/serena/dashboard.py b/src/serena/dashboard.py index d59ff8f1..2325af90 100644 --- a/src/serena/dashboard.py +++ b/src/serena/dashboard.py @@ -50,6 +50,20 @@ class ResponseConfigOverview(BaseModel): available_modes: list[dict[str, str | bool]] available_contexts: list[dict[str, str | bool]] available_memories: list[str] | None + jetbrains_mode: bool + languages: list[str] + + +class ResponseAvailableLanguages(BaseModel): + languages: list[str] + + +class RequestAddLanguage(BaseModel): + language: str + + +class RequestRemoveLanguage(BaseModel): + language: str class SerenaDashboardAPI: @@ -127,6 +141,35 @@ class SerenaDashboardAPI: self._shutdown() return {"status": "shutting down"} + @self._app.route("/get_available_languages", methods=["GET"]) + def get_available_languages() -> dict[str, Any]: + result = self._get_available_languages() + return result.model_dump() + + @self._app.route("/add_language", methods=["POST"]) + def add_language() -> dict[str, str]: + request_data = request.get_json() + if not request_data: + return {"status": "error", "message": "No data provided"} + request_add_language = RequestAddLanguage.model_validate(request_data) + try: + self._add_language(request_add_language) + return {"status": "success", "message": f"Language {request_add_language.language} added successfully"} + except Exception as e: + return {"status": "error", "message": str(e)} + + @self._app.route("/remove_language", methods=["POST"]) + def remove_language() -> dict[str, str]: + request_data = request.get_json() + if not request_data: + return {"status": "error", "message": "No data provided"} + request_remove_language = RequestRemoveLanguage.model_validate(request_data) + try: + self._remove_language(request_remove_language) + return {"status": "success", "message": f"Language {request_remove_language.language} removed successfully"} + except Exception as e: + return {"status": "error", "message": str(e)} + def _get_log_messages(self, request_log: RequestLog) -> ResponseLog: all_messages = self._memory_log_handler.get_log_messages() requested_messages = all_messages[request_log.start_idx :] if request_log.start_idx <= len(all_messages) else [] @@ -233,6 +276,11 @@ class SerenaDashboardAPI: if self._agent.tool_is_active("read_memory") and project is not None: available_memories = project.memories_manager.list_memories() + # Get list of languages for the active project + languages = [] + if project is not None: + languages = [lang.value for lang in project.project_config.languages] + return ResponseConfigOverview( active_project=project_info, context=context_info, @@ -244,6 +292,8 @@ class SerenaDashboardAPI: available_modes=available_modes, available_contexts=available_contexts, available_memories=available_memories, + jetbrains_mode=self._agent.serena_config.jetbrains, + languages=languages, ) def _shutdown(self) -> None: @@ -255,6 +305,46 @@ class SerenaDashboardAPI: # noinspection PyUnresolvedReferences os._exit(0) + def _get_available_languages(self) -> ResponseAvailableLanguages: + from solidlsp.ls_config import Language + + # Get all non-experimental languages + all_languages = [lang.value for lang in Language.iter_all(include_experimental=False)] + + # Filter out already added languages for the active project + project = self._agent.get_active_project() + if project: + current_languages = [lang.value for lang in project.project_config.languages] + available_languages = [lang for lang in all_languages if lang not in current_languages] + else: + available_languages = all_languages + + return ResponseAvailableLanguages(languages=sorted(available_languages)) + + def _add_language(self, request_add_language: RequestAddLanguage) -> None: + from solidlsp.ls_config import Language + + # Convert string to Language enum + try: + language = Language(request_add_language.language) + except ValueError: + raise ValueError(f"Invalid language: {request_add_language.language}") + + # Add the language to the active project + self._agent.add_language(language, wait_for_ls_startup=False) + + def _remove_language(self, request_remove_language: RequestRemoveLanguage) -> None: + from solidlsp.ls_config import Language + + # Convert string to Language enum + try: + language = Language(request_remove_language.language) + except ValueError: + raise ValueError(f"Invalid language: {request_remove_language.language}") + + # Remove the language from the active project + self._agent.remove_language(language) + @staticmethod def _find_first_free_port(start_port: int) -> int: port = start_port diff --git a/src/serena/ls_manager.py b/src/serena/ls_manager.py index c5dcdbd7..8b3bbaf5 100644 --- a/src/serena/ls_manager.py +++ b/src/serena/ls_manager.py @@ -175,6 +175,25 @@ class LanguageServerManager: raise ValueError(f"Language server for language {language.value} already present") return self._create_and_start_language_server(language) + def remove_language_server(self, language: Language, save_cache: bool = False) -> None: + """ + Removes the language server for the given language, stopping it if it is running. + + :param language: the language + """ + if language not in self._language_servers: + raise ValueError(f"No language server for language {language.value} present; cannot remove") + ls = self._language_servers.pop(language) + self._stop_language_server(ls, save_cache=save_cache) + + @staticmethod + def _stop_language_server(ls: SolidLanguageServer, save_cache: bool = False) -> None: + if ls.is_running(): + if save_cache: + ls.save_cache() + log.info(f"Stopping language server for language {ls.language} ...") + ls.stop() + def iter_language_servers(self) -> Iterator[SolidLanguageServer]: for ls in self._language_servers.values(): yield self._ensure_functional_ls(ls) @@ -186,11 +205,7 @@ class LanguageServerManager: :param save_cache: whether to save the cache before stopping """ for ls in self.iter_language_servers(): - if ls.is_running(): - if save_cache: - ls.save_cache() - log.info(f"Stopping language server for language {ls.language} ...") - ls.stop() + self._stop_language_server(ls, save_cache=save_cache) def save_all_caches(self) -> None: """ diff --git a/src/serena/project.py b/src/serena/project.py index 75b0d8e6..371f291c 100644 --- a/src/serena/project.py +++ b/src/serena/project.py @@ -416,6 +416,28 @@ class Project(ToStringMixin): log.info("Adding and starting the language server for new language %s ...", language.value) self.language_server_manager.add_language_server(language) + def remove_language(self, language: Language) -> None: + """ + Removes a programming language from the project configuration, stopping the corresponding + language server instance if the LS manager is active. + The project configuration is saved to disk after removing the language. + + :param language: the programming language to remove + """ + if language not in self.project_config.languages: + log.info(f"Language {language.value} is not present in the project configuration.") + return + # update the project configuration + self.project_config.languages.remove(language) + self.save_config() + + # stop the language server (if the LS manager is active) + if self.language_server_manager is None: + log.info("Language server manager is not active; skipping language server shutdown for the removed language.") + else: + log.info("Removing and stopping the language server for language %s ...", language.value) + self.language_server_manager.remove_language_server(language) + def shutdown(self) -> None: if self.language_server_manager is not None: self.language_server_manager.stop_all(save_cache=True) diff --git a/src/serena/resources/dashboard/dashboard.css b/src/serena/resources/dashboard/dashboard.css index 6f26f466..d320f24c 100644 --- a/src/serena/resources/dashboard/dashboard.css +++ b/src/serena/resources/dashboard/dashboard.css @@ -618,3 +618,96 @@ body { min-width: 100%; } } + +/* Modal Styles */ +.modal { + position: fixed; + z-index: 1000; + left: 0; + top: 0; + width: 100%; + height: 100%; + overflow: auto; + background-color: rgba(0, 0, 0, 0.5); +} + +.modal-content { + background-color: var(--bg-primary); + margin: 10% auto; + padding: 25px; + border: 1px solid var(--border-color); + border-radius: 8px; + width: 90%; + max-width: 500px; + box-shadow: 0 4px 6px rgba(0, 0, 0, 0.3); + position: relative; +} + +.modal-close { + color: var(--text-muted); + float: right; + font-size: 28px; + font-weight: bold; + line-height: 20px; + cursor: pointer; + transition: color 0.2s; +} + +.modal-close:hover, +.modal-close:focus { + color: var(--text-primary); +} + +.modal h3 { + margin-top: 0; + margin-bottom: 15px; + color: var(--text-primary); +} + +/* Language Badge Styles */ +.languages-container { + display: flex; + flex-wrap: wrap; + gap: 8px; +} + +.language-badge { + position: relative; + display: inline-flex; + align-items: center; + padding: 6px 12px; + background: var(--bg-secondary); + border: 1px solid var(--border-color); + border-radius: 6px; + color: var(--text-primary); + font-size: 13px; + font-weight: 500; +} + +.language-badge.removable { + padding-right: 28px; +} + +.language-remove { + position: absolute; + top: 2px; + right: 2px; + width: 18px; + height: 18px; + display: flex; + align-items: center; + justify-content: center; + background: rgba(255, 68, 68, 0.1); + border-radius: 3px; + cursor: pointer; + color: #ff4444; + font-size: 14px; + font-weight: bold; + line-height: 1; + transition: all 0.2s; +} + +.language-remove:hover { + background: rgba(255, 68, 68, 0.2); + transform: scale(1.1); +} diff --git a/src/serena/resources/dashboard/dashboard.js b/src/serena/resources/dashboard/dashboard.js index e63fdd29..fbb306b7 100644 --- a/src/serena/resources/dashboard/dashboard.js +++ b/src/serena/resources/dashboard/dashboard.js @@ -52,6 +52,9 @@ class Dashboard { // Page state this.currentPage = 'overview'; this.configData = null; + this.jetbrainsMode = false; + this.activeProjectName = null; + this.languageToRemove = null; // Tool names and stats this.toolNames = []; @@ -80,6 +83,18 @@ class Dashboard { this.$availableToolsDisplay = $('#available-tools-display'); this.$availableModesDisplay = $('#available-modes-display'); this.$availableContextsDisplay = $('#available-contexts-display'); + this.$addLanguageBtn = $('#add-language-btn'); + this.$addLanguageModal = $('#add-language-modal'); + this.$modalLanguageSelect = $('#modal-language-select'); + this.$modalProjectName = $('#modal-project-name'); + this.$modalAddBtn = $('#modal-add-btn'); + this.$modalCancelBtn = $('#modal-cancel-btn'); + this.$modalClose = $('.modal-close'); + this.$removeLanguageModal = $('#remove-language-modal'); + this.$removeLanguageName = $('#remove-language-name'); + this.$removeModalOkBtn = $('#remove-modal-ok-btn'); + this.$removeModalCancelBtn = $('#remove-modal-cancel-btn'); + this.$modalCloseRemove = $('.modal-close-remove'); // Chart references this.countChart = null; @@ -97,6 +112,13 @@ class Dashboard { this.$themeToggle.click(this.toggleTheme.bind(this)); this.$refreshStats.click(this.loadStats.bind(this)); this.$clearStats.click(this.clearStats.bind(this)); + this.$addLanguageBtn.click(this.openLanguageModal.bind(this)); + this.$modalAddBtn.click(this.addLanguageFromModal.bind(this)); + this.$modalCancelBtn.click(this.closeLanguageModal.bind(this)); + this.$modalClose.click(this.closeLanguageModal.bind(this)); + this.$removeModalOkBtn.click(this.confirmRemoveLanguageOk.bind(this)); + this.$removeModalCancelBtn.click(this.closeRemoveLanguageModal.bind(this)); + this.$modalCloseRemove.click(this.closeRemoveLanguageModal.bind(this)); // Page navigation $('[data-page]').click(function(e) { @@ -112,6 +134,19 @@ class Dashboard { } }); + // Close modals when clicking outside + this.$addLanguageModal.click(function(e) { + if ($(e.target).hasClass('modal')) { + self.closeLanguageModal(); + } + }); + + this.$removeLanguageModal.click(function(e) { + if ($(e.target).hasClass('modal')) { + self.closeRemoveLanguageModal(); + } + }); + // Collapsible sections $('.collapsible-header').click(function() { const $header = $(this); @@ -125,6 +160,17 @@ class Dashboard { // Initialize theme this.initializeTheme(); + // Add ESC key handler for closing modals + $(document).keydown(function(e) { + if (e.key === 'Escape' || e.keyCode === 27) { + if (self.$addLanguageModal.is(':visible')) { + self.closeLanguageModal(); + } else if (self.$removeLanguageModal.is(':visible')) { + self.closeRemoveLanguageModal(); + } + } + }); + // Initialize the application this.loadToolNames().then(function() { // Start on overview page @@ -187,8 +233,11 @@ class Dashboard { url: '/get_config_overview', type: 'GET', success: function(response) { + console.log('Config overview response:', response); self.failureCount = 0; self.configData = response; + self.jetbrainsMode = response.jetbrains_mode; + self.activeProjectName = response.active_project.name; self.displayConfig(response); self.displayBasicStats(response.tool_stats_summary); self.displayProjects(response.registered_projects); @@ -219,13 +268,14 @@ class Dashboard { } displayConfig(config) { - // Check if tools and memories sections are currently expanded - const $existingToolsContent = $('#tools-content'); - const $existingMemoriesContent = $('#memories-content'); - const wasToolsExpanded = $existingToolsContent.is(':visible'); - const wasMemoriesExpanded = $existingMemoriesContent.is(':visible'); + try { + // Check if tools and memories sections are currently expanded + const $existingToolsContent = $('#tools-content'); + const $existingMemoriesContent = $('#memories-content'); + const wasToolsExpanded = $existingToolsContent.is(':visible'); + const wasMemoriesExpanded = $existingMemoriesContent.is(':visible'); - let html = '
'; + let html = '
'; // Project info html += '
Active Project:
'; @@ -236,8 +286,28 @@ class Dashboard { html += '
' + (config.active_project.name || 'None') + '
'; } - html += '
Language:
'; - html += '
' + (config.active_project.language || 'N/A') + '
'; + html += '
Languages:
'; + if (this.jetbrainsMode) { + html += '
Using JetBrains backend
'; + } else { + html += '
'; + if (config.languages && config.languages.length > 0) { + html += '
'; + config.languages.forEach(function(language, index) { + const isRemovable = config.languages.length > 1; + html += '
'; + html += language; + if (isRemovable) { + html += '×'; + } + html += '
'; + }); + html += '
'; + } else { + html += 'N/A'; + } + html += '
'; + } // Context info html += '
Context:
'; @@ -294,6 +364,22 @@ class Dashboard { this.$configDisplay.html(html); + // Show/hide add language button based on jetbrains mode + if (this.jetbrainsMode) { + this.$addLanguageBtn.hide(); + } else { + this.$addLanguageBtn.show(); + } + + // Attach event handlers for language remove buttons + const self = this; + $('.language-remove').click(function(e) { + e.preventDefault(); + e.stopPropagation(); + const language = $(this).data('language'); + self.confirmRemoveLanguage(language); + }); + // Re-attach collapsible handler for the newly created tools header $('#tools-header').click(function() { const $header = $(this); @@ -313,6 +399,10 @@ class Dashboard { $content.slideToggle(300); $icon.toggleClass('expanded'); }); + } catch (error) { + console.error('Error in displayConfig:', error); + this.$configDisplay.html('
Error displaying configuration: ' + error.message + '
'); + } } displayBasicStats(stats) { @@ -952,6 +1042,140 @@ class Dashboard { } } + // ===== Language Management Methods ===== + + confirmRemoveLanguage(language) { + // Store the language to remove + this.languageToRemove = language; + + // Set language name in modal + this.$removeLanguageName.text(language); + + // Show modal + this.$removeLanguageModal.fadeIn(200); + } + + closeRemoveLanguageModal() { + this.$removeLanguageModal.fadeOut(200); + this.languageToRemove = null; + } + + confirmRemoveLanguageOk() { + if (this.languageToRemove) { + this.removeLanguage(this.languageToRemove); + this.closeRemoveLanguageModal(); + } + } + + removeLanguage(language) { + const self = this; + + $.ajax({ + url: '/remove_language', + type: 'POST', + contentType: 'application/json', + data: JSON.stringify({ + language: language + }), + success: function(response) { + if (response.status === 'success') { + // Reload config to show updated language list + self.loadConfigOverview(); + } else { + alert('Error: ' + response.message); + } + }, + error: function(xhr, status, error) { + console.error('Error removing language:', error); + alert('Error removing language: ' + (xhr.responseJSON ? xhr.responseJSON.message : error)); + } + }); + } + + openLanguageModal() { + // Set project name in modal + this.$modalProjectName.text(this.activeProjectName || 'Unknown'); + + // Load available languages into modal dropdown + this.loadAvailableLanguages(); + + // Show modal + this.$addLanguageModal.fadeIn(200); + } + + closeLanguageModal() { + this.$addLanguageModal.fadeOut(200); + this.$modalLanguageSelect.empty(); + this.$modalAddBtn.prop('disabled', false).text('Add Language'); + } + + loadAvailableLanguages() { + let self = this; + $.ajax({ + url: '/get_available_languages', + type: 'GET', + success: function(response) { + const languages = response.languages || []; + // Clear all existing options + self.$modalLanguageSelect.empty(); + + if (languages.length === 0) { + // Show message if no languages available + self.$modalLanguageSelect.append($('