This is page 14 of 17. Use http://codebase.md/oraios/serena?lines=false&page={x} to view the full context.
# Directory Structure
```
├── .devcontainer
│ └── devcontainer.json
├── .dockerignore
├── .env.example
├── .github
│ ├── FUNDING.yml
│ ├── ISSUE_TEMPLATE
│ │ ├── config.yml
│ │ ├── feature_request.md
│ │ └── issue--bug--performance-problem--question-.md
│ └── workflows
│ ├── codespell.yml
│ ├── docker.yml
│ ├── docs.yaml
│ ├── junie.yml
│ ├── publish.yml
│ └── pytest.yml
├── .gitignore
├── .serena
│ ├── .gitignore
│ ├── memories
│ │ ├── adding_new_language_support_guide.md
│ │ ├── serena_core_concepts_and_architecture.md
│ │ ├── serena_repository_structure.md
│ │ └── suggested_commands.md
│ └── project.yml
├── .vscode
│ └── settings.json
├── CHANGELOG.md
├── CLAUDE.md
├── compose.yaml
├── CONTRIBUTING.md
├── docker_build_and_run.sh
├── DOCKER.md
├── Dockerfile
├── docs
│ ├── _config.yml
│ ├── _static
│ │ └── images
│ │ └── jetbrains-marketplace-button.png
│ ├── .gitignore
│ ├── 01-about
│ │ ├── 000_intro.md
│ │ ├── 010_llm-integration.md
│ │ ├── 020_programming-languages.md
│ │ ├── 030_serena-in-action.md
│ │ ├── 035_tools.md
│ │ ├── 040_comparison-to-other-agents.md
│ │ └── 050_acknowledgements.md
│ ├── 02-usage
│ │ ├── 000_intro.md
│ │ ├── 010_prerequisites.md
│ │ ├── 020_running.md
│ │ ├── 025_jetbrains_plugin.md
│ │ ├── 030_clients.md
│ │ ├── 040_workflow.md
│ │ ├── 050_configuration.md
│ │ ├── 060_dashboard.md
│ │ ├── 070_security.md
│ │ └── 999_additional-usage.md
│ ├── 03-special-guides
│ │ ├── 000_intro.md
│ │ ├── custom_agent.md
│ │ ├── groovy_setup_guide_for_serena.md
│ │ ├── scala_setup_guide_for_serena.md
│ │ └── serena_on_chatgpt.md
│ ├── autogen_rst.py
│ ├── create_toc.py
│ └── index.md
├── flake.lock
├── flake.nix
├── lessons_learned.md
├── LICENSE
├── llms-install.md
├── pyproject.toml
├── README.md
├── repo_dir_sync.py
├── resources
│ ├── jetbrains-marketplace-button.cdr
│ ├── serena-icons.cdr
│ ├── serena-logo-dark-mode.svg
│ ├── serena-logo.cdr
│ ├── serena-logo.svg
│ └── vscode_sponsor_logo.png
├── roadmap.md
├── scripts
│ ├── agno_agent.py
│ ├── demo_run_tools.py
│ ├── gen_prompt_factory.py
│ ├── mcp_server.py
│ ├── print_mode_context_options.py
│ ├── print_tool_overview.py
│ └── profile_tool_call.py
├── src
│ ├── interprompt
│ │ ├── __init__.py
│ │ ├── .syncCommitId.remote
│ │ ├── .syncCommitId.this
│ │ ├── jinja_template.py
│ │ ├── multilang_prompt.py
│ │ ├── prompt_factory.py
│ │ └── util
│ │ ├── __init__.py
│ │ └── class_decorators.py
│ ├── README.md
│ ├── serena
│ │ ├── __init__.py
│ │ ├── agent.py
│ │ ├── agno.py
│ │ ├── analytics.py
│ │ ├── cli.py
│ │ ├── code_editor.py
│ │ ├── config
│ │ │ ├── __init__.py
│ │ │ ├── context_mode.py
│ │ │ └── serena_config.py
│ │ ├── constants.py
│ │ ├── dashboard.py
│ │ ├── generated
│ │ │ └── generated_prompt_factory.py
│ │ ├── gui_log_viewer.py
│ │ ├── ls_manager.py
│ │ ├── mcp.py
│ │ ├── project.py
│ │ ├── prompt_factory.py
│ │ ├── resources
│ │ │ ├── config
│ │ │ │ ├── contexts
│ │ │ │ │ ├── agent.yml
│ │ │ │ │ ├── chatgpt.yml
│ │ │ │ │ ├── claude-code.yml
│ │ │ │ │ ├── codex.yml
│ │ │ │ │ ├── context.template.yml
│ │ │ │ │ ├── desktop-app.yml
│ │ │ │ │ ├── ide.yml
│ │ │ │ │ └── oaicompat-agent.yml
│ │ │ │ ├── internal_modes
│ │ │ │ │ └── jetbrains.yml
│ │ │ │ ├── modes
│ │ │ │ │ ├── editing.yml
│ │ │ │ │ ├── interactive.yml
│ │ │ │ │ ├── mode.template.yml
│ │ │ │ │ ├── no-memories.yml
│ │ │ │ │ ├── no-onboarding.yml
│ │ │ │ │ ├── onboarding.yml
│ │ │ │ │ ├── one-shot.yml
│ │ │ │ │ └── planning.yml
│ │ │ │ └── prompt_templates
│ │ │ │ ├── simple_tool_outputs.yml
│ │ │ │ └── system_prompt.yml
│ │ │ ├── dashboard
│ │ │ │ ├── dashboard.css
│ │ │ │ ├── dashboard.js
│ │ │ │ ├── index.html
│ │ │ │ ├── jquery.min.js
│ │ │ │ ├── news
│ │ │ │ │ └── 20260111.html
│ │ │ │ ├── serena-icon-16.png
│ │ │ │ ├── serena-icon-32.png
│ │ │ │ ├── serena-icon-48.png
│ │ │ │ ├── serena-logo-dark-mode.svg
│ │ │ │ ├── serena-logo.svg
│ │ │ │ ├── serena-logs-dark-mode.png
│ │ │ │ └── serena-logs.png
│ │ │ ├── project.template.yml
│ │ │ └── serena_config.template.yml
│ │ ├── symbol.py
│ │ ├── task_executor.py
│ │ ├── text_utils.py
│ │ ├── tools
│ │ │ ├── __init__.py
│ │ │ ├── cmd_tools.py
│ │ │ ├── config_tools.py
│ │ │ ├── file_tools.py
│ │ │ ├── jetbrains_plugin_client.py
│ │ │ ├── jetbrains_tools.py
│ │ │ ├── jetbrains_types.py
│ │ │ ├── memory_tools.py
│ │ │ ├── symbol_tools.py
│ │ │ ├── tools_base.py
│ │ │ └── workflow_tools.py
│ │ └── util
│ │ ├── class_decorators.py
│ │ ├── cli_util.py
│ │ ├── exception.py
│ │ ├── file_system.py
│ │ ├── general.py
│ │ ├── git.py
│ │ ├── gui.py
│ │ ├── inspection.py
│ │ ├── logging.py
│ │ ├── shell.py
│ │ ├── thread.py
│ │ └── version.py
│ └── solidlsp
│ ├── __init__.py
│ ├── .gitignore
│ ├── language_servers
│ │ ├── al_language_server.py
│ │ ├── bash_language_server.py
│ │ ├── clangd_language_server.py
│ │ ├── clojure_lsp.py
│ │ ├── common.py
│ │ ├── csharp_language_server.py
│ │ ├── dart_language_server.py
│ │ ├── eclipse_jdtls.py
│ │ ├── elixir_tools
│ │ │ ├── __init__.py
│ │ │ ├── elixir_tools.py
│ │ │ └── README.md
│ │ ├── elm_language_server.py
│ │ ├── erlang_language_server.py
│ │ ├── fortran_language_server.py
│ │ ├── fsharp_language_server.py
│ │ ├── gopls.py
│ │ ├── groovy_language_server.py
│ │ ├── haskell_language_server.py
│ │ ├── intelephense.py
│ │ ├── jedi_server.py
│ │ ├── julia_server.py
│ │ ├── kotlin_language_server.py
│ │ ├── lua_ls.py
│ │ ├── marksman.py
│ │ ├── matlab_language_server.py
│ │ ├── nixd_ls.py
│ │ ├── omnisharp
│ │ │ ├── initialize_params.json
│ │ │ ├── runtime_dependencies.json
│ │ │ └── workspace_did_change_configuration.json
│ │ ├── omnisharp.py
│ │ ├── pascal_server.py
│ │ ├── perl_language_server.py
│ │ ├── powershell_language_server.py
│ │ ├── pyright_server.py
│ │ ├── r_language_server.py
│ │ ├── regal_server.py
│ │ ├── ruby_lsp.py
│ │ ├── rust_analyzer.py
│ │ ├── scala_language_server.py
│ │ ├── solargraph.py
│ │ ├── sourcekit_lsp.py
│ │ ├── taplo_server.py
│ │ ├── terraform_ls.py
│ │ ├── typescript_language_server.py
│ │ ├── vts_language_server.py
│ │ ├── vue_language_server.py
│ │ ├── yaml_language_server.py
│ │ └── zls.py
│ ├── ls_config.py
│ ├── ls_exceptions.py
│ ├── ls_handler.py
│ ├── ls_request.py
│ ├── ls_types.py
│ ├── ls_utils.py
│ ├── ls.py
│ ├── lsp_protocol_handler
│ │ ├── lsp_constants.py
│ │ ├── lsp_requests.py
│ │ ├── lsp_types.py
│ │ └── server.py
│ ├── settings.py
│ └── util
│ ├── cache.py
│ ├── subprocess_util.py
│ └── zip.py
├── sync.py
├── test
│ ├── __init__.py
│ ├── conftest.py
│ ├── resources
│ │ └── repos
│ │ ├── al
│ │ │ └── test_repo
│ │ │ ├── app.json
│ │ │ └── src
│ │ │ ├── Codeunits
│ │ │ │ ├── CustomerMgt.Codeunit.al
│ │ │ │ └── PaymentProcessorImpl.Codeunit.al
│ │ │ ├── Enums
│ │ │ │ └── CustomerType.Enum.al
│ │ │ ├── Interfaces
│ │ │ │ └── IPaymentProcessor.Interface.al
│ │ │ ├── Pages
│ │ │ │ ├── CustomerCard.Page.al
│ │ │ │ └── CustomerList.Page.al
│ │ │ ├── TableExtensions
│ │ │ │ └── Item.TableExt.al
│ │ │ └── Tables
│ │ │ └── Customer.Table.al
│ │ ├── bash
│ │ │ └── test_repo
│ │ │ ├── config.sh
│ │ │ ├── main.sh
│ │ │ └── utils.sh
│ │ ├── clojure
│ │ │ └── test_repo
│ │ │ ├── deps.edn
│ │ │ └── src
│ │ │ └── test_app
│ │ │ ├── core.clj
│ │ │ └── utils.clj
│ │ ├── csharp
│ │ │ └── test_repo
│ │ │ ├── .gitignore
│ │ │ ├── Models
│ │ │ │ └── Person.cs
│ │ │ ├── Program.cs
│ │ │ ├── serena.sln
│ │ │ └── TestProject.csproj
│ │ ├── dart
│ │ │ └── test_repo
│ │ │ ├── .gitignore
│ │ │ ├── lib
│ │ │ │ ├── helper.dart
│ │ │ │ ├── main.dart
│ │ │ │ └── models.dart
│ │ │ └── pubspec.yaml
│ │ ├── elixir
│ │ │ └── test_repo
│ │ │ ├── .gitignore
│ │ │ ├── lib
│ │ │ │ ├── examples.ex
│ │ │ │ ├── ignored_dir
│ │ │ │ │ └── ignored_module.ex
│ │ │ │ ├── models.ex
│ │ │ │ ├── services.ex
│ │ │ │ ├── test_repo.ex
│ │ │ │ └── utils.ex
│ │ │ ├── mix.exs
│ │ │ ├── mix.lock
│ │ │ ├── scripts
│ │ │ │ └── build_script.ex
│ │ │ └── test
│ │ │ ├── models_test.exs
│ │ │ └── test_repo_test.exs
│ │ ├── elm
│ │ │ └── test_repo
│ │ │ ├── elm.json
│ │ │ ├── Main.elm
│ │ │ └── Utils.elm
│ │ ├── erlang
│ │ │ └── test_repo
│ │ │ ├── hello.erl
│ │ │ ├── ignored_dir
│ │ │ │ └── ignored_module.erl
│ │ │ ├── include
│ │ │ │ ├── records.hrl
│ │ │ │ └── types.hrl
│ │ │ ├── math_utils.erl
│ │ │ ├── rebar.config
│ │ │ ├── src
│ │ │ │ ├── app.erl
│ │ │ │ ├── models.erl
│ │ │ │ ├── services.erl
│ │ │ │ └── utils.erl
│ │ │ └── test
│ │ │ ├── models_tests.erl
│ │ │ └── utils_tests.erl
│ │ ├── fortran
│ │ │ └── test_repo
│ │ │ ├── main.f90
│ │ │ └── modules
│ │ │ ├── geometry.f90
│ │ │ └── math_utils.f90
│ │ ├── fsharp
│ │ │ └── test_repo
│ │ │ ├── .gitignore
│ │ │ ├── Calculator.fs
│ │ │ ├── Models
│ │ │ │ └── Person.fs
│ │ │ ├── Program.fs
│ │ │ ├── README.md
│ │ │ └── TestProject.fsproj
│ │ ├── go
│ │ │ └── test_repo
│ │ │ └── main.go
│ │ ├── groovy
│ │ │ └── test_repo
│ │ │ ├── .gitignore
│ │ │ ├── build.gradle
│ │ │ └── src
│ │ │ └── main
│ │ │ └── groovy
│ │ │ └── com
│ │ │ └── example
│ │ │ ├── Main.groovy
│ │ │ ├── Model.groovy
│ │ │ ├── ModelUser.groovy
│ │ │ └── Utils.groovy
│ │ ├── haskell
│ │ │ └── test_repo
│ │ │ ├── app
│ │ │ │ └── Main.hs
│ │ │ ├── haskell-test-repo.cabal
│ │ │ ├── package.yaml
│ │ │ ├── src
│ │ │ │ ├── Calculator.hs
│ │ │ │ └── Helper.hs
│ │ │ └── stack.yaml
│ │ ├── java
│ │ │ └── test_repo
│ │ │ ├── pom.xml
│ │ │ └── src
│ │ │ └── main
│ │ │ └── java
│ │ │ └── test_repo
│ │ │ ├── Main.java
│ │ │ ├── Model.java
│ │ │ ├── ModelUser.java
│ │ │ └── Utils.java
│ │ ├── julia
│ │ │ └── test_repo
│ │ │ ├── lib
│ │ │ │ └── helper.jl
│ │ │ └── main.jl
│ │ ├── kotlin
│ │ │ └── test_repo
│ │ │ ├── .gitignore
│ │ │ ├── build.gradle.kts
│ │ │ └── src
│ │ │ └── main
│ │ │ └── kotlin
│ │ │ └── test_repo
│ │ │ ├── Main.kt
│ │ │ ├── Model.kt
│ │ │ ├── ModelUser.kt
│ │ │ └── Utils.kt
│ │ ├── lua
│ │ │ └── test_repo
│ │ │ ├── .gitignore
│ │ │ ├── main.lua
│ │ │ ├── src
│ │ │ │ ├── calculator.lua
│ │ │ │ └── utils.lua
│ │ │ └── tests
│ │ │ └── test_calculator.lua
│ │ ├── markdown
│ │ │ └── test_repo
│ │ │ ├── api.md
│ │ │ ├── CONTRIBUTING.md
│ │ │ ├── guide.md
│ │ │ └── README.md
│ │ ├── matlab
│ │ │ └── test_repo
│ │ │ ├── Calculator.m
│ │ │ └── main.m
│ │ ├── nix
│ │ │ └── test_repo
│ │ │ ├── .gitignore
│ │ │ ├── default.nix
│ │ │ ├── flake.nix
│ │ │ ├── lib
│ │ │ │ └── utils.nix
│ │ │ ├── modules
│ │ │ │ └── example.nix
│ │ │ └── scripts
│ │ │ └── hello.sh
│ │ ├── pascal
│ │ │ └── test_repo
│ │ │ ├── .gitignore
│ │ │ ├── lib
│ │ │ │ └── helper.pas
│ │ │ └── main.pas
│ │ ├── perl
│ │ │ └── test_repo
│ │ │ ├── helper.pl
│ │ │ └── main.pl
│ │ ├── php
│ │ │ └── test_repo
│ │ │ ├── helper.php
│ │ │ ├── index.php
│ │ │ └── simple_var.php
│ │ ├── powershell
│ │ │ └── test_repo
│ │ │ ├── main.ps1
│ │ │ ├── PowerShellEditorServices.json
│ │ │ └── utils.ps1
│ │ ├── python
│ │ │ └── test_repo
│ │ │ ├── .gitignore
│ │ │ ├── custom_test
│ │ │ │ ├── __init__.py
│ │ │ │ └── advanced_features.py
│ │ │ ├── examples
│ │ │ │ ├── __init__.py
│ │ │ │ └── user_management.py
│ │ │ ├── ignore_this_dir_with_postfix
│ │ │ │ └── ignored_module.py
│ │ │ ├── scripts
│ │ │ │ ├── __init__.py
│ │ │ │ └── run_app.py
│ │ │ └── test_repo
│ │ │ ├── __init__.py
│ │ │ ├── complex_types.py
│ │ │ ├── models.py
│ │ │ ├── name_collisions.py
│ │ │ ├── nested_base.py
│ │ │ ├── nested.py
│ │ │ ├── overloaded.py
│ │ │ ├── services.py
│ │ │ ├── utils.py
│ │ │ └── variables.py
│ │ ├── r
│ │ │ └── test_repo
│ │ │ ├── .Rbuildignore
│ │ │ ├── DESCRIPTION
│ │ │ ├── examples
│ │ │ │ └── analysis.R
│ │ │ ├── NAMESPACE
│ │ │ └── R
│ │ │ ├── models.R
│ │ │ └── utils.R
│ │ ├── rego
│ │ │ └── test_repo
│ │ │ ├── policies
│ │ │ │ ├── authz.rego
│ │ │ │ └── validation.rego
│ │ │ └── utils
│ │ │ └── helpers.rego
│ │ ├── ruby
│ │ │ └── test_repo
│ │ │ ├── .solargraph.yml
│ │ │ ├── examples
│ │ │ │ └── user_management.rb
│ │ │ ├── lib.rb
│ │ │ ├── main.rb
│ │ │ ├── models.rb
│ │ │ ├── nested.rb
│ │ │ ├── services.rb
│ │ │ └── variables.rb
│ │ ├── rust
│ │ │ ├── test_repo
│ │ │ │ ├── Cargo.lock
│ │ │ │ ├── Cargo.toml
│ │ │ │ └── src
│ │ │ │ ├── lib.rs
│ │ │ │ └── main.rs
│ │ │ └── test_repo_2024
│ │ │ ├── Cargo.lock
│ │ │ ├── Cargo.toml
│ │ │ └── src
│ │ │ ├── lib.rs
│ │ │ └── main.rs
│ │ ├── scala
│ │ │ ├── build.sbt
│ │ │ ├── project
│ │ │ │ ├── build.properties
│ │ │ │ ├── metals.sbt
│ │ │ │ └── plugins.sbt
│ │ │ └── src
│ │ │ └── main
│ │ │ └── scala
│ │ │ └── com
│ │ │ └── example
│ │ │ ├── Main.scala
│ │ │ └── Utils.scala
│ │ ├── swift
│ │ │ └── test_repo
│ │ │ ├── Package.swift
│ │ │ └── src
│ │ │ ├── main.swift
│ │ │ └── utils.swift
│ │ ├── terraform
│ │ │ └── test_repo
│ │ │ ├── data.tf
│ │ │ ├── main.tf
│ │ │ ├── outputs.tf
│ │ │ └── variables.tf
│ │ ├── toml
│ │ │ └── test_repo
│ │ │ ├── Cargo.toml
│ │ │ ├── config.toml
│ │ │ └── pyproject.toml
│ │ ├── typescript
│ │ │ └── test_repo
│ │ │ ├── .serena
│ │ │ │ └── project.yml
│ │ │ ├── index.ts
│ │ │ ├── tsconfig.json
│ │ │ ├── use_helper.ts
│ │ │ └── ws_manager.js
│ │ ├── vue
│ │ │ └── test_repo
│ │ │ ├── .gitignore
│ │ │ ├── index.html
│ │ │ ├── package.json
│ │ │ ├── src
│ │ │ │ ├── App.vue
│ │ │ │ ├── components
│ │ │ │ │ ├── CalculatorButton.vue
│ │ │ │ │ ├── CalculatorDisplay.vue
│ │ │ │ │ └── CalculatorInput.vue
│ │ │ │ ├── composables
│ │ │ │ │ ├── useFormatter.ts
│ │ │ │ │ └── useTheme.ts
│ │ │ │ ├── main.ts
│ │ │ │ ├── stores
│ │ │ │ │ └── calculator.ts
│ │ │ │ └── types
│ │ │ │ └── index.ts
│ │ │ ├── tsconfig.json
│ │ │ ├── tsconfig.node.json
│ │ │ └── vite.config.ts
│ │ ├── yaml
│ │ │ └── test_repo
│ │ │ ├── config.yaml
│ │ │ ├── data.yaml
│ │ │ └── services.yml
│ │ └── zig
│ │ └── test_repo
│ │ ├── .gitignore
│ │ ├── build.zig
│ │ ├── src
│ │ │ ├── calculator.zig
│ │ │ ├── main.zig
│ │ │ └── math_utils.zig
│ │ └── zls.json
│ ├── serena
│ │ ├── __init__.py
│ │ ├── __snapshots__
│ │ │ └── test_symbol_editing.ambr
│ │ ├── config
│ │ │ ├── __init__.py
│ │ │ └── test_serena_config.py
│ │ ├── test_cli_project_commands.py
│ │ ├── test_edit_marker.py
│ │ ├── test_mcp.py
│ │ ├── test_serena_agent.py
│ │ ├── test_symbol_editing.py
│ │ ├── test_symbol.py
│ │ ├── test_task_executor.py
│ │ ├── test_text_utils.py
│ │ ├── test_tool_parameter_types.py
│ │ └── util
│ │ ├── test_exception.py
│ │ └── test_file_system.py
│ └── solidlsp
│ ├── al
│ │ └── test_al_basic.py
│ ├── bash
│ │ ├── __init__.py
│ │ └── test_bash_basic.py
│ ├── clojure
│ │ ├── __init__.py
│ │ └── test_clojure_basic.py
│ ├── csharp
│ │ └── test_csharp_basic.py
│ ├── dart
│ │ ├── __init__.py
│ │ └── test_dart_basic.py
│ ├── elixir
│ │ ├── __init__.py
│ │ ├── conftest.py
│ │ ├── test_elixir_basic.py
│ │ ├── test_elixir_ignored_dirs.py
│ │ ├── test_elixir_integration.py
│ │ └── test_elixir_symbol_retrieval.py
│ ├── elm
│ │ └── test_elm_basic.py
│ ├── erlang
│ │ ├── __init__.py
│ │ ├── conftest.py
│ │ ├── test_erlang_basic.py
│ │ ├── test_erlang_ignored_dirs.py
│ │ └── test_erlang_symbol_retrieval.py
│ ├── fortran
│ │ ├── __init__.py
│ │ └── test_fortran_basic.py
│ ├── fsharp
│ │ └── test_fsharp_basic.py
│ ├── go
│ │ └── test_go_basic.py
│ ├── groovy
│ │ └── test_groovy_basic.py
│ ├── haskell
│ │ ├── __init__.py
│ │ └── test_haskell_basic.py
│ ├── java
│ │ └── test_java_basic.py
│ ├── julia
│ │ └── test_julia_basic.py
│ ├── kotlin
│ │ └── test_kotlin_basic.py
│ ├── lua
│ │ └── test_lua_basic.py
│ ├── markdown
│ │ ├── __init__.py
│ │ └── test_markdown_basic.py
│ ├── matlab
│ │ ├── __init__.py
│ │ └── test_matlab_basic.py
│ ├── nix
│ │ └── test_nix_basic.py
│ ├── pascal
│ │ ├── __init__.py
│ │ └── test_pascal_basic.py
│ ├── perl
│ │ └── test_perl_basic.py
│ ├── php
│ │ └── test_php_basic.py
│ ├── powershell
│ │ ├── __init__.py
│ │ └── test_powershell_basic.py
│ ├── python
│ │ ├── test_python_basic.py
│ │ ├── test_retrieval_with_ignored_dirs.py
│ │ └── test_symbol_retrieval.py
│ ├── r
│ │ ├── __init__.py
│ │ └── test_r_basic.py
│ ├── rego
│ │ └── test_rego_basic.py
│ ├── ruby
│ │ ├── test_ruby_basic.py
│ │ └── test_ruby_symbol_retrieval.py
│ ├── rust
│ │ ├── test_rust_2024_edition.py
│ │ ├── test_rust_analyzer_detection.py
│ │ └── test_rust_basic.py
│ ├── scala
│ │ └── test_scala_language_server.py
│ ├── swift
│ │ └── test_swift_basic.py
│ ├── terraform
│ │ └── test_terraform_basic.py
│ ├── test_lsp_protocol_handler_server.py
│ ├── toml
│ │ ├── __init__.py
│ │ ├── test_toml_basic.py
│ │ ├── test_toml_edge_cases.py
│ │ ├── test_toml_ignored_dirs.py
│ │ └── test_toml_symbol_retrieval.py
│ ├── typescript
│ │ └── test_typescript_basic.py
│ ├── util
│ │ └── test_zip.py
│ ├── vue
│ │ ├── __init__.py
│ │ ├── test_vue_basic.py
│ │ ├── test_vue_error_cases.py
│ │ ├── test_vue_rename.py
│ │ └── test_vue_symbol_retrieval.py
│ ├── yaml_ls
│ │ ├── __init__.py
│ │ └── test_yaml_basic.py
│ └── zig
│ └── test_zig_basic.py
└── uv.lock
```
# Files
--------------------------------------------------------------------------------
/src/serena/cli.py:
--------------------------------------------------------------------------------
```python
import collections
import glob
import json
import os
import shutil
import subprocess
import sys
from collections.abc import Iterator
from logging import Logger
from pathlib import Path
from typing import Any, Literal
import click
from sensai.util import logging
from sensai.util.logging import FileLoggerContext, datetime_tag
from sensai.util.string import dict_string
from tqdm import tqdm
from serena.agent import SerenaAgent
from serena.config.context_mode import SerenaAgentContext, SerenaAgentMode
from serena.config.serena_config import LanguageBackend, ProjectConfig, SerenaConfig, SerenaPaths
from serena.constants import (
DEFAULT_CONTEXT,
DEFAULT_MODES,
PROMPT_TEMPLATES_DIR_INTERNAL,
SERENA_LOG_FORMAT,
SERENAS_OWN_CONTEXT_YAMLS_DIR,
SERENAS_OWN_MODE_YAMLS_DIR,
)
from serena.mcp import SerenaMCPFactory
from serena.project import Project
from serena.tools import FindReferencingSymbolsTool, FindSymbolTool, GetSymbolsOverviewTool, SearchForPatternTool, ToolRegistry
from serena.util.logging import MemoryLogHandler
from solidlsp.ls_config import Language
from solidlsp.util.subprocess_util import subprocess_kwargs
log = logging.getLogger(__name__)
def find_project_root(root: str | Path | None = None) -> str:
"""Find project root by walking up from CWD.
Checks for .serena/project.yml first (explicit Serena project), then .git (git root).
Falls back to CWD if no marker is found.
:param root: If provided, constrains the search to this directory and below
(acts as a virtual filesystem root). Search stops at this boundary.
:return: absolute path to project root (falls back to CWD if no marker found)
"""
current = Path.cwd().resolve()
boundary = Path(root).resolve() if root is not None else None
def ancestors() -> Iterator[Path]:
"""Yield current directory and ancestors up to boundary."""
yield current
for parent in current.parents:
yield parent
if boundary is not None and parent == boundary:
return
# First pass: look for .serena
for directory in ancestors():
if (directory / ".serena" / "project.yml").is_file():
return str(directory)
# Second pass: look for .git
for directory in ancestors():
if (directory / ".git").exists(): # .git can be file (worktree) or dir
return str(directory)
# Fall back to CWD
return str(current)
# --------------------- Utilities -------------------------------------
def _open_in_editor(path: str) -> None:
"""Open the given file in the system's default editor or viewer."""
editor = os.environ.get("EDITOR")
run_kwargs = subprocess_kwargs()
try:
if editor:
subprocess.run([editor, path], check=False, **run_kwargs)
elif sys.platform.startswith("win"):
try:
os.startfile(path)
except OSError:
subprocess.run(["notepad.exe", path], check=False, **run_kwargs)
elif sys.platform == "darwin":
subprocess.run(["open", path], check=False, **run_kwargs)
else:
subprocess.run(["xdg-open", path], check=False, **run_kwargs)
except Exception as e:
print(f"Failed to open {path}: {e}")
class ProjectType(click.ParamType):
"""ParamType allowing either a project name or a path to a project directory."""
name = "[PROJECT_NAME|PROJECT_PATH]"
def convert(self, value: str, param: Any, ctx: Any) -> str:
path = Path(value).resolve()
if path.exists() and path.is_dir():
return str(path)
return value
PROJECT_TYPE = ProjectType()
class AutoRegisteringGroup(click.Group):
"""
A click.Group subclass that automatically registers any click.Command
attributes defined on the class into the group.
After initialization, it inspects its own class for attributes that are
instances of click.Command (typically created via @click.command) and
calls self.add_command(cmd) on each. This lets you define your commands
as static methods on the subclass for IDE-friendly organization without
manual registration.
"""
def __init__(self, name: str, help: str):
super().__init__(name=name, help=help)
# Scan class attributes for click.Command instances and register them.
for attr in dir(self.__class__):
cmd = getattr(self.__class__, attr)
if isinstance(cmd, click.Command):
self.add_command(cmd)
class TopLevelCommands(AutoRegisteringGroup):
"""Root CLI group containing the core Serena commands."""
def __init__(self) -> None:
super().__init__(name="serena", help="Serena CLI commands. You can run `<command> --help` for more info on each command.")
@staticmethod
@click.command("start-mcp-server", help="Starts the Serena MCP server.")
@click.option("--project", "project", type=PROJECT_TYPE, default=None, help="Path or name of project to activate at startup.")
@click.option("--project-file", "project", type=PROJECT_TYPE, default=None, help="[DEPRECATED] Use --project instead.")
@click.argument("project_file_arg", type=PROJECT_TYPE, required=False, default=None, metavar="")
@click.option(
"--context", type=str, default=DEFAULT_CONTEXT, show_default=True, help="Built-in context name or path to custom context YAML."
)
@click.option(
"--mode",
"modes",
type=str,
multiple=True,
default=DEFAULT_MODES,
show_default=True,
help="Built-in mode names or paths to custom mode YAMLs.",
)
@click.option(
"--language-backend",
type=click.Choice([lb.value for lb in LanguageBackend]),
default=None,
help="Override the configured language backend.",
)
@click.option(
"--transport",
type=click.Choice(["stdio", "sse", "streamable-http"]),
default="stdio",
show_default=True,
help="Transport protocol.",
)
@click.option(
"--host",
type=str,
default="0.0.0.0",
show_default=True,
help="Listen address for the MCP server (when using corresponding transport).",
)
@click.option(
"--port", type=int, default=8000, show_default=True, help="Listen port for the MCP server (when using corresponding transport)."
)
@click.option("--enable-web-dashboard", type=bool, is_flag=False, default=None, help="Override dashboard setting in config.")
@click.option("--enable-gui-log-window", type=bool, is_flag=False, default=None, help="Override GUI log window setting in config.")
@click.option(
"--log-level",
type=click.Choice(["DEBUG", "INFO", "WARNING", "ERROR", "CRITICAL"]),
default=None,
help="Override log level in config.",
)
@click.option("--trace-lsp-communication", type=bool, is_flag=False, default=None, help="Whether to trace LSP communication.")
@click.option("--tool-timeout", type=float, default=None, help="Override tool execution timeout in config.")
@click.option(
"--project-from-cwd",
is_flag=True,
default=False,
help="Auto-detect project from current working directory (searches for .serena/project.yml or .git, falls back to CWD). Intended for CLI-based agents like Claude Code, Gemini and Codex.",
)
def start_mcp_server(
project: str | None,
project_file_arg: str | None,
project_from_cwd: bool | None,
context: str,
modes: tuple[str, ...],
language_backend: str | None,
transport: Literal["stdio", "sse", "streamable-http"],
host: str,
port: int,
enable_web_dashboard: bool | None,
enable_gui_log_window: bool | None,
log_level: Literal["DEBUG", "INFO", "WARNING", "ERROR", "CRITICAL"] | None,
trace_lsp_communication: bool | None,
tool_timeout: float | None,
) -> None:
# initialize logging, using INFO level initially (will later be adjusted by SerenaAgent according to the config)
# * memory log handler (for use by GUI/Dashboard)
# * stream handler for stderr (for direct console output, which will also be captured by clients like Claude Desktop)
# * file handler
# (Note that stdout must never be used for logging, as it is used by the MCP server to communicate with the client.)
Logger.root.setLevel(logging.INFO)
formatter = logging.Formatter(SERENA_LOG_FORMAT)
memory_log_handler = MemoryLogHandler()
Logger.root.addHandler(memory_log_handler)
stderr_handler = logging.StreamHandler(stream=sys.stderr)
stderr_handler.formatter = formatter
Logger.root.addHandler(stderr_handler)
log_path = SerenaPaths().get_next_log_file_path("mcp")
file_handler = logging.FileHandler(log_path, mode="w")
file_handler.formatter = formatter
Logger.root.addHandler(file_handler)
log.info("Initializing Serena MCP server")
log.info("Storing logs in %s", log_path)
# Handle --project-from-cwd flag
if project_from_cwd:
if project is not None or project_file_arg is not None:
raise click.UsageError("--project-from-cwd cannot be used with --project or positional project argument")
project = find_project_root()
log.info("Auto-detected project root: %s", project)
project_file = project_file_arg or project
factory = SerenaMCPFactory(context=context, project=project_file, memory_log_handler=memory_log_handler)
server = factory.create_mcp_server(
host=host,
port=port,
modes=modes,
language_backend=LanguageBackend.from_str(language_backend) if language_backend else None,
enable_web_dashboard=enable_web_dashboard,
enable_gui_log_window=enable_gui_log_window,
log_level=log_level,
trace_lsp_communication=trace_lsp_communication,
tool_timeout=tool_timeout,
)
if project_file_arg:
log.warning(
"Positional project arg is deprecated; use --project instead. Used: %s",
project_file,
)
log.info("Starting MCP server …")
server.run(transport=transport)
@staticmethod
@click.command("print-system-prompt", help="Print the system prompt for a project.")
@click.argument("project", type=click.Path(exists=True), default=os.getcwd(), required=False)
@click.option(
"--log-level",
type=click.Choice(["DEBUG", "INFO", "WARNING", "ERROR", "CRITICAL"]),
default="WARNING",
help="Log level for prompt generation.",
)
@click.option("--only-instructions", is_flag=True, help="Print only the initial instructions, without prefix/postfix.")
@click.option(
"--context", type=str, default=DEFAULT_CONTEXT, show_default=True, help="Built-in context name or path to custom context YAML."
)
@click.option(
"--mode",
"modes",
type=str,
multiple=True,
default=DEFAULT_MODES,
show_default=True,
help="Built-in mode names or paths to custom mode YAMLs.",
)
def print_system_prompt(project: str, log_level: str, only_instructions: bool, context: str, modes: tuple[str, ...]) -> None:
prefix = "You will receive access to Serena's symbolic tools. Below are instructions for using them, take them into account."
postfix = "You begin by acknowledging that you understood the above instructions and are ready to receive tasks."
from serena.tools.workflow_tools import InitialInstructionsTool
lvl = logging.getLevelNamesMapping()[log_level.upper()]
logging.configure(level=lvl)
context_instance = SerenaAgentContext.load(context)
mode_instances = [SerenaAgentMode.load(mode) for mode in modes]
agent = SerenaAgent(
project=os.path.abspath(project),
serena_config=SerenaConfig(web_dashboard=False, log_level=lvl),
context=context_instance,
modes=mode_instances,
)
tool = agent.get_tool(InitialInstructionsTool)
instr = tool.apply()
if only_instructions:
print(instr)
else:
print(f"{prefix}\n{instr}\n{postfix}")
class ModeCommands(AutoRegisteringGroup):
"""Group for 'mode' subcommands."""
def __init__(self) -> None:
super().__init__(name="mode", help="Manage Serena modes. You can run `mode <command> --help` for more info on each command.")
@staticmethod
@click.command("list", help="List available modes.")
def list() -> None:
mode_names = SerenaAgentMode.list_registered_mode_names()
max_len_name = max(len(name) for name in mode_names) if mode_names else 20
for name in mode_names:
mode_yml_path = SerenaAgentMode.get_path(name)
is_internal = Path(mode_yml_path).is_relative_to(SERENAS_OWN_MODE_YAMLS_DIR)
descriptor = "(internal)" if is_internal else f"(at {mode_yml_path})"
name_descr_string = f"{name:<{max_len_name + 4}}{descriptor}"
click.echo(name_descr_string)
@staticmethod
@click.command("create", help="Create a new mode or copy an internal one.")
@click.option(
"--name",
"-n",
type=str,
default=None,
help="Name for the new mode. If --from-internal is passed may be left empty to create a mode of the same name, which will then override the internal mode.",
)
@click.option("--from-internal", "from_internal", type=str, default=None, help="Copy from an internal mode.")
def create(name: str, from_internal: str) -> None:
if not (name or from_internal):
raise click.UsageError("Provide at least one of --name or --from-internal.")
mode_name = name or from_internal
dest = os.path.join(SerenaPaths().user_modes_dir, f"{mode_name}.yml")
src = (
os.path.join(SERENAS_OWN_MODE_YAMLS_DIR, f"{from_internal}.yml")
if from_internal
else os.path.join(SERENAS_OWN_MODE_YAMLS_DIR, "mode.template.yml")
)
if not os.path.exists(src):
raise FileNotFoundError(
f"Internal mode '{from_internal}' not found in {SERENAS_OWN_MODE_YAMLS_DIR}. Available modes: {SerenaAgentMode.list_registered_mode_names()}"
)
os.makedirs(os.path.dirname(dest), exist_ok=True)
shutil.copyfile(src, dest)
click.echo(f"Created mode '{mode_name}' at {dest}")
_open_in_editor(dest)
@staticmethod
@click.command("edit", help="Edit a custom mode YAML file.")
@click.argument("mode_name")
def edit(mode_name: str) -> None:
path = os.path.join(SerenaPaths().user_modes_dir, f"{mode_name}.yml")
if not os.path.exists(path):
if mode_name in SerenaAgentMode.list_registered_mode_names(include_user_modes=False):
click.echo(
f"Mode '{mode_name}' is an internal mode and cannot be edited directly. "
f"Use 'mode create --from-internal {mode_name}' to create a custom mode that overrides it before editing."
)
else:
click.echo(f"Custom mode '{mode_name}' not found. Create it with: mode create --name {mode_name}.")
return
_open_in_editor(path)
@staticmethod
@click.command("delete", help="Delete a custom mode file.")
@click.argument("mode_name")
def delete(mode_name: str) -> None:
path = os.path.join(SerenaPaths().user_modes_dir, f"{mode_name}.yml")
if not os.path.exists(path):
click.echo(f"Custom mode '{mode_name}' not found.")
return
os.remove(path)
click.echo(f"Deleted custom mode '{mode_name}'.")
class ContextCommands(AutoRegisteringGroup):
"""Group for 'context' subcommands."""
def __init__(self) -> None:
super().__init__(
name="context", help="Manage Serena contexts. You can run `context <command> --help` for more info on each command."
)
@staticmethod
@click.command("list", help="List available contexts.")
def list() -> None:
context_names = SerenaAgentContext.list_registered_context_names()
max_len_name = max(len(name) for name in context_names) if context_names else 20
for name in context_names:
context_yml_path = SerenaAgentContext.get_path(name)
is_internal = Path(context_yml_path).is_relative_to(SERENAS_OWN_CONTEXT_YAMLS_DIR)
descriptor = "(internal)" if is_internal else f"(at {context_yml_path})"
name_descr_string = f"{name:<{max_len_name + 4}}{descriptor}"
click.echo(name_descr_string)
@staticmethod
@click.command("create", help="Create a new context or copy an internal one.")
@click.option(
"--name",
"-n",
type=str,
default=None,
help="Name for the new context. If --from-internal is passed may be left empty to create a context of the same name, which will then override the internal context",
)
@click.option("--from-internal", "from_internal", type=str, default=None, help="Copy from an internal context.")
def create(name: str, from_internal: str) -> None:
if not (name or from_internal):
raise click.UsageError("Provide at least one of --name or --from-internal.")
ctx_name = name or from_internal
dest = os.path.join(SerenaPaths().user_contexts_dir, f"{ctx_name}.yml")
src = (
os.path.join(SERENAS_OWN_CONTEXT_YAMLS_DIR, f"{from_internal}.yml")
if from_internal
else os.path.join(SERENAS_OWN_CONTEXT_YAMLS_DIR, "context.template.yml")
)
if not os.path.exists(src):
raise FileNotFoundError(
f"Internal context '{from_internal}' not found in {SERENAS_OWN_CONTEXT_YAMLS_DIR}. Available contexts: {SerenaAgentContext.list_registered_context_names()}"
)
os.makedirs(os.path.dirname(dest), exist_ok=True)
shutil.copyfile(src, dest)
click.echo(f"Created context '{ctx_name}' at {dest}")
_open_in_editor(dest)
@staticmethod
@click.command("edit", help="Edit a custom context YAML file.")
@click.argument("context_name")
def edit(context_name: str) -> None:
path = os.path.join(SerenaPaths().user_contexts_dir, f"{context_name}.yml")
if not os.path.exists(path):
if context_name in SerenaAgentContext.list_registered_context_names(include_user_contexts=False):
click.echo(
f"Context '{context_name}' is an internal context and cannot be edited directly. "
f"Use 'context create --from-internal {context_name}' to create a custom context that overrides it before editing."
)
else:
click.echo(f"Custom context '{context_name}' not found. Create it with: context create --name {context_name}.")
return
_open_in_editor(path)
@staticmethod
@click.command("delete", help="Delete a custom context file.")
@click.argument("context_name")
def delete(context_name: str) -> None:
path = os.path.join(SerenaPaths().user_contexts_dir, f"{context_name}.yml")
if not os.path.exists(path):
click.echo(f"Custom context '{context_name}' not found.")
return
os.remove(path)
click.echo(f"Deleted custom context '{context_name}'.")
class SerenaConfigCommands(AutoRegisteringGroup):
"""Group for 'config' subcommands."""
def __init__(self) -> None:
super().__init__(name="config", help="Manage Serena configuration.")
@staticmethod
@click.command(
"edit", help="Edit serena_config.yml in your default editor. Will create a config file from the template if no config is found."
)
def edit() -> None:
serena_config = SerenaConfig.from_config_file()
assert serena_config.config_file_path is not None
_open_in_editor(serena_config.config_file_path)
class ProjectCommands(AutoRegisteringGroup):
"""Group for 'project' subcommands."""
def __init__(self) -> None:
super().__init__(
name="project", help="Manage Serena projects. You can run `project <command> --help` for more info on each command."
)
@staticmethod
def _create_project(project_path: str, name: str | None, language: tuple[str, ...]) -> ProjectConfig:
"""
Helper method to create a project configuration file.
:param project_path: Path to the project directory
:param name: Optional project name (defaults to directory name if not specified)
:param language: Tuple of language names
:return: The generated ProjectConfig instance
:raises FileExistsError: If project.yml already exists
:raises ValueError: If an unsupported language is specified
"""
yml_path = os.path.join(project_path, ProjectConfig.rel_path_to_project_yml())
if os.path.exists(yml_path):
raise FileExistsError(f"Project file {yml_path} already exists.")
languages: list[Language] = []
if language:
for lang in language:
try:
languages.append(Language(lang.lower()))
except ValueError:
all_langs = [l.value for l in Language]
raise ValueError(f"Unknown language '{lang}'. Supported: {all_langs}")
generated_conf = ProjectConfig.autogenerate(
project_root=project_path, project_name=name, languages=languages if languages else None, interactive=True
)
yml_path = ProjectConfig.path_to_project_yml(project_path)
languages_str = ", ".join([lang.value for lang in generated_conf.languages]) if generated_conf.languages else "N/A"
click.echo(f"Generated project with languages {{{languages_str}}} at {yml_path}.")
return generated_conf
@staticmethod
@click.command("create", help="Create a new Serena project configuration.")
@click.argument("project_path", type=click.Path(exists=True, file_okay=False), default=os.getcwd())
@click.option("--name", type=str, default=None, help="Project name; defaults to directory name if not specified.")
@click.option(
"--language", type=str, multiple=True, help="Programming language(s); inferred if not specified. Can be passed multiple times."
)
@click.option("--index", is_flag=True, help="Index the project after creation.")
@click.option(
"--log-level",
type=click.Choice(["DEBUG", "INFO", "WARNING", "ERROR", "CRITICAL"]),
default="WARNING",
help="Log level for indexing (only used if --index is set).",
)
@click.option("--timeout", type=float, default=10, help="Timeout for indexing a single file (only used if --index is set).")
def create(project_path: str, name: str | None, language: tuple[str, ...], index: bool, log_level: str, timeout: float) -> None:
try:
ProjectCommands._create_project(project_path, name, language)
if index:
click.echo("Indexing project...")
ProjectCommands._index_project(project_path, log_level, timeout=timeout)
except FileExistsError as e:
raise click.ClickException(f"Project already exists: {e}\nUse 'serena project index' to index an existing project.")
except ValueError as e:
raise click.ClickException(str(e))
@staticmethod
@click.command("index", help="Index a project by saving symbols to the LSP cache. Auto-creates project.yml if it doesn't exist.")
@click.argument("project", type=click.Path(exists=True), default=os.getcwd(), required=False)
@click.option("--name", type=str, default=None, help="Project name (only used if auto-creating project.yml).")
@click.option(
"--language",
type=str,
multiple=True,
help="Programming language(s) (only used if auto-creating project.yml). Inferred if not specified.",
)
@click.option(
"--log-level",
type=click.Choice(["DEBUG", "INFO", "WARNING", "ERROR", "CRITICAL"]),
default="WARNING",
help="Log level for indexing.",
)
@click.option("--timeout", type=float, default=10, help="Timeout for indexing a single file.")
def index(project: str, name: str | None, language: tuple[str, ...], log_level: str, timeout: float) -> None:
# Check if project.yml exists, if not auto-create it
yml_path = os.path.join(project, ProjectConfig.rel_path_to_project_yml())
if not os.path.exists(yml_path):
click.echo(f"Project configuration not found at {yml_path}. Auto-creating...")
try:
ProjectCommands._create_project(project, name, language)
except FileExistsError:
# Race condition - file was created between check and creation
pass
except ValueError as e:
raise click.ClickException(str(e))
ProjectCommands._index_project(project, log_level, timeout=timeout)
@staticmethod
@click.command("index-deprecated", help="Deprecated alias for 'serena project index'.")
@click.argument("project", type=click.Path(exists=True), default=os.getcwd(), required=False)
@click.option("--log-level", type=click.Choice(["DEBUG", "INFO", "WARNING", "ERROR", "CRITICAL"]), default="WARNING")
@click.option("--timeout", type=float, default=10, help="Timeout for indexing a single file.")
def index_deprecated(project: str, log_level: str, timeout: float) -> None:
click.echo("Deprecated! Use `serena project index` instead.")
ProjectCommands._index_project(project, log_level, timeout=timeout)
@staticmethod
def _index_project(project: str, log_level: str, timeout: float) -> None:
lvl = logging.getLevelNamesMapping()[log_level.upper()]
logging.configure(level=lvl)
serena_config = SerenaConfig.from_config_file()
proj = Project.load(os.path.abspath(project))
click.echo(f"Indexing symbols in project {project} …")
ls_mgr = proj.create_language_server_manager(
log_level=lvl, ls_timeout=timeout, ls_specific_settings=serena_config.ls_specific_settings
)
try:
log_file = os.path.join(project, ".serena", "logs", "indexing.txt")
files = proj.gather_source_files()
collected_exceptions: list[Exception] = []
files_failed = []
language_file_counts: dict[Language, int] = collections.defaultdict(lambda: 0)
for i, f in enumerate(tqdm(files, desc="Indexing")):
try:
ls = ls_mgr.get_language_server(f)
ls.request_document_symbols(f)
language_file_counts[ls.language] += 1
except Exception as e:
log.error(f"Failed to index {f}, continuing.")
collected_exceptions.append(e)
files_failed.append(f)
if (i + 1) % 10 == 0:
ls_mgr.save_all_caches()
reported_language_file_counts = {k.value: v for k, v in language_file_counts.items()}
click.echo(f"Indexed files per language: {dict_string(reported_language_file_counts, brackets=None)}")
ls_mgr.save_all_caches()
if len(files_failed) > 0:
os.makedirs(os.path.dirname(log_file), exist_ok=True)
with open(log_file, "w") as f:
for file, exception in zip(files_failed, collected_exceptions, strict=True):
f.write(f"{file}\n")
f.write(f"{exception}\n")
click.echo(f"Failed to index {len(files_failed)} files, see:\n{log_file}")
finally:
ls_mgr.stop_all()
@staticmethod
@click.command("is_ignored_path", help="Check if a path is ignored by the project configuration.")
@click.argument("path", type=click.Path(exists=False, file_okay=True, dir_okay=True))
@click.argument("project", type=click.Path(exists=True, file_okay=False, dir_okay=True), default=os.getcwd())
def is_ignored_path(path: str, project: str) -> None:
"""
Check if a given path is ignored by the project configuration.
:param path: The path to check.
:param project: The path to the project directory, defaults to the current working directory.
"""
proj = Project.load(os.path.abspath(project))
if os.path.isabs(path):
path = os.path.relpath(path, start=proj.project_root)
is_ignored = proj.is_ignored_path(path)
click.echo(f"Path '{path}' IS {'ignored' if is_ignored else 'IS NOT ignored'} by the project configuration.")
@staticmethod
@click.command("index-file", help="Index a single file by saving its symbols to the LSP cache.")
@click.argument("file", type=click.Path(exists=True, file_okay=True, dir_okay=False))
@click.argument("project", type=click.Path(exists=True, file_okay=False, dir_okay=True), default=os.getcwd())
@click.option("--verbose", "-v", is_flag=True, help="Print detailed information about the indexed symbols.")
def index_file(file: str, project: str, verbose: bool) -> None:
"""
Index a single file by saving its symbols to the LSP cache, useful for debugging.
:param file: path to the file to index, must be inside the project directory.
:param project: path to the project directory, defaults to the current working directory.
:param verbose: if set, prints detailed information about the indexed symbols.
"""
proj = Project.load(os.path.abspath(project))
if os.path.isabs(file):
file = os.path.relpath(file, start=proj.project_root)
if proj.is_ignored_path(file, ignore_non_source_files=True):
click.echo(f"'{file}' is ignored or declared as non-code file by the project configuration, won't index.")
exit(1)
ls_mgr = proj.create_language_server_manager()
try:
for ls in ls_mgr.iter_language_servers():
click.echo(f"Indexing for language {ls.language.value} …")
document_symbols = ls.request_document_symbols(file)
symbols, _ = document_symbols.get_all_symbols_and_roots()
if verbose:
click.echo(f"Symbols in file '{file}':")
for symbol in symbols:
click.echo(f" - {symbol['name']} at line {symbol['selectionRange']['start']['line']} of kind {symbol['kind']}")
ls.save_cache()
click.echo(f"Successfully indexed file '{file}', {len(symbols)} symbols saved to cache in {ls.cache_dir}.")
finally:
ls_mgr.stop_all()
@staticmethod
@click.command("health-check", help="Perform a comprehensive health check of the project's tools and language server.")
@click.argument("project", type=click.Path(exists=True, file_okay=False, dir_okay=True), default=os.getcwd())
def health_check(project: str) -> None:
"""
Perform a comprehensive health check of the project's tools and language server.
:param project: path to the project directory, defaults to the current working directory.
"""
# NOTE: completely written by Claude Code, only functionality was reviewed, not implementation
logging.configure(level=logging.INFO)
project_path = os.path.abspath(project)
proj = Project.load(project_path)
# Create log file with timestamp
timestamp = datetime_tag()
log_dir = os.path.join(project_path, ".serena", "logs", "health-checks")
os.makedirs(log_dir, exist_ok=True)
log_file = os.path.join(log_dir, f"health_check_{timestamp}.log")
with FileLoggerContext(log_file, append=False, enabled=True):
log.info("Starting health check for project: %s", project_path)
try:
# Create SerenaAgent with dashboard disabled
log.info("Creating SerenaAgent with disabled dashboard...")
config = SerenaConfig(gui_log_window_enabled=False, web_dashboard=False)
agent = SerenaAgent(project=project_path, serena_config=config)
log.info("SerenaAgent created successfully")
# Find first non-empty file that can be analyzed
log.info("Searching for analyzable files...")
files = proj.gather_source_files()
target_file = None
for file_path in files:
try:
full_path = os.path.join(project_path, file_path)
if os.path.getsize(full_path) > 0:
target_file = file_path
log.info("Found analyzable file: %s", target_file)
break
except (OSError, FileNotFoundError):
continue
if not target_file:
log.error("No analyzable files found in project")
click.echo("❌ Health check failed: No analyzable files found")
click.echo(f"Log saved to: {log_file}")
return
# Get tools from agent
overview_tool = agent.get_tool(GetSymbolsOverviewTool)
find_symbol_tool = agent.get_tool(FindSymbolTool)
find_refs_tool = agent.get_tool(FindReferencingSymbolsTool)
search_pattern_tool = agent.get_tool(SearchForPatternTool)
# Test 1: Get symbols overview
log.info("Testing GetSymbolsOverviewTool on file: %s", target_file)
overview_result = agent.execute_task(lambda: overview_tool.apply(target_file))
overview_data = json.loads(overview_result)
log.info("GetSymbolsOverviewTool returned %d symbols", len(overview_data))
if not overview_data:
log.error("No symbols found in file %s", target_file)
click.echo("❌ Health check failed: No symbols found in target file")
click.echo(f"Log saved to: {log_file}")
return
# Extract suitable symbol (prefer class or function over variables)
# LSP symbol kinds: 5=class, 12=function, 6=method, 9=constructor
preferred_kinds = [5, 12, 6, 9] # class, function, method, constructor
selected_symbol = None
for symbol in overview_data:
if symbol.get("kind") in preferred_kinds:
selected_symbol = symbol
break
# If no preferred symbol found, use first available
if not selected_symbol:
selected_symbol = overview_data[0]
log.info("No class or function found, using first available symbol")
symbol_name = selected_symbol.get("name_path", "unknown")
symbol_kind = selected_symbol.get("kind", "unknown")
log.info("Using symbol for testing: %s (kind: %d)", symbol_name, symbol_kind)
# Test 2: FindSymbolTool
log.info("Testing FindSymbolTool for symbol: %s", symbol_name)
find_symbol_result = agent.execute_task(
lambda: find_symbol_tool.apply(symbol_name, relative_path=target_file, include_body=True)
)
find_symbol_data = json.loads(find_symbol_result)
log.info("FindSymbolTool found %d matches for symbol %s", len(find_symbol_data), symbol_name)
# Test 3: FindReferencingSymbolsTool
log.info("Testing FindReferencingSymbolsTool for symbol: %s", symbol_name)
try:
find_refs_result = agent.execute_task(lambda: find_refs_tool.apply(symbol_name, relative_path=target_file))
find_refs_data = json.loads(find_refs_result)
log.info("FindReferencingSymbolsTool found %d references for symbol %s", len(find_refs_data), symbol_name)
except Exception as e:
log.warning("FindReferencingSymbolsTool failed for symbol %s: %s", symbol_name, str(e))
find_refs_data = []
# Test 4: SearchForPatternTool to verify references
log.info("Testing SearchForPatternTool for pattern: %s", symbol_name)
try:
search_result = agent.execute_task(
lambda: search_pattern_tool.apply(substring_pattern=symbol_name, restrict_search_to_code_files=True)
)
search_data = json.loads(search_result)
pattern_matches = sum(len(matches) for matches in search_data.values())
log.info("SearchForPatternTool found %d pattern matches for %s", pattern_matches, symbol_name)
except Exception as e:
log.warning("SearchForPatternTool failed for pattern %s: %s", symbol_name, str(e))
pattern_matches = 0
# Verify tools worked as expected
tools_working = True
if not find_symbol_data:
log.error("FindSymbolTool returned no results")
tools_working = False
if len(find_refs_data) == 0 and pattern_matches == 0:
log.warning("Both FindReferencingSymbolsTool and SearchForPatternTool found no matches - this might indicate an issue")
log.info("Health check completed successfully")
if tools_working:
click.echo("✅ Health check passed - All tools working correctly")
else:
click.echo("⚠️ Health check completed with warnings - Check log for details")
except Exception as e:
log.exception("Health check failed with exception: %s", str(e))
click.echo(f"❌ Health check failed: {e!s}")
finally:
click.echo(f"Log saved to: {log_file}")
class ToolCommands(AutoRegisteringGroup):
"""Group for 'tool' subcommands."""
def __init__(self) -> None:
super().__init__(
name="tools",
help="Commands related to Serena's tools. You can run `serena tools <command> --help` for more info on each command.",
)
@staticmethod
@click.command(
"list",
help="Prints an overview of the tools that are active by default (not just the active ones for your project). For viewing all tools, pass `--all / -a`",
)
@click.option("--quiet", "-q", is_flag=True)
@click.option("--all", "-a", "include_optional", is_flag=True, help="List all tools, including those not enabled by default.")
@click.option("--only-optional", is_flag=True, help="List only optional tools (those not enabled by default).")
def list(quiet: bool = False, include_optional: bool = False, only_optional: bool = False) -> None:
tool_registry = ToolRegistry()
if quiet:
if only_optional:
tool_names = tool_registry.get_tool_names_optional()
elif include_optional:
tool_names = tool_registry.get_tool_names()
else:
tool_names = tool_registry.get_tool_names_default_enabled()
for tool_name in tool_names:
click.echo(tool_name)
else:
ToolRegistry().print_tool_overview(include_optional=include_optional, only_optional=only_optional)
@staticmethod
@click.command(
"description",
help="Print the description of a tool, optionally with a specific context (the latter may modify the default description).",
)
@click.argument("tool_name", type=str)
@click.option("--context", type=str, default=None, help="Context name or path to context file.")
def description(tool_name: str, context: str | None = None) -> None:
# Load the context
serena_context = None
if context:
serena_context = SerenaAgentContext.load(context)
agent = SerenaAgent(
project=None,
serena_config=SerenaConfig(web_dashboard=False, log_level=logging.INFO),
context=serena_context,
)
tool = agent.get_tool_by_name(tool_name)
mcp_tool = SerenaMCPFactory.make_mcp_tool(tool)
click.echo(mcp_tool.description)
class PromptCommands(AutoRegisteringGroup):
def __init__(self) -> None:
super().__init__(name="prompts", help="Commands related to Serena's prompts that are outside of contexts and modes.")
@staticmethod
def _get_user_prompt_yaml_path(prompt_yaml_name: str) -> str:
templates_dir = SerenaPaths().user_prompt_templates_dir
os.makedirs(templates_dir, exist_ok=True)
return os.path.join(templates_dir, prompt_yaml_name)
@staticmethod
@click.command("list", help="Lists yamls that are used for defining prompts.")
def list() -> None:
serena_prompt_yaml_names = [os.path.basename(f) for f in glob.glob(PROMPT_TEMPLATES_DIR_INTERNAL + "/*.yml")]
for prompt_yaml_name in serena_prompt_yaml_names:
user_prompt_yaml_path = PromptCommands._get_user_prompt_yaml_path(prompt_yaml_name)
if os.path.exists(user_prompt_yaml_path):
click.echo(f"{user_prompt_yaml_path} merged with default prompts in {prompt_yaml_name}")
else:
click.echo(prompt_yaml_name)
@staticmethod
@click.command("create-override", help="Create an override of an internal prompts yaml for customizing Serena's prompts")
@click.argument("prompt_yaml_name")
def create_override(prompt_yaml_name: str) -> None:
"""
:param prompt_yaml_name: The yaml name of the prompt you want to override. Call the `list` command for discovering valid prompt yaml names.
:return:
"""
# for convenience, we can pass names without .yml
if not prompt_yaml_name.endswith(".yml"):
prompt_yaml_name = prompt_yaml_name + ".yml"
user_prompt_yaml_path = PromptCommands._get_user_prompt_yaml_path(prompt_yaml_name)
if os.path.exists(user_prompt_yaml_path):
raise FileExistsError(f"{user_prompt_yaml_path} already exists.")
serena_prompt_yaml_path = os.path.join(PROMPT_TEMPLATES_DIR_INTERNAL, prompt_yaml_name)
shutil.copyfile(serena_prompt_yaml_path, user_prompt_yaml_path)
_open_in_editor(user_prompt_yaml_path)
@staticmethod
@click.command("edit-override", help="Edit an existing prompt override file")
@click.argument("prompt_yaml_name")
def edit_override(prompt_yaml_name: str) -> None:
"""
:param prompt_yaml_name: The yaml name of the prompt override to edit.
:return:
"""
# for convenience, we can pass names without .yml
if not prompt_yaml_name.endswith(".yml"):
prompt_yaml_name = prompt_yaml_name + ".yml"
user_prompt_yaml_path = PromptCommands._get_user_prompt_yaml_path(prompt_yaml_name)
if not os.path.exists(user_prompt_yaml_path):
click.echo(f"Override file '{prompt_yaml_name}' not found. Create it with: prompts create-override {prompt_yaml_name}")
return
_open_in_editor(user_prompt_yaml_path)
@staticmethod
@click.command("list-overrides", help="List existing prompt override files")
def list_overrides() -> None:
user_templates_dir = SerenaPaths().user_prompt_templates_dir
os.makedirs(user_templates_dir, exist_ok=True)
serena_prompt_yaml_names = [os.path.basename(f) for f in glob.glob(PROMPT_TEMPLATES_DIR_INTERNAL + "/*.yml")]
override_files = glob.glob(os.path.join(user_templates_dir, "*.yml"))
for file_path in override_files:
if os.path.basename(file_path) in serena_prompt_yaml_names:
click.echo(file_path)
@staticmethod
@click.command("delete-override", help="Delete a prompt override file")
@click.argument("prompt_yaml_name")
def delete_override(prompt_yaml_name: str) -> None:
"""
:param prompt_yaml_name: The yaml name of the prompt override to delete."
:return:
"""
# for convenience, we can pass names without .yml
if not prompt_yaml_name.endswith(".yml"):
prompt_yaml_name = prompt_yaml_name + ".yml"
user_prompt_yaml_path = PromptCommands._get_user_prompt_yaml_path(prompt_yaml_name)
if not os.path.exists(user_prompt_yaml_path):
click.echo(f"Override file '{prompt_yaml_name}' not found.")
return
os.remove(user_prompt_yaml_path)
click.echo(f"Deleted override file '{prompt_yaml_name}'.")
# Expose groups so we can reference them in pyproject.toml
mode = ModeCommands()
context = ContextCommands()
project = ProjectCommands()
config = SerenaConfigCommands()
tools = ToolCommands()
prompts = PromptCommands()
# Expose toplevel commands for the same reason
top_level = TopLevelCommands()
start_mcp_server = top_level.start_mcp_server
index_project = project.index_deprecated
# needed for the help script to work - register all subcommands to the top-level group
for subgroup in (mode, context, project, config, tools, prompts):
top_level.add_command(subgroup)
def get_help() -> str:
"""Retrieve the help text for the top-level Serena CLI."""
return top_level.get_help(click.Context(top_level, info_name="serena"))
```
--------------------------------------------------------------------------------
/src/serena/resources/dashboard/dashboard.js:
--------------------------------------------------------------------------------
```javascript
class LogMessage {
constructor(message, toolNames) {
message = this.escapeHtml(message);
const logLevel = this.determineLogLevel(message);
const highlightedMessage = this.highlightToolNames(message, toolNames);
this.$elem = $('<div>').addClass('log-' + logLevel).html(highlightedMessage + '\n');
}
determineLogLevel(message) {
if (message.startsWith('DEBUG')) {
return 'debug';
} else if (message.startsWith('INFO')) {
return 'info';
} else if (message.startsWith('WARNING')) {
return 'warning';
} else if (message.startsWith('ERROR')) {
return 'error';
} else {
return 'default';
}
}
highlightToolNames(message, toolNames) {
let highlightedMessage = message;
toolNames.forEach(function (toolName) {
const regex = new RegExp('\\b' + toolName + '\\b', 'gi');
highlightedMessage = highlightedMessage.replace(regex, '<span class="tool-name">' + toolName + '</span>');
});
return highlightedMessage;
}
escapeHtml(convertString) {
if (typeof convertString !== 'string') return convertString;
const patterns = {
'<': '<', '>': '>', '&': '&', '"': '"', '\'': ''', '`': '`'
};
return convertString.replace(/[<>&"'`]/g, match => patterns[match]);
};
}
function updateThemeAwareImage($img, theme=null) {
if (!theme) {
const isDarkMode = $('html').data("theme") == 'dark';
theme = isDarkMode ? 'dark' : 'light';
}
console.log("updating theme-aware image to theme:", theme);
const newSrc = $img.data('src-' + theme);
if (newSrc) {
$img.attr('src', newSrc);
}
}
class BannerRotation {
constructor() {
this.platinumIndex = 0;
this.goldIndex = 0;
this.platinumTimer = null;
this.goldTimer = null;
this.platinumInterval = 15000;
this.goldInterval = 15000;
this.init();
}
init() {
let self = this;
this.loadBanners(function() {
self.startPlatinumRotation();
self.startGoldRotation();
});
}
loadBanners(onSuccess) {
$.ajax({
url: 'https://oraios-software.de/serena-banners/manifest.php',
type: 'GET',
success: function (response) {
console.log('Banners loaded:', response);
function fillBanners($container, banners, className) {
$.each(banners, function (index, banner) {
let $img = $('<img src="' + banner.image + '" alt="' + banner.alt + '" class="banner-image">');
if (banner.image_dark) {
$img.addClass('theme-aware-img');
$img.attr('data-src-dark', banner.image_dark);
$img.attr('data-src-light', banner.image);
updateThemeAwareImage($img);
}
let $anchor = $('<a href="' + banner.link + '" target="_blank"></a>');
$anchor.append($img);
let $banner = $('<div class="' + className + '-slide" data-banner="' + (index + 1) + '"></div>');
$banner.append($anchor);
if (index === 0) {
$banner.addClass('active');
}
if (banner.border) {
$img.addClass('banner-border');
}
$container.append($banner);
});
}
fillBanners($('#gold-banners'), response.gold, 'gold-banner');
fillBanners($('#platinum-banners'), response.platinum, 'platinum-banner');
onSuccess();
},
error: function (xhr, status, error) {
console.error('Error loading banners:', error);
}
});
}
startPlatinumRotation() {
const self = this;
this.platinumTimer = setInterval(() => {
self.rotatePlatinum('next');
}, this.platinumInterval);
}
startGoldRotation() {
const self = this;
this.goldTimer = setInterval(() => {
self.rotateGold('next');
}, this.goldInterval);
}
rotatePlatinum(direction) {
const $slides = $('.platinum-banner-slide');
const total = $slides.length;
if (total === 0) return;
// Remove active class from current slide
$slides.eq(this.platinumIndex).removeClass('active');
// Calculate next index
if (direction === 'next') {
this.platinumIndex = (this.platinumIndex + 1) % total;
} else {
this.platinumIndex = (this.platinumIndex - 1 + total) % total;
}
// Add active class to new slide
$slides.eq(this.platinumIndex).addClass('active');
// Reset timer
clearInterval(this.platinumTimer);
this.startPlatinumRotation();
}
rotateGold(direction) {
const $groups = $('.gold-banner-slide');
const total = $groups.length;
if (total === 0) return;
// Remove active class from current group
$groups.eq(this.goldIndex).removeClass('active');
// Calculate next index
if (direction === 'next') {
this.goldIndex = (this.goldIndex + 1) % total;
} else {
this.goldIndex = (this.goldIndex - 1 + total) % total;
}
// Add active class to new group
$groups.eq(this.goldIndex).addClass('active');
// Reset timer
clearInterval(this.goldTimer);
this.startGoldRotation();
}
}
class Dashboard {
constructor() {
let self = this;
// Page state
this.currentPage = 'overview';
this.configData = null;
this.lastConfigDataJson = null; // Cache for comparison
this.jetbrainsMode = false;
this.activeProjectName = null;
this.languageToRemove = null;
this.currentMemoryName = null;
this.originalMemoryContent = null;
this.memoryContentDirty = false;
this.memoryToDelete = null;
this.isAddingLanguage = false;
this.waitingForConfigPollingResult = false;
this.waitingForExecutionsPollingResult = false;
this.originalSerenaConfigContent = null;
this.serenaConfigContentDirty = false;
// Execution tracking
this.cancelledExecutions = [];
this.executionToCancel = null;
// Tool names and stats
this.toolNames = [];
this.currentMaxIdx = -1;
this.pollInterval = null;
this.configPollInterval = null;
this.executionsPollInterval = null;
this.heartbeatFailureCount = 0;
// jQuery elements
this.$logContainer = $('#log-container');
this.$errorContainer = $('#error-container');
this.$copyLogsBtn = $('#copy-logs-btn');
this.$menuToggle = $('#menu-toggle');
this.$menuDropdown = $('#menu-dropdown');
this.$menuShutdown = $('#menu-shutdown');
this.$themeToggle = $('#theme-toggle');
this.$themeIcon = $('#theme-icon');
this.$themeText = $('#theme-text');
this.$configDisplay = $('#config-display');
this.$basicStatsDisplay = $('#basic-stats-display');
this.$statsSection = $('#stats-section');
this.$refreshStats = $('#refresh-stats');
this.$clearStats = $('#clear-stats');
this.$projectsDisplay = $('#projects-display');
this.$projectsHeader = $('#projects-header');
this.$availableToolsDisplay = $('#available-tools-display');
this.$availableModesDisplay = $('#available-modes-display');
this.$availableContextsDisplay = $('#available-contexts-display');
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');
this.$editMemoryModal = $('#edit-memory-modal');
this.$editMemoryName = $('#edit-memory-name');
this.$editMemoryContent = $('#edit-memory-content');
this.$editMemorySaveBtn = $('#edit-memory-save-btn');
this.$editMemoryCancelBtn = $('#edit-memory-cancel-btn');
this.$modalCloseEditMemory = $('.modal-close-edit-memory');
this.$deleteMemoryModal = $('#delete-memory-modal');
this.$deleteMemoryName = $('#delete-memory-name');
this.$deleteMemoryOkBtn = $('#delete-memory-ok-btn');
this.$deleteMemoryCancelBtn = $('#delete-memory-cancel-btn');
this.$modalCloseDeleteMemory = $('.modal-close-delete-memory');
this.$createMemoryModal = $('#create-memory-modal');
this.$createMemoryProjectName = $('#create-memory-project-name');
this.$createMemoryNameInput = $('#create-memory-name-input');
this.$createMemoryCreateBtn = $('#create-memory-create-btn');
this.$createMemoryCancelBtn = $('#create-memory-cancel-btn');
this.$modalCloseCreateMemory = $('.modal-close-create-memory');
this.$activeExecutionQueueDisplay = $('#active-executions-display');
this.$lastExecutionDisplay = $('#last-execution-display');
this.$cancelledExecutionsDisplay = $('#cancelled-executions-display');
this.$cancelExecutionModal = $('#cancel-execution-modal');
this.$cancelExecutionOkBtn = $('#cancel-execution-ok-btn');
this.$cancelExecutionCancelBtn = $('#cancel-execution-cancel-btn');
this.$modalCloseCancelExecution = $('.modal-close-cancel-execution');
this.$editSerenaConfigModal = $('#edit-serena-config-modal');
this.$editSerenaConfigContent = $('#edit-serena-config-content');
this.$editSerenaConfigSaveBtn = $('#edit-serena-config-save-btn');
this.$editSerenaConfigCancelBtn = $('#edit-serena-config-cancel-btn');
this.$modalCloseEditSerenaConfig = $('.modal-close-edit-serena-config');
this.$newsSection = $('#news-section');
this.$newsDisplay = $('#news-display');
// Chart references
this.countChart = null;
this.tokensChart = null;
this.inputChart = null;
this.outputChart = null;
// Register event handlers
this.$copyLogsBtn.click(this.copyLogs.bind(this));
this.$menuShutdown.click(function (e) {
e.preventDefault();
self.shutdown();
});
this.$menuToggle.click(this.toggleMenu.bind(this));
this.$themeToggle.click(this.toggleTheme.bind(this));
this.$refreshStats.click(this.loadStats.bind(this));
this.$clearStats.click(this.clearStats.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));
this.$editMemorySaveBtn.click(this.saveMemoryFromModal.bind(this));
this.$editMemoryCancelBtn.click(this.closeEditMemoryModal.bind(this));
this.$modalCloseEditMemory.click(this.closeEditMemoryModal.bind(this));
this.$editMemoryContent.on('input', this.trackMemoryChanges.bind(this));
this.$deleteMemoryOkBtn.click(this.confirmDeleteMemoryOk.bind(this));
this.$deleteMemoryCancelBtn.click(this.closeDeleteMemoryModal.bind(this));
this.$modalCloseDeleteMemory.click(this.closeDeleteMemoryModal.bind(this));
this.$createMemoryCreateBtn.click(this.createMemoryFromModal.bind(this));
this.$createMemoryCancelBtn.click(this.closeCreateMemoryModal.bind(this));
this.$modalCloseCreateMemory.click(this.closeCreateMemoryModal.bind(this));
this.$createMemoryNameInput.keypress(function (e) {
if (e.which === 13) { // Enter key
e.preventDefault();
self.createMemoryFromModal();
}
});
this.$cancelExecutionOkBtn.click(this.confirmCancelExecutionOk.bind(this));
this.$cancelExecutionCancelBtn.click(this.closeCancelExecutionModal.bind(this));
this.$modalCloseCancelExecution.click(this.closeCancelExecutionModal.bind(this));
this.$editSerenaConfigSaveBtn.click(this.saveSerenaConfigFromModal.bind(this));
this.$editSerenaConfigCancelBtn.click(this.closeEditSerenaConfigModal.bind(this));
this.$modalCloseEditSerenaConfig.click(this.closeEditSerenaConfigModal.bind(this));
// Page navigation
$('[data-page]').click(function (e) {
e.preventDefault();
const page = $(this).data('page');
self.navigateToPage(page);
});
// Close menu when clicking outside
$(document).click(function (e) {
if (!$(e.target).closest('.header-nav').length) {
self.$menuDropdown.hide();
}
});
// 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();
}
});
this.$editMemoryModal.click(function (e) {
if ($(e.target).hasClass('modal')) {
self.closeEditMemoryModal();
}
});
this.$deleteMemoryModal.click(function (e) {
if ($(e.target).hasClass('modal')) {
self.closeDeleteMemoryModal();
}
});
this.$createMemoryModal.click(function (e) {
if ($(e.target).hasClass('modal')) {
self.closeCreateMemoryModal();
}
});
this.$editSerenaConfigModal.click(function (e) {
if ($(e.target).hasClass('modal')) {
self.closeEditSerenaConfigModal();
}
});
// Collapsible sections
$('.collapsible-header').click(function () {
const $header = $(this);
const $content = $header.next('.collapsible-content');
const $icon = $header.find('.toggle-icon');
$content.slideToggle(300);
$icon.toggleClass('expanded');
});
// Initialize theme
this.initializeTheme();
// Initialize banner rotation
this.bannerRotation = new BannerRotation();
// 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();
} else if (self.$editMemoryModal.is(':visible')) {
self.closeEditMemoryModal();
} else if (self.$deleteMemoryModal.is(':visible')) {
self.closeDeleteMemoryModal();
} else if (self.$createMemoryModal.is(':visible')) {
self.closeCreateMemoryModal();
}
}
});
// Initialize the application
this.loadToolNames().then(function () {
// Start on overview page
self.loadNews();
self.loadConfigOverview();
self.startConfigPolling();
self.startExecutionsPolling();
});
// Initialize heartbeat interval
setInterval(this.heartbeat.bind(this), 250);
}
heartbeat() {
let self = this;
$.ajax({
url: '/heartbeat',
type: 'GET',
success: function (response) {
self.heartbeatFailureCount = 0;
},
error: function (xhr, status, error) {
self.heartbeatFailureCount++;
console.error('Heartbeat failure; count = ', self.heartbeatFailureCount);
if (self.heartbeatFailureCount >= 1) {
console.log('Server appears to be down, closing tab');
window.close();
}
},
});
}
toggleMenu() {
this.$menuDropdown.toggle();
}
navigateToPage(page) {
// Hide menu
this.$menuDropdown.hide();
// Hide all pages
$('.page-view').hide();
// Show selected page
$('#page-' + page).show();
// Update menu active state
$('[data-page]').removeClass('active');
$('[data-page="' + page + '"]').addClass('active');
// Update current page
this.currentPage = page;
// Stop all polling
this.stopPolling();
// Start appropriate polling for the page
if (page === 'overview') {
this.loadNews();
this.loadConfigOverview();
this.startConfigPolling();
this.startExecutionsPolling();
} else if (page === 'logs') {
this.loadLogs();
} else if (page === 'stats') {
this.loadStats();
}
}
stopPolling() {
if (this.pollInterval) {
clearInterval(this.pollInterval);
this.pollInterval = null;
}
if (this.configPollInterval) {
clearInterval(this.configPollInterval);
this.configPollInterval = null;
}
if (this.executionsPollInterval) {
clearInterval(this.executionsPollInterval);
this.executionsPollInterval = null;
}
}
// ===== Config Overview Methods =====
loadConfigOverview() {
if (this.waitingForConfigPollingResult) {
console.log('Still waiting for previous config poll result, skipping this poll');
return;
}
this.waitingForConfigPollingResult = true;
console.log('Polling for config overview...');
let self = this;
$.ajax({
url: '/get_config_overview',
type: 'GET',
success: function (response) {
// Check if the config data has actually changed
const currentConfigJson = JSON.stringify(response);
const hasChanged = self.lastConfigDataJson !== currentConfigJson;
if (hasChanged) {
console.log('Config has changed, updating display');
self.lastConfigDataJson = currentConfigJson;
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);
self.displayAvailableTools(response.available_tools);
self.displayAvailableModes(response.available_modes);
self.displayAvailableContexts(response.available_contexts);
} else {
console.log('Config unchanged, skipping display update');
}
}, error: function (xhr, status, error) {
console.error('Error loading config overview:', error);
self.$configDisplay.html('<div class="error-message">Error loading configuration</div>');
self.$basicStatsDisplay.html('<div class="error-message">Error loading stats</div>');
self.$projectsDisplay.html('<div class="error-message">Error loading projects</div>');
self.$availableToolsDisplay.html('<div class="error-message">Error loading tools</div>');
self.$availableModesDisplay.html('<div class="error-message">Error loading modes</div>');
self.$availableContextsDisplay.html('<div class="error-message">Error loading contexts</div>');
}, complete: function () {
self.waitingForConfigPollingResult = false;
}
});
}
startConfigPolling() {
this.configPollInterval = setInterval(this.loadConfigOverview.bind(this), 1000);
}
startExecutionsPolling() {
// Poll every 1 second for executions (independent of config polling)
// This ensures stuck executions can still be cancelled even if config polling is blocked
this.loadExecutions()
this.executionsPollInterval = setInterval(() => {
this.loadQueuedExecutions();
this.loadLastExecution();
}, 1000);
}
displayConfig(config) {
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 = '<div class="config-grid">';
// Project info
html += '<div class="config-label">Active Project:</div>';
if (config.active_project.name && config.active_project.path) {
const configPath = config.active_project.path + '/.serena/project.yml';
html += '<div class="config-value"><span title="Project configuration in ' + configPath + '">' + config.active_project.name + '</span></div>';
} else {
html += '<div class="config-value">' + (config.active_project.name || 'None') + '</div>';
}
html += '<div class="config-label">Languages:</div>';
if (this.jetbrainsMode) {
html += '<div class="config-value">Using JetBrains backend</div>';
} else {
html += '<div class="config-value">';
if (config.languages && config.languages.length > 0) {
html += '<div class="languages-container">';
config.languages.forEach(function (language, index) {
const isRemovable = config.languages.length > 1;
html += '<div class="language-badge' + (isRemovable ? ' removable' : '') + '">';
html += language;
if (isRemovable) {
html += '<span class="language-remove" data-language="' + language + '">×</span>';
}
html += '</div>';
});
// Add the "Add Language" button inline with language badges (only if active project exists)
if (config.active_project && config.active_project.name) {
// TODO: address after refactoring, it's not awesome to keep depending on state
if (this.isAddingLanguage) {
html += '<div id="add-language-spinner" class="language-spinner">';
} else {
html += '<button id="add-language-btn" class="btn language-add-btn">+ Add Language</button>';
html += '<div id="add-language-spinner" class="language-spinner" style="display:none;">';
}
html += '<div class="spinner"></div>';
html += '</div>';
}
html += '</div>';
} else {
html += 'N/A';
}
html += '</div>';
}
// Context info
html += '<div class="config-label">Context:</div>';
html += '<div class="config-value"><span title="' + config.context.path + '">' + config.context.name + '</span></div>';
// Modes info
html += '<div class="config-label">Active Modes:</div>';
html += '<div class="config-value">';
if (config.modes.length > 0) {
const modeSpans = config.modes.map(function (mode) {
return '<span title="' + mode.path + '">' + mode.name + '</span>';
});
html += modeSpans.join(', ');
} else {
html += 'None';
}
html += '</div>';
// File Encoding info
html += '<div class="config-label">File Encoding:</div>';
html += '<div class="config-value">' + (config.encoding || 'N/A') + '</div>';
html += '</div>';
// Active tools - collapsible
html += '<div style="margin-top: 20px;">';
html += '<h3 class="collapsible-header" id="tools-header" style="font-size: 16px; margin: 0;">';
html += '<span>Active Tools (' + config.active_tools.length + ')</span>';
html += '<span class="toggle-icon' + (wasToolsExpanded ? ' expanded' : '') + '">▼</span>';
html += '</h3>';
html += '<div class="collapsible-content tools-grid" id="tools-content" style="' + (wasToolsExpanded ? '' : 'display:none;') + ' margin-top: 10px;">';
config.active_tools.forEach(function (tool) {
html += '<div class="tool-item" title="' + tool + '">' + tool + '</div>';
});
html += '</div>';
html += '</div>';
// Available memories - collapsible (show if memories exist or if project exists)
if (config.active_project && config.active_project.name) {
html += '<div style="margin-top: 20px;">';
html += '<h3 class="collapsible-header" id="memories-header" style="font-size: 16px; margin: 0;">';
const memoryCount = (config.available_memories && config.available_memories.length) || 0;
html += '<span>Available Memories (' + memoryCount + ')</span>';
html += '<span class="toggle-icon' + (wasMemoriesExpanded ? ' expanded' : '') + '">▼</span>';
html += '</h3>';
html += '<div class="collapsible-content memories-container" id="memories-content" style="' + (wasMemoriesExpanded ? '' : 'display:none;') + ' margin-top: 10px;">';
if (config.available_memories && config.available_memories.length > 0) {
config.available_memories.forEach(function (memory) {
html += '<div class="memory-item removable" data-memory="' + memory + '">';
html += memory;
html += '<span class="memory-remove" data-memory="' + memory + '">×</span>';
html += '</div>';
});
}
// Add Create Memory button
html += '<button id="create-memory-btn" class="memory-add-btn">+ Add Memory</button>';
html += '</div>';
html += '</div>';
}
// Configuration help link and edit config button
html += '<div style="margin-top: 15px; display: flex; gap: 10px; align-items: center;">';
html += '<div style="flex: 1; padding: 10px; background: var(--bg-secondary); border-radius: 4px; font-size: 13px; border: 1px solid var(--border-color);">';
html += '<span style="color: var(--text-muted);">📖</span> ';
html += '<a href="https://oraios.github.io/serena/02-usage/050_configuration.html" target="_blank" rel="noopener noreferrer" style="color: var(--btn-primary); text-decoration: none; font-weight: 500;">View Configuration Guide</a>';
html += '</div>';
html += '<button id="edit-serena-config-btn" class="btn language-add-btn" style="white-space: nowrap; padding: 10px; ">Edit Global Serena Config</button>';
html += '</div>';
this.$configDisplay.html(html);
// Attach event handlers for the dynamically created add language button
$('#add-language-btn').click(this.openLanguageModal.bind(this));
// Attach event handler for edit serena config button
$('#edit-serena-config-btn').click(this.openEditSerenaConfigModal.bind(this));
// 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);
});
// Attach event handlers for memory items
$('.memory-item').click(function (e) {
e.preventDefault();
const memoryName = $(this).data('memory');
self.openEditMemoryModal(memoryName);
});
// Attach event handlers for memory remove buttons
$('.memory-remove').click(function (e) {
e.preventDefault();
e.stopPropagation();
const memoryName = $(this).data('memory');
self.confirmDeleteMemory(memoryName);
});
// Attach event handler for create memory button
$('#create-memory-btn').click(this.openCreateMemoryModal.bind(this));
// Re-attach collapsible handler for the newly created tools header
$('#tools-header').click(function () {
const $header = $(this);
const $content = $('#tools-content');
const $icon = $header.find('.toggle-icon');
$content.slideToggle(300);
$icon.toggleClass('expanded');
});
// Re-attach collapsible handler for the newly created memories header
$('#memories-header').click(function () {
const $header = $(this);
const $content = $('#memories-content');
const $icon = $header.find('.toggle-icon');
$content.slideToggle(300);
$icon.toggleClass('expanded');
});
} catch (error) {
console.error('Error in displayConfig:', error);
this.$configDisplay.html('<div class="error-message">Error displaying configuration: ' + error.message + '</div>');
}
}
displayBasicStats(stats) {
if (Object.keys(stats).length === 0) {
this.$basicStatsDisplay.html('<div class="no-stats-message">No tool usage stats collected yet.</div>');
return;
}
// Sort tools by call count (descending)
const sortedTools = Object.keys(stats).sort((a, b) => {
return stats[b].num_calls - stats[a].num_calls;
});
const maxCalls = Math.max(...sortedTools.map(tool => stats[tool].num_calls));
let html = '';
sortedTools.forEach(function (toolName) {
const count = stats[toolName].num_calls;
const percentage = maxCalls > 0 ? (count / maxCalls * 100) : 0;
html += '<div class="stat-bar-container">';
html += '<div class="stat-tool-name" title="' + toolName + '">' + toolName + '</div>';
html += '<div class="bar-wrapper">';
html += '<div class="bar" style="width: ' + percentage + '%"></div>';
html += '</div>';
html += '<div class="stat-count">' + count + '</div>';
html += '</div>';
});
this.$basicStatsDisplay.html(html);
}
displayProjects(projects) {
if (!projects || projects.length === 0) {
this.$projectsDisplay.html('<div class="no-stats-message">No projects registered.</div>');
return;
}
let html = '';
projects.forEach(function (project) {
const activeClass = project.is_active ? ' active' : '';
html += '<div class="project-item' + activeClass + '">';
html += '<div class="project-name" title="' + project.name + '">' + project.name + '</div>';
html += '<div class="project-path" title="' + project.path + '">' + project.path + '</div>';
html += '</div>';
});
this.$projectsDisplay.html(html);
}
displayAvailableTools(tools) {
if (!tools || tools.length === 0) {
this.$availableToolsDisplay.html('<div class="no-stats-message">All tools are active.</div>');
return;
}
let html = '';
tools.forEach(function (tool) {
html += '<div class="info-item" title="' + tool.name + '">' + tool.name + '</div>';
});
this.$availableToolsDisplay.html(html);
}
displayAvailableModes(modes) {
if (!modes || modes.length === 0) {
this.$availableModesDisplay.html('<div class="no-stats-message">No modes available.</div>');
return;
}
let html = '';
modes.forEach(function (mode) {
const activeClass = mode.is_active ? ' active' : '';
html += '<div class="info-item' + activeClass + '" title="' + mode.path + '">' + mode.name + '</div>';
});
this.$availableModesDisplay.html(html);
}
displayAvailableContexts(contexts) {
if (!contexts || contexts.length === 0) {
this.$availableContextsDisplay.html('<div class="no-stats-message">No contexts available.</div>');
return;
}
let html = '';
contexts.forEach(function (context) {
const activeClass = context.is_active ? ' active' : '';
html += '<div class="info-item' + activeClass + '" title="' + context.path + '">' + context.name + '</div>';
});
this.$availableContextsDisplay.html(html);
}
// ===== Executions Methods =====
loadQueuedExecutions() {
let self = this;
$.ajax({
url: '/queued_task_executions', type: 'GET', success: function (response) {
if (response.status === 'success') {
self.displayActiveExecutionsQueue(response.queued_executions || []);
} else {
console.error('Error loading executions:', response.message);
}
}, error: function (xhr, status, error) {
console.error('Error loading executions:', error);
self.$activeExecutionQueueDisplay.html('<div class="error-message">Error loading executions</div>');
}
});
}
loadLastExecution() {
let self = this;
$.ajax({
url: '/last_execution', type: 'GET', success: function (response) {
if (response.status === 'success') {
if (response.last_execution !== null && response.last_execution.logged) {
self.displayLastExecution(response.last_execution);
}
} else {
console.error('Error loading last execution:', response.message);
}
}, error: function (xhr, status, error) {
console.error('Error loading last execution:', error);
self.$lastExecutionDisplay.html('<div class="error-message">Error loading last execution</div>');
}
});
}
loadExecutions() {
if (this.waitingForExecutionsPollingResult) {
console.log('Still waiting for previous executions poll result, skipping this poll');
} else {
this.waitingForExecutionsPollingResult = true;
console.log('Polling for executions...');
this.loadQueuedExecutions();
this.loadLastExecution();
}
}
displayActiveExecutionsQueue(executions) {
if (!executions || executions.length === 0) {
return;
}
let html = '<div class="execution-list">';
let self = this;
executions.forEach(function (execution) {
const isRunning = execution.is_running;
const logged = execution.logged;
if (!logged) {
return; // Skip unlogged executions
}
let itemClass = 'execution-item';
if (isRunning) {
itemClass += ' running';
}
// Escape JSON for HTML attribute - replace single quotes and use HTML entities
const executionJson = JSON.stringify(execution).replace(/'/g, ''');
html += '<div class="' + itemClass + '" data-task-id="' + execution.task_id + '" data-execution=\'' + executionJson + '\'>';
if (isRunning) {
html += '<div class="execution-spinner"></div>';
}
html += '<div class="execution-name">' + self.escapeHtml(execution.name) + '</div>';
if (isRunning) {
html += '<div class="execution-meta">#' + execution.task_id + '</div>';
} else {
html += '<div class="execution-meta">queued · #' + execution.task_id + '</div>';
}
html += '<button class="execution-cancel-btn" data-task-id="' + execution.task_id + '" data-is-running="' + isRunning + '">✕</button>';
html += '</div>';
});
html += '</div>';
this.$activeExecutionQueueDisplay.html(html);
// Attach event handlers for cancel buttons
$('.execution-cancel-btn').click(function (e) {
e.preventDefault();
console.log('Cancel button clicked');
const $item = $(this).closest('.execution-item');
console.log('Found item:', $item.length);
const executionDataStr = $item.attr('data-execution');
console.log('Execution data string:', executionDataStr);
if (executionDataStr) {
// Unescape HTML entities
const unescapedStr = executionDataStr.replace(/'/g, "'");
const executionData = JSON.parse(unescapedStr);
console.log('Parsed execution data:', executionData);
self.confirmCancelExecution(executionData);
} else {
console.error('No execution data found on element');
}
});
// Update cancelled executions display
this.displayCancelledExecutions(executions);
}
displayLastExecution(execution) {
if (!execution) {
this.$lastExecutionDisplay.html('<div class="no-stats-message">No executions yet.</div>');
return;
}
const isSuccess = execution.finished_successfully;
let html = '<div class="last-execution-container' + (isSuccess ? '' : ' error') + '">';
html += '<div class="last-execution-icon-container">';
html += isSuccess ? '✓' : '✕';
html += '</div>';
html += '<div class="last-execution-body">';
html += '<div class="last-execution-status">' + (isSuccess ? 'Succeeded' : 'Failed') + '</div>';
html += '<div class="last-execution-name">' + this.escapeHtml(execution.name) + '</div>';
html += '</div>';
html += '<div class="execution-meta">#' + execution.task_id + '</div>';
html += '</div>';
this.$lastExecutionDisplay.html(html);
}
displayCancelledExecutions() {
let self = this;
const cancelledExecs = self.cancelledExecutions
if (cancelledExecs.length === 0) {
// Hide the cancelled executions section
$('.executions-section').eq(2).hide();
return;
}
// Show the cancelled executions section
$('.executions-section').eq(2).show();
let html = '<div class="execution-list">';
cancelledExecs.forEach(function (execution) {
const isAbandoned = execution.is_running;
html += '<div class="execution-item ' + (isAbandoned ? 'abandoned' : 'cancelled') + '">';
html += '<div class="execution-icon ' + (isAbandoned ? 'abandoned' : 'cancelled') + '">';
html += isAbandoned ? '!' : '✕';
html += '</div>';
html += '<div class="execution-name">' + self.escapeHtml(execution.name) + '</div>';
html += '<div class="execution-meta">' + (isAbandoned ? 'abandoned · ' : '') + '#' + execution.task_id + '</div>';
html += '</div>';
});
html += '</div>';
this.$cancelledExecutionsDisplay.html(html);
}
confirmCancelExecution(executionData) {
console.log('confirmCancelExecution called with:', executionData);
this.executionToCancel = executionData;
if (executionData.is_running) {
// Show modal for running executions
console.log('Showing modal for running execution');
this.$cancelExecutionModal.fadeIn(200);
} else {
// Directly cancel queued executions
console.log('Directly cancelling queued execution');
this.cancelExecution(executionData);
}
}
confirmCancelExecutionOk() {
if (this.executionToCancel) {
this.cancelExecution(this.executionToCancel);
}
this.closeCancelExecutionModal();
}
cancelExecution(executionData) {
const self = this;
console.log('cancelExecution called with full execution data:', executionData);
console.log('Attempting to cancel task:', executionData.task_id);
// Call backend API to cancel the task
$.ajax({
url: '/cancel_task_execution', type: 'POST', contentType: 'application/json', data: JSON.stringify({
task_id: executionData.task_id
}), success: function (response) {
console.log('Cancel task response:', response);
if (response.status === 'error') {
console.error('Backend returned error status:', response.message);
alert('Error cancelling task: ' + response.message);
return;
}
if (response.status === 'success') {
if (response.was_cancelled) {
console.log('Task ' + executionData.task_id + ' was successfully cancelled');
// Add to cancelled list (only managed in JS, not persisted)
const alreadyCancelled = self.cancelledExecutions.some(function (exec) {
return exec.task_id === executionData.task_id;
});
if (!alreadyCancelled) {
console.log('Adding execution to cancelled list:', executionData);
self.cancelledExecutions.push(executionData);
console.log('Cancelled executions array now contains:', self.cancelledExecutions);
} else {
console.log('Execution already in cancelled list');
}
} else {
console.log('Task ' + executionData.task_id + ' could not be cancelled (may have already completed). ' + response.message);
}
// Refresh display regardless
self.loadQueuedExecutions();
} else {
console.error('Unexpected response status:', response.status);
alert('Unexpected response from server');
}
}, error: function (xhr, status, error) {
console.error('AJAX error cancelling task:');
console.error(' Status:', status);
console.error(' Error:', error);
console.error(' XHR:', xhr);
console.error(' Response:', xhr.responseText);
let errorMessage = error;
if (xhr.responseJSON && xhr.responseJSON.message) {
errorMessage = xhr.responseJSON.message;
} else if (xhr.responseText) {
errorMessage = xhr.responseText;
}
alert('Error cancelling task: ' + errorMessage);
}
});
}
closeCancelExecutionModal() {
this.$cancelExecutionModal.fadeOut(200);
this.executionToCancel = null;
}
escapeHtml(text) {
if (typeof text !== 'string') return text;
const patterns = {
'<': '<', '>': '>', '&': '&', '"': '"', "'": ''', '`': '`'
};
return text.replace(/[<>&"'`]/g, match => patterns[match]);
}
// ===== Logs Methods =====
displayLogMessage(message) {
this.$logContainer.append(new LogMessage(message, this.toolNames).$elem);
}
loadToolNames() {
let self = this;
return $.ajax({
url: '/get_tool_names', type: 'GET', success: function (response) {
self.toolNames = response.tool_names || [];
console.log('Loaded tool names:', self.toolNames);
}, error: function (xhr, status, error) {
console.error('Error loading tool names:', error);
}
});
}
updateTitle(activeProject) {
document.title = activeProject ? `${activeProject} – Serena Dashboard` : 'Serena Dashboard';
}
copyLogs() {
const logText = this.$logContainer.text();
if (!logText) {
alert('No logs to copy');
return;
}
// Use the Clipboard API to copy text
navigator.clipboard.writeText(logText).then(() => {
// Visual feedback - temporarily change icon to grey checkmark
const originalHtml = this.$copyLogsBtn.html();
const checkmarkSvg = '<svg xmlns="http://www.w3.org/2000/svg" width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="#888" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><polyline points="20 6 9 17 4 12"></polyline></svg><span class="copy-logs-text">copy logs</span>';
this.$copyLogsBtn.html(checkmarkSvg);
setTimeout(() => {
this.$copyLogsBtn.html(originalHtml);
}, 1500);
}).catch(err => {
console.error('Failed to copy logs:', err);
alert('Failed to copy logs to clipboard');
});
}
loadLogs() {
console.log("Loading logs");
let self = this;
self.$errorContainer.empty();
// Make API call
$.ajax({
url: '/get_log_messages', type: 'POST', contentType: 'application/json', data: JSON.stringify({
start_idx: 0
}), success: function (response) {
// Clear existing logs
self.$logContainer.empty();
// Update max_idx
self.currentMaxIdx = response.max_idx || -1;
// Display each log message
if (response.messages && response.messages.length > 0) {
response.messages.forEach(function (message) {
self.displayLogMessage(message);
});
// Auto-scroll to bottom
const logContainer = $('#log-container')[0];
logContainer.scrollTop = logContainer.scrollHeight;
} else {
$('#log-container').html('<div class="loading">No log messages found.</div>');
}
self.updateTitle(response.active_project);
// Start periodic polling for new logs
self.startPeriodicPolling();
}, error: function (xhr, status, error) {
console.error('Error loading logs:', error);
self.$errorContainer.html('<div class="error-message">Error loading logs: ' + (xhr.responseJSON ? xhr.responseJSON.detail : error) + '</div>');
}
});
}
pollForNewLogs() {
let self = this;
console.log("Polling logs", this.currentMaxIdx);
$.ajax({
url: '/get_log_messages',
type: 'POST',
contentType: 'application/json',
data: JSON.stringify({
start_idx: self.currentMaxIdx + 1
}),
success: function (response) {
// Only append new messages if we have any
if (response.messages && response.messages.length > 0) {
let wasAtBottom = false;
const logContainer = $('#log-container')[0];
// Check if user was at the bottom before adding new logs
if (logContainer.scrollHeight > 0) {
wasAtBottom = (logContainer.scrollTop + logContainer.clientHeight) >= (logContainer.scrollHeight - 10);
}
// Append new messages
response.messages.forEach(function (message) {
self.displayLogMessage(message);
});
// Update max_idx
self.currentMaxIdx = response.max_idx || self.currentMaxIdx;
// Auto-scroll to bottom if user was already at bottom
if (wasAtBottom) {
logContainer.scrollTop = logContainer.scrollHeight;
}
} else {
// Update max_idx even if no new messages
self.currentMaxIdx = response.max_idx || self.currentMaxIdx;
}
// Update window title with active project
self.updateTitle(response.active_project);
}
});
}
startPeriodicPolling() {
// Clear any existing interval
if (this.pollInterval) {
clearInterval(this.pollInterval);
}
// Start polling every second (1000ms)
this.pollInterval = setInterval(this.pollForNewLogs.bind(this), 1000);
}
// ===== Stats Methods =====
loadStats() {
let self = this;
$.when($.ajax({url: '/get_tool_stats', type: 'GET'}), $.ajax({
url: '/get_token_count_estimator_name',
type: 'GET'
})).done(function (statsResp, estimatorResp) {
const stats = statsResp[0].stats;
const tokenCountEstimatorName = estimatorResp[0].token_count_estimator_name;
self.displayStats(stats, tokenCountEstimatorName);
}).fail(function () {
console.error('Error loading stats or estimator name');
});
}
clearStats() {
let self = this;
$.ajax({
url: '/clear_tool_stats', type: 'POST', success: function () {
self.loadStats();
}, error: function (xhr, status, error) {
console.error('Error clearing stats:', error);
}
});
}
displayStats(stats, tokenCountEstimatorName) {
const names = Object.keys(stats);
// If no stats collected
if (names.length === 0) {
// hide summary, charts, estimator name
$('#stats-summary').hide();
$('#estimator-name').hide();
$('.charts-container').hide();
// show no-stats message
$('#no-stats-message').show();
return;
} else {
// Ensure everything is visible
$('#estimator-name').show();
$('#stats-summary').show();
$('.charts-container').show();
$('#no-stats-message').hide();
}
$('#estimator-name').html(`<strong>Token count estimator:</strong> ${tokenCountEstimatorName}`);
const counts = names.map(n => stats[n].num_times_called);
const inputTokens = names.map(n => stats[n].input_tokens);
const outputTokens = names.map(n => stats[n].output_tokens);
const totalTokens = names.map(n => stats[n].input_tokens + stats[n].output_tokens);
// Calculate totals for summary table
const totalCalls = counts.reduce((sum, count) => sum + count, 0);
const totalInputTokens = inputTokens.reduce((sum, tokens) => sum + tokens, 0);
const totalOutputTokens = outputTokens.reduce((sum, tokens) => sum + tokens, 0);
// Generate consistent colors for tools
const colors = this.generateColors(names.length);
const countCtx = document.getElementById('count-chart');
const tokensCtx = document.getElementById('tokens-chart');
const inputCtx = document.getElementById('input-chart');
const outputCtx = document.getElementById('output-chart');
if (this.countChart) this.countChart.destroy();
if (this.tokensChart) this.tokensChart.destroy();
if (this.inputChart) this.inputChart.destroy();
if (this.outputChart) this.outputChart.destroy();
// Update summary table
this.updateSummaryTable(totalCalls, totalInputTokens, totalOutputTokens);
// Register datalabels plugin
Chart.register(ChartDataLabels);
// Get theme-aware colors
const isDark = document.documentElement.getAttribute('data-theme') === 'dark';
const textColor = isDark ? '#ffffff' : '#000000';
const gridColor = isDark ? '#444' : '#ddd';
// Tool calls pie chart
this.countChart = new Chart(countCtx, {
type: 'pie', data: {
labels: names, datasets: [{
data: counts, backgroundColor: colors
}]
}, options: {
plugins: {
legend: {
display: true, labels: {
color: textColor
}
}, datalabels: {
display: true, color: 'white', font: {weight: 'bold'}, formatter: (value) => value
}
}
}
});
// Input tokens pie chart
this.inputChart = new Chart(inputCtx, {
type: 'pie', data: {
labels: names, datasets: [{
data: inputTokens, backgroundColor: colors
}]
}, options: {
plugins: {
legend: {
display: true, labels: {
color: textColor
}
}, datalabels: {
display: true, color: 'white', font: {weight: 'bold'}, formatter: (value) => value
}
}
}
});
// Output tokens pie chart
this.outputChart = new Chart(outputCtx, {
type: 'pie', data: {
labels: names, datasets: [{
data: outputTokens, backgroundColor: colors
}]
}, options: {
plugins: {
legend: {
display: true, labels: {
color: textColor
}
}, datalabels: {
display: true, color: 'white', font: {weight: 'bold'}, formatter: (value) => value
}
}
}
});
// Combined input/output tokens bar chart
this.tokensChart = new Chart(tokensCtx, {
type: 'bar', data: {
labels: names, datasets: [{
label: 'Input Tokens', data: inputTokens, backgroundColor: colors.map(color => color + '80'), // Semi-transparent
borderColor: colors, borderWidth: 2, borderSkipped: false, yAxisID: 'y'
}, {
label: 'Output Tokens', data: outputTokens, backgroundColor: colors, yAxisID: 'y1'
}]
}, options: {
responsive: true, plugins: {
legend: {
labels: {
color: textColor
}
}
}, scales: {
x: {
ticks: {
color: textColor
}, grid: {
color: gridColor
}
}, y: {
type: 'linear', display: true, position: 'left', beginAtZero: true, title: {
display: true, text: 'Input Tokens', color: textColor
}, ticks: {
color: textColor
}, grid: {
color: gridColor
}
}, y1: {
type: 'linear', display: true, position: 'right', beginAtZero: true, title: {
display: true, text: 'Output Tokens', color: textColor
}, ticks: {
color: textColor
}, grid: {
drawOnChartArea: false, color: gridColor
}
}
}
}
});
}
generateColors(count) {
const colors = ['#FF6384', '#36A2EB', '#FFCE56', '#4BC0C0', '#9966FF', '#FF9F40', '#FF6384', '#C9CBCF', '#4BC0C0', '#FF6384'];
return Array.from({length: count}, (_, i) => colors[i % colors.length]);
}
updateSummaryTable(totalCalls, totalInputTokens, totalOutputTokens) {
const tableHtml = `
<table class="stats-summary">
<tr><th>Metric</th><th>Total</th></tr>
<tr><td>Tool Calls</td><td>${totalCalls}</td></tr>
<tr><td>Input Tokens</td><td>${totalInputTokens}</td></tr>
<tr><td>Output Tokens</td><td>${totalOutputTokens}</td></tr>
<tr><td>Total Tokens</td><td>${totalInputTokens + totalOutputTokens}</td></tr>
</table>
`;
$('#stats-summary').html(tableHtml);
}
// ===== Theme Methods =====
initializeTheme() {
// Check if user has manually set a theme preference
const savedTheme = localStorage.getItem('serena-theme');
if (savedTheme) {
// User has manually set a preference, use it
this.setTheme(savedTheme);
} else {
// No manual preference, detect system color scheme
this.detectSystemTheme();
}
// Listen for system theme changes
this.setupSystemThemeListener();
}
detectSystemTheme() {
// Check if system prefers dark mode
const prefersDark = window.matchMedia('(prefers-color-scheme: dark)').matches;
const theme = prefersDark ? 'dark' : 'light';
this.setTheme(theme);
}
setupSystemThemeListener() {
// Listen for changes in system color scheme
const mediaQuery = window.matchMedia('(prefers-color-scheme: dark)');
const handleSystemThemeChange = (e) => {
// Only auto-switch if user hasn't manually set a preference
const savedTheme = localStorage.getItem('serena-theme');
if (!savedTheme) {
const newTheme = e.matches ? 'dark' : 'light';
this.setTheme(newTheme);
}
};
// Add listener for system theme changes
if (mediaQuery.addEventListener) {
mediaQuery.addEventListener('change', handleSystemThemeChange);
} else {
// Fallback for older browsers
mediaQuery.addListener(handleSystemThemeChange);
}
}
toggleTheme() {
const currentTheme = document.documentElement.getAttribute('data-theme') || 'light';
const newTheme = currentTheme === 'light' ? 'dark' : 'light';
// When user manually toggles, save their preference
localStorage.setItem('serena-theme', newTheme);
this.setTheme(newTheme);
}
/**
* @param theme {'light' | 'dark'}
*/
setTheme(theme) {
// Set the theme on the document element
document.documentElement.setAttribute('data-theme', theme);
// Update the theme toggle button
if (theme === 'dark') {
this.$themeIcon.text('☀️');
this.$themeText.text('Light');
} else {
this.$themeIcon.text('🌙');
this.$themeText.text('Dark');
}
// Update theme-aware images
$(".theme-aware-img").each(function() {
const $img = $(this);
updateThemeAwareImage($img, theme);
});
// Save to localStorage
localStorage.setItem('serena-theme', theme);
// Update charts if they exist
this.updateChartsTheme();
}
updateChartsTheme() {
const isDark = document.documentElement.getAttribute('data-theme') === 'dark';
const textColor = isDark ? '#ffffff' : '#000000';
const gridColor = isDark ? '#444' : '#ddd';
// Update existing charts if they exist and have the scales property
if (this.countChart && this.countChart.options.plugins) {
if (this.countChart.options.plugins.legend) {
this.countChart.options.plugins.legend.labels.color = textColor;
}
this.countChart.update();
}
if (this.inputChart && this.inputChart.options.plugins) {
if (this.inputChart.options.plugins.legend) {
this.inputChart.options.plugins.legend.labels.color = textColor;
}
this.inputChart.update();
}
if (this.outputChart && this.outputChart.options.plugins) {
if (this.outputChart.options.plugins.legend) {
this.outputChart.options.plugins.legend.labels.color = textColor;
}
this.outputChart.update();
}
if (this.tokensChart && this.tokensChart.options.scales) {
this.tokensChart.options.scales.x.ticks.color = textColor;
this.tokensChart.options.scales.y.ticks.color = textColor;
this.tokensChart.options.scales.y1.ticks.color = textColor;
this.tokensChart.options.scales.x.grid.color = gridColor;
this.tokensChart.options.scales.y.grid.color = gridColor;
this.tokensChart.options.scales.y1.grid.color = gridColor;
this.tokensChart.options.scales.y.title.color = textColor;
this.tokensChart.options.scales.y1.title.color = textColor;
if (this.tokensChart.options.plugins && this.tokensChart.options.plugins.legend) {
this.tokensChart.options.plugins.legend.labels.color = textColor;
}
this.tokensChart.update();
}
}
// ===== 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 removing language ' + language + ": " + 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($('<option>').val('').text('No languages available to add'));
self.$modalAddBtn.prop('disabled', true);
} else {
// Add language options
languages.forEach(function (language) {
self.$modalLanguageSelect.append($('<option>').val(language).text(language));
});
self.$modalAddBtn.prop('disabled', false);
}
}, error: function (xhr, status, error) {
console.error('Error loading available languages:', error);
}
});
}
addLanguageFromModal() {
const selectedLanguage = this.$modalLanguageSelect.val();
if (!selectedLanguage) {
alert('No language selected or no languages available to add');
return;
}
const self = this;
// Close modal immediately
self.closeLanguageModal();
// Hide the inline add language button and show spinner
$('#add-language-btn').hide();
$('#add-language-spinner').show();
self.isAddingLanguage = true;
$.ajax({
url: '/add_language', type: 'POST', contentType: 'application/json', data: JSON.stringify({
language: selectedLanguage
}), success: function (response) {
if (response.status === 'success') {
console.log("Language added successfully");
} else {
alert('Error adding language ' + selectedLanguage + ": " + response.message);
// Restore button visibility on error
$('#add-language-btn').show();
$('#add-language-spinner').hide();
}
}, error: function (xhr, status, error) {
console.error('Error adding language:', error);
alert('Error adding language: ' + (xhr.responseJSON ? xhr.responseJSON.message : error));
// Restore button visibility on error
$('#add-language-btn').show();
$('#add-language-spinner').hide();
}, complete: function () {
self.isAddingLanguage = false;
self.loadConfigOverview();
}
});
}
// ===== Memory Editing Methods =====
openEditMemoryModal(memoryName) {
const self = this;
this.currentMemoryName = memoryName;
this.memoryContentDirty = false;
// Set memory name in modal
this.$editMemoryName.text(memoryName);
// Load memory content
$.ajax({
url: '/get_memory', type: 'POST', contentType: 'application/json', data: JSON.stringify({
memory_name: memoryName
}), success: function (response) {
if (response.status === 'error') {
alert('Error: ' + response.message);
return;
}
self.originalMemoryContent = response.content;
self.$editMemoryContent.val(response.content);
self.memoryContentDirty = false;
self.$editMemoryModal.fadeIn(200);
}, error: function (xhr, status, error) {
console.error('Error loading memory:', error);
alert('Error loading memory: ' + (xhr.responseJSON ? xhr.responseJSON.message : error));
}
});
}
closeEditMemoryModal() {
// Check if there are unsaved changes
if (this.memoryContentDirty) {
if (!confirm('You have unsaved changes. Are you sure you want to close?')) {
return;
}
}
this.$editMemoryModal.fadeOut(200);
this.currentMemoryName = null;
this.originalMemoryContent = null;
this.memoryContentDirty = false;
}
trackMemoryChanges() {
const currentContent = this.$editMemoryContent.val();
this.memoryContentDirty = (currentContent !== this.originalMemoryContent);
}
saveMemoryFromModal() {
const self = this;
const memoryName = this.currentMemoryName;
const content = this.$editMemoryContent.val();
if (!memoryName) {
alert('No memory selected');
return;
}
// Disable button during request
self.$editMemorySaveBtn.prop('disabled', true).text('Saving...');
$.ajax({
url: '/save_memory', type: 'POST', contentType: 'application/json', data: JSON.stringify({
memory_name: memoryName, content: content
}), success: function (response) {
if (response.status === 'success') {
// Update original content and reset dirty flag
self.originalMemoryContent = content;
self.memoryContentDirty = false;
// Close modal
self.$editMemoryModal.fadeOut(200);
self.currentMemoryName = null;
} else {
alert('Error: ' + response.message);
}
}, error: function (xhr, status, error) {
console.error('Error saving memory:', error);
alert('Error saving memory: ' + (xhr.responseJSON ? xhr.responseJSON.message : error));
}, complete: function () {
// Re-enable button
self.$editMemorySaveBtn.prop('disabled', false).text('Save');
}
});
}
confirmDeleteMemory(memoryName) {
// Set memory name to delete
this.memoryToDelete = memoryName;
// Set memory name in modal
this.$deleteMemoryName.text(memoryName);
// Show modal
this.$deleteMemoryModal.fadeIn(200);
}
closeDeleteMemoryModal() {
this.$deleteMemoryModal.fadeOut(200);
this.memoryToDelete = null;
}
confirmDeleteMemoryOk() {
if (this.memoryToDelete) {
this.deleteMemory(this.memoryToDelete);
this.closeDeleteMemoryModal();
}
}
deleteMemory(memoryName) {
const self = this;
$.ajax({
url: '/delete_memory', type: 'POST', contentType: 'application/json', data: JSON.stringify({
memory_name: memoryName
}), success: function (response) {
if (response.status === 'success') {
// Reload config to show updated memory list
self.loadConfigOverview();
} else {
alert('Error: ' + response.message);
}
}, error: function (xhr, status, error) {
console.error('Error deleting memory:', error);
alert('Error deleting memory: ' + (xhr.responseJSON ? xhr.responseJSON.message : error));
}
});
}
openCreateMemoryModal() {
// Set project name in modal
this.$createMemoryProjectName.text(this.activeProjectName || 'Unknown');
// Clear the input field
this.$createMemoryNameInput.val('');
// Show modal
this.$createMemoryModal.fadeIn(200);
// Focus on the input field
setTimeout(() => {
this.$createMemoryNameInput.focus();
}, 250);
}
closeCreateMemoryModal() {
this.$createMemoryModal.fadeOut(200);
this.$createMemoryNameInput.val('');
this.$createMemoryCreateBtn.prop('disabled', false).text('Create');
}
createMemoryFromModal() {
const memoryName = this.$createMemoryNameInput.val().trim();
if (!memoryName) {
alert('Please enter a memory name');
return;
}
// Validate memory name (alphanumeric and underscores only)
if (!/^[a-zA-Z0-9_]+$/.test(memoryName)) {
alert('Memory name can only contain letters, numbers, and underscores');
return;
}
const self = this;
// Disable button during request
self.$createMemoryCreateBtn.prop('disabled', true).text('Creating...');
$.ajax({
url: '/save_memory', type: 'POST', contentType: 'application/json', data: JSON.stringify({
memory_name: memoryName, content: ''
}), success: function (response) {
if (response.status === 'success') {
// Close the create modal
self.closeCreateMemoryModal();
// Reload config to show the new memory
self.loadConfigOverview();
// Open the edit modal for the newly created memory
setTimeout(() => {
self.openEditMemoryModal(memoryName);
}, 500);
} else {
alert('Error: ' + response.message);
self.$createMemoryCreateBtn.prop('disabled', false).text('Create');
}
}, error: function (xhr, status, error) {
console.error('Error creating memory:', error);
alert('Error creating memory: ' + (xhr.responseJSON ? xhr.responseJSON.message : error));
self.$createMemoryCreateBtn.prop('disabled', false).text('Create');
}
});
}
// ===== News Methods =====
loadNews() {
let self = this;
console.log('Loading news...');
$.ajax({
url: '/news_snippet_ids',
type: 'GET',
success: function(response) {
console.log('News snippet IDs response:', response);
if (response.status === 'success' && response.news_snippet_ids && response.news_snippet_ids.length > 0) {
console.log('Displaying news with IDs:', response.news_snippet_ids);
self.displayNews(response.news_snippet_ids);
} else {
console.log('No unread news, hiding section');
self.$newsSection.hide();
}
},
error: function(xhr, status, error) {
console.error('Error loading news snippet IDs:', error);
self.$newsSection.hide();
}
});
}
displayNews(newsIds) {
let self = this;
console.log('displayNews called with:', newsIds);
// Sort newest first (descending order)
newsIds.sort((a, b) => b - a);
if (newsIds.length === 0) {
console.log('No news items to display.');
self.$newsSection.hide();
return;
}
self.$newsSection.show();
self.$newsDisplay.empty();
console.log('Displaying ' + newsIds.length + ' news items.');
// Load each news snippet HTML
let loadedCount = 0;
newsIds.forEach(function(newsId) {
$.ajax({
url: '/dashboard/news/' + newsId + '.html',
type: 'GET',
success: function(html) {
// Wrap the HTML in a container with a button
let $newsContainer = $('<div class="news-container">').attr('data-news-id', newsId);
let $newsContent = $(html);
// Add button for marking as read
let $markRead = $('<div class="news-mark-read">');
let $button = $('<button class="news-mark-read-btn">').attr('data-news-id', newsId).text('Mark as read');
$markRead.append($button);
$newsContent.append($markRead);
$newsContainer.append($newsContent);
self.$newsDisplay.append($newsContainer);
// Bind button click event
$button.on('click', function() {
const btn = $(this);
btn.prop('disabled', true).text('Marking...');
self.markNewsAsRead(newsId);
});
loadedCount++;
},
error: function(xhr, status, error) {
console.error('Error loading news snippet ' + newsId + ':', error);
loadedCount++;
}
});
});
}
markNewsAsRead(newsId) {
let self = this;
$.ajax({
url: '/mark_news_snippet_as_read',
type: 'POST',
contentType: 'application/json',
data: JSON.stringify({ news_snippet_id: newsId }),
success: function(response) {
if (response.status === 'success') {
// Reload news to show updated list
self.loadNews();
} else {
console.error('Error marking news as read:', response.message);
}
},
error: function(xhr, status, error) {
console.error('Error marking news as read:', error);
}
});
}
// ===== Serena Config Editing Methods =====
openEditSerenaConfigModal() {
const self = this;
this.serenaConfigContentDirty = false;
// Load serena config content
$.ajax({
url: '/get_serena_config', type: 'GET', success: function (response) {
if (response.status === 'error') {
alert('Error: ' + response.message);
return;
}
self.originalSerenaConfigContent = response.content;
self.$editSerenaConfigContent.val(response.content);
self.serenaConfigContentDirty = false;
self.$editSerenaConfigModal.fadeIn(200);
}, error: function (xhr, status, error) {
console.error('Error loading serena config:', error);
alert('Error loading serena config: ' + (xhr.responseJSON ? xhr.responseJSON.message : error));
}
});
// Track changes to config content
this.$editSerenaConfigContent.off('input').on('input', function () {
const currentContent = self.$editSerenaConfigContent.val();
self.serenaConfigContentDirty = (currentContent !== self.originalSerenaConfigContent);
});
}
closeEditSerenaConfigModal() {
// Check if there are unsaved changes
if (this.serenaConfigContentDirty) {
if (!confirm('You have unsaved changes. Are you sure you want to close?')) {
return;
}
}
this.$editSerenaConfigModal.fadeOut(200);
this.originalSerenaConfigContent = null;
this.serenaConfigContentDirty = false;
}
saveSerenaConfigFromModal() {
const self = this;
const content = this.$editSerenaConfigContent.val();
// Disable button during request
self.$editSerenaConfigSaveBtn.prop('disabled', true).text('Saving...');
$.ajax({
url: '/save_serena_config', type: 'POST', contentType: 'application/json', data: JSON.stringify({
content: content
}), success: function (response) {
if (response.status === 'success') {
// Update original content and reset dirty flag
self.originalSerenaConfigContent = content;
self.serenaConfigContentDirty = false;
// Close modal
self.$editSerenaConfigModal.fadeOut(200);
alert('Configuration saved successfully. Please restart Serena for changes to take effect.');
} else {
alert('Error: ' + response.message);
}
}, error: function (xhr, status, error) {
console.error('Error saving serena config:', error);
alert('Error saving serena config: ' + (xhr.responseJSON ? xhr.responseJSON.message : error));
}, complete: function () {
// Re-enable button
self.$editSerenaConfigSaveBtn.prop('disabled', false).text('Save');
}
});
}
// ===== Shutdown Method =====
shutdown() {
const self = this;
const _shutdown = function () {
console.log("Triggering shutdown");
$.ajax({
url: '/shutdown', type: "PUT", contentType: 'application/json',
});
self.$errorContainer.html('<div class="error-message">Shutting down ...</div>')
setTimeout(function () {
window.close();
}, 1000);
}
// ask for confirmation using a dialog
if (confirm("This will fully terminate the Serena server.")) {
_shutdown();
} else {
console.log("Shutdown cancelled");
}
// Close menu
self.$menuDropdown.hide();
}
}
```