@tool class_name McpDock extends VBoxContainer ## Editor dock panel showing MCP connection status, client config, and command log. ## ## Audit-v2 #360 partial extraction. Two cohesive subpanels live in ## res://addons/godot_ai/dock_panels/: ## - log_viewer.gd: MCP request/response log (dev-mode only). ## - port_picker_panel.gd: spawn-failure escape hatch nested in the crash panel. ## ## The audit also called for ServerStatusPanel and ClientRowController ## extractions; those were *deliberately deferred*. Their UI scatters across ## the dock layout (status icon at top, crash panel mid, setup section lower; ## client rows + drift banner + scroll grid spread similarly), so a clean ## extract-by-panel needs either visible UI reorganization or a coordinator- ## Node pattern with property-accessor façades on McpDock that re-tangle the ## very state they claim to move. ## ## A future refactor probably wants extract-by-concern instead — e.g. ## `utils/mcp_async_refresh_state_machine.gd` owning the IDLE → RUNNING → ## RUNNING_TIMED_OUT → DEFERRED_FOR_FILESYSTEM → SHUTTING_DOWN transitions ## and pending-flag triplet, `utils/mcp_client_action_dispatcher.gd` owning ## the per-row Configure/Remove worker pool. The dock would keep UI ## construction and lose the state-machine ownership. See issue #360. const ServerStateScript := preload("res://addons/godot_ai/utils/mcp_server_state.gd") const ClientRefreshStateScript := preload("res://addons/godot_ai/utils/mcp_client_refresh_state.gd") const Telemetry := preload("res://addons/godot_ai/telemetry.gd") const UpdateManagerScript := preload("res://addons/godot_ai/utils/update_manager.gd") const UpdateMixedStateScript := preload("res://addons/godot_ai/utils/update_mixed_state.gd") const Client := preload("res://addons/godot_ai/clients/_base.gd") const ClientConfigurator := preload("res://addons/godot_ai/client_configurator.gd") const ClientRegistry := preload("res://addons/godot_ai/clients/_registry.gd") const JsonStrategy := preload("res://addons/godot_ai/clients/_json_strategy.gd") const TomlStrategy := preload("res://addons/godot_ai/clients/_toml_strategy.gd") const CliStrategy := preload("res://addons/godot_ai/clients/_cli_strategy.gd") const ToolCatalog := preload("res://addons/godot_ai/tool_catalog.gd") const LogViewerScript := preload("res://addons/godot_ai/dock_panels/log_viewer.gd") const PortPickerPanelScript := preload("res://addons/godot_ai/dock_panels/port_picker_panel.gd") const DEV_MODE_SETTING := "godot_ai/dev_mode" const CLIENT_STATUS_REFRESH_COOLDOWN_MSEC := 15 * 1000 const CLIENT_STATUS_REFRESH_TIMEOUT_MSEC := 30 * 1000 static var COLOR_MUTED := Color(0.7, 0.7, 0.7) static var COLOR_HEADER := Color(0.95, 0.95, 0.95) ## Used for "in-progress" / "stale, action needed" UI: the startup-grace ## status icon, the spawn-failure suggested-port hint, the drift banner, ## and the per-row mismatch dot. One constant so a future palette tweak ## doesn't have to find every literal. static var COLOR_AMBER := Color(1.0, 0.75, 0.25) var _connection var _log_buffer var _plugin: EditorPlugin # Always visible var _redock_btn: Button var _status_icon: ColorRect var _status_label: Label var _client_grid: VBoxContainer var _client_configure_all_btn: Button var _clients_summary_label: Label var _clients_window: Window var _dev_mode_toggle: CheckButton var _install_label: Label # Settings tab (secondary window, Tab 2) — domain-exclusion UI for clients # that cap total tool count (Antigravity: 100). Pending set is mutated by # checkbox clicks; saved set reflects what the spawned server actually # sees. `Apply & Restart Server` writes pending → setting and triggers a # plugin reload so the new server comes up with the trimmed list. var _tools_pending_excluded: PackedStringArray = PackedStringArray() var _tools_saved_excluded: PackedStringArray = PackedStringArray() var _tools_domain_checkboxes: Dictionary = {} var _tools_count_label: Label var _tools_apply_btn: Button var _tools_reset_btn: Button var _tools_dirty_warning: Label var _tools_close_confirm: ConfirmationDialog var _telemetry_toggle: CheckButton var _telemetry_pending_enabled: bool = true var _telemetry_saved_enabled: bool = true ## Per-client UI handles, keyed by client id. Each entry holds the row's ## status dot, configure button, remove button, manual-command panel + text. var _client_rows: Dictionary = {} # Drift banner — surfaced near the Clients section when one or more clients # have a stored entry whose URL no longer matches `http_url()` (typical after # the user changes `godot_ai/http_port`). Refreshes are stale-while-refreshing: # cached row dots/banner remain visible while a background worker performs the # potentially blocking config/CLI probes, then the main thread applies results. # Automatic focus-in refreshes use a short cooldown to avoid repeated sweeps # during tab-away/tab-back churn. See #166 and #226. var _drift_banner: VBoxContainer var _drift_label: Label ## Handles for the Setup section's "Server" row. `_update_status` keeps ## the label text/color in sync with `McpConnection.server_version` so the ## dock reports the TRUE running server version, not the plugin's ## expected version. See #174 follow-up — a plugin upgrade via self- ## update can leave the plugin connected to an older adopted server ## (foreign-port branch never sets `_server_pid`, so `_stop_server` ## can't kill it); the line has to show the mismatch honestly. var _setup_server_label: Label ## Last rendered server-version string. `_update_status` runs every ## frame; early-outs text repaint when nothing changed. Empty means ## "no line rendered yet" (dev-checkout branch doesn't render a ## user-mode Server line). var _last_rendered_server_text: String = "" ## Restart-server button shown next to the Setup container when ## `McpConnection.server_version` drifts from the plugin version. Hidden ## in the match case so the UI stays calm. var _version_restart_btn: Button var _server_restart_in_progress := false ## Sorted snapshot of the most recent mismatched-client set. Powers two things: ## (a) the Reconfigure button reuses this list instead of re-running ## `check_status` per row (saves ~18 filesystem reads per click), and ## (b) `_refresh_drift_banner` early-returns when the set is unchanged so ## repeated explicit refreshes don't repaint identical text. Mirrors the ## `_last_server_status` pattern used by the crash panel. var _last_mismatched_ids: Array[String] = [] var _client_status_refresh_thread: Thread ## Single source of truth for the refresh-sweep state machine. See ## `ClientRefreshStateScript` for the transition table. Replaces the ## previously scattered booleans (`_in_flight`, `_timed_out`, ## `_deferred_until_filesystem_ready`, `_shutdown_requested`). var _refresh_state: int = ClientRefreshStateScript.IDLE ## Pending-request flags. Kept separate from `_refresh_state` because ## they're "what should the next refresh look like" — not state of ## any current refresh. A pending request is queued when a refresh ## arrives during RUNNING / RUNNING_TIMED_OUT and consumed by ## `_apply_client_status_refresh_results` once the in-flight worker ## drains. `_pending_force` also captures forced retries deferred via ## DEFERRED_FOR_FILESYSTEM so a pending user click survives the wait. var _client_status_refresh_pending: bool = false var _client_status_refresh_pending_force: bool = false var _client_status_refresh_pending_initial: bool = false var _last_client_status_refresh_completed_msec: int = 0 var _client_status_refresh_started_msec: int = 0 var _client_status_refresh_generation: int = 0 ## Owns the self-update slice: GitHub Releases poll, ZIP download, install ## orchestration, and the install-in-flight gate. Dock keeps banner UI ## only and consults the gate via `_is_self_update_in_progress()`. var _update_manager static var _orphaned_client_status_refresh_threads: Array[Thread] = [] ## Per-row worker state for Configure / Remove. Issue #239: shelling out ## to a hung CLI on main hangs the editor. We dispatch each click to its ## own thread (one slot per client) and apply the result via call_deferred ## once the subprocess returns or the wall-clock budget in McpCliExec ## kicks in. The buttons stay disabled while the slot is busy so the user ## can't queue a re-click on the same row. ## ## Per-client (not single-slot) so Configure-all can fan out — the ## workers are independent, only the row UI is shared, and McpCliExec ## bounds the wall-clock for each. ## ## No orphan-thread list (unlike the refresh worker): action threads ## never get abandoned mid-flight. McpCliExec's wall-clock budget caps ## the worst case at ~10s, so the `_exit_tree` / `McpUpdateManager` ## install-time drain blocks briefly and finishes — there's no path that ## "gives up" on an action thread the way `_abandon_client_status_refresh_thread` ## does for the refresh worker. var _client_action_threads: Dictionary = {} var _client_action_generations: Dictionary = {} # Dev-mode only var _dev_section: VBoxContainer var _server_label: Label var _reload_btn: Button var _setup_section: VBoxContainer var _setup_container: VBoxContainer ## Primary dev-section button — always (re)starts a `--reload` dev server. ## Same-version Python edits get adopted as compatible by the lifecycle, so ## neither the drift nor the crash Restart button surfaces; this is the ## unconditional kick contributors need to pick up source changes without ## a version bump. var _dev_primary_btn: Button ## Small "✕" affordance next to the primary — stops the dev server without ## spawning a replacement. Disabled when no dev server is running. var _dev_stop_btn: Button var _log_viewer: LogViewerScript var _last_connected := false var _last_status_text := "" var _startup_grace_until_msec: int = 0 # Spawn-failure panel — rendered when `get_server_status` reports a # non-OK `state`. One panel, one body paragraph per state, no cascading # booleans. See `_crash_body_for_state`. var _crash_panel: VBoxContainer var _crash_output: RichTextLabel var _crash_restart_btn: Button var _crash_reload_btn: Button ## Port-picker escape hatch — visible inside the crash panel when the root ## cause is port contention (PORT_EXCLUDED or FOREIGN_PORT). The dock writes ## the EditorSetting and reloads the plugin in response to the panel's ## `port_apply_requested` signal. var _port_picker_panel: PortPickerPanelScript ## Last status Dict rendered into the panel — used to skip re-population ## when nothing changed, which would otherwise reset the user's scroll ## position on every frame. GDScript Dicts compare by value with `==`. var _last_server_status: Dictionary = {} # First-run grace: uvx installs 60+ Python packages on first run (can take # 10-30s on a slow connection). Don't scare users with "Disconnected" during # that window — show "Starting server…" instead. After this expires, fall # back to the normal disconnect UI. const STARTUP_GRACE_MSEC := 60 * 1000 # Update banner — visible UI only. Releases polling, ZIP download, and # the install pipeline live on `_update_manager`. var _update_banner: VBoxContainer var _update_label: Label var _update_btn: Button # Mixed-state banner — surfaces when `addons/godot_ai/` contains # `*.update_backup` files left by a self-update whose rollback failed # (`UpdateReloadRunner.InstallStatus.FAILED_MIXED`). Without this banner # the user sees "plugin won't start" with no actionable context, re-runs # the update, and compounds the mismatch (issue #354 / audit-v2 #10). var _mixed_state_banner: VBoxContainer var _mixed_state_label: Label var _mixed_state_files: RichTextLabel var _mixed_state_rescan_btn: Button func setup(connection: McpConnection, log_buffer: McpLogBuffer, plugin: EditorPlugin) -> void: _connection = connection _log_buffer = log_buffer _plugin = plugin _startup_grace_until_msec = Time.get_ticks_msec() + STARTUP_GRACE_MSEC func _ready() -> void: _build_ui() func _process(_delta: float) -> void: if _connection == null: return _prune_orphaned_client_status_refresh_threads() _check_client_status_refresh_timeout() _retry_deferred_client_status_refresh() _update_status() if _log_viewer != null and _log_viewer.visible: _log_viewer.tick() func _exit_tree() -> void: ## Block on any in-flight refresh worker before letting the dock leave the ## tree. The plugin disable path (editor_reload_plugin, Project Settings ## toggle) reloads the McpDock script class — which wipes the static ## `_orphaned_client_status_refresh_threads`, GCs the Thread objects mid- ## execution, and triggers `~Thread … destroyed without its completion ## having been realized` plus GDScript VM corruption (Opcode: 0, IP-bounds ## errors, intermittent SIGSEGV). Probes finish in well under a second ## under normal conditions; if a CLI probe genuinely hung, the runtime ## timeout path (`_abandon_client_status_refresh_thread`) has already ## moved that thread into the orphan list, so we drain it here too. ## ## `wait_to_finish` is unbounded by design: GDScript's Thread API has no ## timeout, and a polling/abandon fallback would just re-introduce the ## GC-mid-execution crash this fix exists to prevent. Blocking the editor ## briefly on plugin-reload is strictly better than the SIGSEGV. _refresh_state = ClientRefreshStateScript.SHUTTING_DOWN _drain_client_status_refresh_workers() _drain_client_action_workers() ## Public drain entry consulted by `McpUpdateManager._install_zip` before ## any disk write. Pairs both worker pools so the manager doesn't reach ## into private dock methods. `_exit_tree` still calls the two underlying ## drains directly because it has additional state-machine work ## (SHUTTING_DOWN sticky-set) that the install-time path must NOT inherit. func prepare_for_self_update_drain() -> void: _drain_client_status_refresh_workers() _drain_client_action_workers() func _drain_client_status_refresh_workers() -> void: ## Block until any in-flight refresh worker (and any orphaned workers from ## a prior timeout) finish, then clear refresh state. Same blocking ## semantics as the `_exit_tree` drain — see #232. Used by `_exit_tree` ## (dock teardown) and `McpUpdateManager._install_zip` (before extract ## overwrites plugin scripts on disk). _client_status_refresh_generation += 1 if _client_status_refresh_thread != null: _client_status_refresh_thread.wait_to_finish() _client_status_refresh_thread = null for thread in _orphaned_client_status_refresh_threads: if thread != null: thread.wait_to_finish() _orphaned_client_status_refresh_threads.clear() ## Don't transition out of SHUTTING_DOWN — the drain is called from ## `_exit_tree` (sticky shutdown) and from ## `McpUpdateManager._install_zip`'s post-drain reset, which writes ## the state explicitly. if _refresh_state != ClientRefreshStateScript.SHUTTING_DOWN: _refresh_state = ClientRefreshStateScript.IDLE _client_status_refresh_pending = false _client_status_refresh_pending_force = false _client_status_refresh_pending_initial = false func _drain_client_action_workers() -> void: ## Same drain semantics as the refresh worker (see comment above): the ## plugin disable / install-update path reloads our script class, so any ## live Thread must finish before its slot is GC'd or we hit ## `~Thread … destroyed without its completion having been realized` → ## VM corruption. Bounded by `McpCliExec` wall-clock budgets, so the ## worst case is a ~10s blocking drain, vs. an unbounded SIGSEGV. ## ## Generation-bumped per-row so any pending `call_deferred( ## "_apply_client_action_result")` from a worker that finished after we ## started draining detects the generation mismatch and short-circuits ## without touching freed UI state. ## ## After draining, restore the row UI for any in-flight rows: bare ## `_client_action_threads.clear()` would leave the dock stuck showing ## "Configuring…" / "Removing…" with disabled buttons forever — a ## user-visible failure mode for the install-update bail-out branch ## (zip extract failure on the manager clears `_install_in_flight` and ## the dock stays alive). for client_id in _client_action_threads.keys(): var t: Thread = _client_action_threads[client_id] if t != null: t.wait_to_finish() _client_action_generations[client_id] = int(_client_action_generations.get(client_id, 0)) + 1 _finalize_action_buttons(String(client_id)) var row: Dictionary = _client_rows.get(String(client_id), {}) if not row.is_empty(): _apply_row_status( String(client_id), row.get("status", Client.Status.NOT_CONFIGURED), "" ) _client_action_threads.clear() func _notification(what: int) -> void: # Detect dock/undock by watching for reparenting events. if what == NOTIFICATION_PARENTED or what == NOTIFICATION_UNPARENTED: _update_redock_visibility.call_deferred() elif what == NOTIFICATION_APPLICATION_FOCUS_IN: if _should_refresh_client_statuses_on_focus_in(): _request_client_status_refresh(false) func _should_refresh_client_statuses_on_focus_in() -> bool: ## Focus-in is part of Godot/editor window activation. Keep automatic refresh, ## but only through the async/cooldown-protected path; never run a blocking ## client-status sweep directly from this notification. return true func _is_floating() -> bool: var p := get_parent() while p != null: if p is Window: return p != get_tree().root p = p.get_parent() return false func _update_redock_visibility() -> void: if _redock_btn == null: return var floating := _is_floating() if _redock_btn.visible != floating: _redock_btn.visible = floating func _on_redock() -> void: # When floating, our Window is NOT the editor root. Closing it triggers # Godot's internal dock-return logic (same as clicking the window's X). var win := get_window() if win != null and win != get_tree().root: win.close_requested.emit() func _build_margin_container(margin: int = 12) -> MarginContainer: var margin_container := MarginContainer.new() margin_container.add_theme_constant_override("margin_left", margin) margin_container.add_theme_constant_override("margin_right", margin) margin_container.add_theme_constant_override("margin_top", margin) margin_container.add_theme_constant_override("margin_bottom", margin) return margin_container func _build_ui() -> void: add_theme_constant_override("separation", 8) # --- Top row: status indicator + redock button (when floating) --- var status_row := HBoxContainer.new() status_row.add_theme_constant_override("separation", 8) _status_icon = ColorRect.new() _status_icon.custom_minimum_size = Vector2(14, 14) # Amber on first paint — matches the "Starting server…" label text and # distinguishes from a real disconnect (red). _status_icon.color = COLOR_AMBER var icon_center := CenterContainer.new() icon_center.add_child(_status_icon) status_row.add_child(icon_center) _status_label = Label.new() # Start in grace state — _update_status will take over on the next frame # once the connection is available. Never show bare "Disconnected" on # first paint because that's misleading while the server is still # spinning up. _status_label.text = "Starting server…" _status_label.size_flags_horizontal = Control.SIZE_EXPAND_FILL status_row.add_child(_status_label) _redock_btn = Button.new() _redock_btn.text = "Dock" _redock_btn.tooltip_text = "Return this panel to the editor dock" _redock_btn.visible = false _redock_btn.pressed.connect(_on_redock) status_row.add_child(_redock_btn) add_child(status_row) # Install-mode line — so a git-clone user doesn't press the yellow Update # banner below and silently downgrade from main to the last release tag. # See #144. _install_label = Label.new() _install_label.add_theme_color_override("font_color", COLOR_MUTED) _install_label.autowrap_mode = TextServer.AUTOWRAP_WORD_SMART _install_label.size_flags_horizontal = Control.SIZE_EXPAND_FILL _install_label.text = _install_mode_text() _install_label.tooltip_text = _install_mode_tooltip() _install_label.mouse_filter = Control.MOUSE_FILTER_STOP add_child(_install_label) # --- Spawn-failure panel (shown when `_start_server` reports a non-OK # state via `get_server_status`). One body paragraph + the matching # action; the top status label already carries the state headline. _crash_panel = VBoxContainer.new() _crash_panel.add_theme_constant_override("separation", 6) _crash_panel.visible = false _crash_output = RichTextLabel.new() _crash_output.custom_minimum_size = Vector2(0, 60) _crash_output.bbcode_enabled = false _crash_output.selection_enabled = true _crash_output.scroll_following = false _crash_output.autowrap_mode = TextServer.AUTOWRAP_WORD_SMART _crash_output.fit_content = true _crash_panel.add_child(_crash_output) _port_picker_panel = PortPickerPanelScript.new() _port_picker_panel.setup() _port_picker_panel.port_apply_requested.connect(_on_port_apply_requested) _crash_panel.add_child(_port_picker_panel) _crash_restart_btn = Button.new() _crash_restart_btn.text = "Restart Server" _crash_restart_btn.tooltip_text = "Stop the old server on this port and start the bundled godot-ai server" _crash_restart_btn.size_flags_horizontal = Control.SIZE_EXPAND_FILL _crash_restart_btn.add_theme_color_override("font_color", Color.WHITE) _crash_restart_btn.add_theme_color_override("font_hover_color", Color.WHITE) _crash_restart_btn.add_theme_color_override("font_pressed_color", Color.WHITE) _crash_restart_btn.pressed.connect(_on_restart_stale_server) _crash_restart_btn.visible = false _crash_panel.add_child(_crash_restart_btn) _crash_reload_btn = Button.new() _crash_reload_btn.text = "Reload Plugin" _crash_reload_btn.tooltip_text = "Re-run the spawn after fixing the underlying issue" _crash_reload_btn.pressed.connect(_on_reload_plugin) _crash_panel.add_child(_crash_reload_btn) _crash_panel.add_child(HSeparator.new()) add_child(_crash_panel) _build_mixed_state_banner() _refresh_mixed_state_banner() # --- Update banner (top of dock, hidden until check finds a newer version) --- _update_banner = VBoxContainer.new() _update_banner.add_theme_constant_override("separation", 4) _update_banner.visible = false _update_label = Label.new() _update_label.add_theme_font_size_override("font_size", 15) _update_label.add_theme_color_override("font_color", Color(1.0, 0.85, 0.3)) ## Wrap long banner text (e.g. the < 4.4 manual-update guidance) instead ## of letting a single line stretch the whole dock wide. The dock is a ## fixed-width side panel, so constrain horizontally and wrap. _update_label.autowrap_mode = TextServer.AUTOWRAP_WORD_SMART _update_label.size_flags_horizontal = Control.SIZE_FILL _update_label.custom_minimum_size = Vector2(0, 0) _update_banner.add_child(_update_label) var update_btn_row := HBoxContainer.new() update_btn_row.add_theme_constant_override("separation", 6) _update_btn = Button.new() _update_btn.text = "Update" _update_btn.size_flags_horizontal = Control.SIZE_EXPAND_FILL _update_btn.pressed.connect(_on_update_pressed) update_btn_row.add_child(_update_btn) var release_link := Button.new() release_link.text = "Release notes" release_link.pressed.connect(func(): OS.shell_open(UpdateManagerScript.RELEASES_PAGE)) update_btn_row.add_child(release_link) _update_banner.add_child(update_btn_row) _update_banner.add_child(HSeparator.new()) add_child(_update_banner) if _update_manager == null: _update_manager = UpdateManagerScript.new() _update_manager.setup(_plugin, self) _update_manager.update_check_completed.connect(_on_update_check_result) _update_manager.install_state_changed.connect(_on_install_state_changed) add_child(_update_manager) _update_manager.check_for_updates.call_deferred() # --- Dev-only connection extras (server label + reload button) --- _dev_section = VBoxContainer.new() _dev_section.add_theme_constant_override("separation", 6) add_child(_dev_section) _server_label = Label.new() _server_label.add_theme_color_override("font_color", COLOR_MUTED) _dev_section.add_child(_server_label) _refresh_server_label() var btn_row := HBoxContainer.new() btn_row.add_theme_constant_override("separation", 6) _reload_btn = Button.new() _reload_btn.text = "Dev: Reload Plugin" _reload_btn.tooltip_text = "Developer utility: reload the GDScript plugin. This does not restart or replace the server." _reload_btn.size_flags_horizontal = Control.SIZE_EXPAND_FILL _reload_btn.pressed.connect(_on_reload_plugin) btn_row.add_child(_reload_btn) _dev_section.add_child(btn_row) # --- Setup section (dev-only or when uv missing) --- _setup_section = VBoxContainer.new() _setup_section.add_theme_constant_override("separation", 6) add_child(_setup_section) _setup_section.add_child(HSeparator.new()) _setup_section.add_child(_make_header("Setup")) _setup_container = VBoxContainer.new() _setup_container.add_theme_constant_override("separation", 6) _setup_section.add_child(_setup_container) add_child(HSeparator.new()) # --- Clients --- var clients_row := HBoxContainer.new() clients_row.add_theme_constant_override("separation", 8) var clients_header := _make_header("Clients") clients_row.add_child(clients_header) _clients_summary_label = Label.new() _clients_summary_label.add_theme_color_override("font_color", COLOR_MUTED) _clients_summary_label.size_flags_horizontal = Control.SIZE_EXPAND_FILL clients_row.add_child(_clients_summary_label) var clients_refresh_btn := Button.new() clients_refresh_btn.text = "Refresh" clients_refresh_btn.tooltip_text = "Refresh client status in the background. Cached status stays visible while checks run." clients_refresh_btn.pressed.connect(_on_refresh_clients_pressed) clients_row.add_child(clients_refresh_btn) var clients_open_btn := Button.new() clients_open_btn.text = "Clients & Settings" clients_open_btn.tooltip_text = "Open the MCP settings window — configure AI clients, choose telemetry preferences, or disable tool domains to fit under a client's hard tool-count cap (e.g. Antigravity's 100)." clients_open_btn.pressed.connect(_on_open_clients_window) clients_row.add_child(clients_open_btn) add_child(clients_row) # Drift banner — hidden until a sweep finds at least one mismatched client. _drift_banner = VBoxContainer.new() _drift_banner.add_theme_constant_override("separation", 4) _drift_banner.visible = false _drift_label = Label.new() _drift_label.add_theme_color_override("font_color", COLOR_AMBER) _drift_label.autowrap_mode = TextServer.AUTOWRAP_WORD_SMART _drift_label.size_flags_horizontal = Control.SIZE_EXPAND_FILL _drift_banner.add_child(_drift_label) var drift_btn := Button.new() drift_btn.text = "Reconfigure mismatched" drift_btn.tooltip_text = "Re-run Configure on every client whose stored URL doesn't match the current server URL." drift_btn.pressed.connect(_on_reconfigure_mismatched) _drift_banner.add_child(drift_btn) add_child(_drift_banner) _clients_window = Window.new() _clients_window.title = "MCP Clients & Settings" ## `Vector2i * float` yields Vector2; wrap the result back to Vector2i. _clients_window.min_size = Vector2i(Vector2(560, 460) * EditorInterface.get_editor_scale()) _clients_window.visible = false _clients_window.close_requested.connect(_on_clients_window_close_requested) add_child(_clients_window) ## Two-tab secondary window: Clients (existing per-client rows) and Tools ## (domain-exclusion checkboxes for clients that cap total tool count, ## like Antigravity at 100). Adding a third tab is one more _build_*_tab ## call and a set_tab_title line — no surgery on the rest of the window. var tabs := TabContainer.new() tabs.anchor_right = 1.0 tabs.anchor_bottom = 1.0 _clients_window.add_child(tabs) var clients_tab := VBoxContainer.new() clients_tab.add_theme_constant_override("separation", 8) var clients_margin := _build_margin_container() clients_margin.name = "Clients" clients_margin.add_child(clients_tab) tabs.add_child(clients_margin) _client_configure_all_btn = Button.new() _client_configure_all_btn.text = "Configure all" _client_configure_all_btn.tooltip_text = "Configure every client that isn't already pointing at this server" _client_configure_all_btn.size_flags_horizontal = Control.SIZE_SHRINK_END _client_configure_all_btn.pressed.connect(_on_configure_all_clients) clients_tab.add_child(_client_configure_all_btn) var clients_scroll := ScrollContainer.new() clients_scroll.size_flags_horizontal = Control.SIZE_EXPAND_FILL clients_scroll.size_flags_vertical = Control.SIZE_EXPAND_FILL clients_scroll.horizontal_scroll_mode = ScrollContainer.SCROLL_MODE_DISABLED clients_tab.add_child(clients_scroll) _client_grid = VBoxContainer.new() _client_grid.add_theme_constant_override("separation", 4) _client_grid.size_flags_horizontal = Control.SIZE_EXPAND_FILL clients_scroll.add_child(_client_grid) for client_id in ClientConfigurator.client_ids(): _build_client_row(client_id) _build_tools_tab(tabs) add_child(HSeparator.new()) # --- Dev mode toggle (always visible) --- var dev_toggle_row := HBoxContainer.new() var dev_toggle_label := Label.new() dev_toggle_label.text = "Developer mode" dev_toggle_label.size_flags_horizontal = Control.SIZE_EXPAND_FILL dev_toggle_row.add_child(dev_toggle_label) _dev_mode_toggle = CheckButton.new() _dev_mode_toggle.button_pressed = _load_dev_mode() _dev_mode_toggle.toggled.connect(_on_dev_mode_toggled) dev_toggle_row.add_child(_dev_mode_toggle) add_child(dev_toggle_row) # --- Log section (dev-only) --- _log_viewer = LogViewerScript.new() _log_viewer.setup(_log_buffer) _log_viewer.logging_enabled_changed.connect(_on_log_logging_enabled_changed) add_child(_log_viewer) # Apply initial dev-mode visibility _apply_dev_mode_visibility() _refresh_setup_status.call_deferred() _perform_initial_client_status_refresh() ## Static so `dock_panels/*.gd` subpanels can call it via `McpDock._make_header(...)` ## without re-declaring identical helpers + COLOR_HEADER constants. static func _make_header(text: String) -> Label: var label := Label.new() label.text = text label.add_theme_font_size_override("font_size", 18) label.add_theme_color_override("font_color", COLOR_HEADER) return label func _build_client_row(client_id: String) -> void: var row := HBoxContainer.new() row.add_theme_constant_override("separation", 6) row.size_flags_horizontal = Control.SIZE_EXPAND_FILL var dot := ColorRect.new() dot.custom_minimum_size = Vector2(10, 10) dot.color = COLOR_MUTED var dot_center := CenterContainer.new() dot_center.add_child(dot) row.add_child(dot_center) var name_label := Label.new() name_label.text = ClientConfigurator.client_display_name(client_id) name_label.size_flags_horizontal = Control.SIZE_EXPAND_FILL ## Long error messages from `_verify_post_state` (e.g. "reported remove ok ## but verification still reads configured…") used to push the Retry / ## Configure button off-screen — the row's Label wanted its full text ## width as minimum size, so the buttons got squeezed out. Wrap onto ## multiple lines instead so the row keeps its right edge stable and ## the buttons remain visible; the user can also read the whole message ## without resizing the window. name_label.autowrap_mode = TextServer.AUTOWRAP_WORD_SMART name_label.vertical_alignment = VERTICAL_ALIGNMENT_CENTER row.add_child(name_label) var configure_btn := Button.new() configure_btn.text = "Configure" configure_btn.pressed.connect(_on_configure_client.bind(client_id)) row.add_child(configure_btn) var remove_btn := Button.new() remove_btn.text = "Remove" remove_btn.visible = false remove_btn.pressed.connect(_on_remove_client.bind(client_id)) row.add_child(remove_btn) _client_grid.add_child(row) var manual_panel := VBoxContainer.new() manual_panel.add_theme_constant_override("separation", 4) manual_panel.visible = false var manual_hint := Label.new() manual_hint.text = "Run this manually:" manual_hint.add_theme_color_override("font_color", COLOR_MUTED) manual_panel.add_child(manual_hint) var manual_text := TextEdit.new() manual_text.editable = false manual_text.custom_minimum_size = Vector2(0, 60) manual_text.wrap_mode = TextEdit.LINE_WRAPPING_BOUNDARY manual_panel.add_child(manual_text) var copy_btn := Button.new() copy_btn.text = "Copy" copy_btn.pressed.connect(_on_copy_manual_command.bind(client_id)) manual_panel.add_child(copy_btn) _client_grid.add_child(manual_panel) _client_rows[client_id] = { "dot": dot, "status": Client.Status.NOT_CONFIGURED, "name_label": name_label, "configure_btn": configure_btn, "remove_btn": remove_btn, "manual_panel": manual_panel, "manual_text": manual_text, } # --- Status updates --- func _update_status() -> void: var connected: bool = _connection.is_connected ## During plugin self-update there's a brief window where this dock ## script is already the new version (Godot hot-reloads scripts on ## file change) but `_plugin` is still the old `EditorPlugin` instance ## (only `set_plugin_enabled(false, true)` re-instantiates that). When ## the new dock calls a method the old plugin doesn't have, `_process` ## errors every frame until `McpUpdateManager._reload_after_update` ## lands. Guard every `_plugin.()` call with `has_method` ## so that window stays silent. See #168. var server_status: Dictionary = ( _plugin.get_server_status() if _plugin != null and _plugin.has_method("get_server_status") else {} ) var state: int = int(server_status.get("state", ServerStateScript.UNINITIALIZED)) if ServerStateScript.blocks_client_health(state): connected = false ## One `match`/`elif` chain, one source of truth. Adding a new ## spawn outcome = one `ServerStateScript` constant + one arm here + ## one body string in `_crash_body_for_state`. var status_text: String var status_color: Color if _server_restart_in_progress: status_text = "Restarting server..." status_color = COLOR_AMBER elif connected: status_text = "Connected" status_color = Color.GREEN elif state == ServerStateScript.CRASHED: var exit_ms: int = server_status.get("exit_ms", 0) status_text = "Server exited after %.1fs" % (exit_ms / 1000.0) status_color = Color.RED elif state == ServerStateScript.PORT_EXCLUDED: status_text = "Port %d reserved by Windows" % ClientConfigurator.http_port() status_color = Color.RED elif state == ServerStateScript.INCOMPATIBLE: status_text = "Incompatible server on port %d" % ClientConfigurator.http_port() status_color = Color.RED elif state == ServerStateScript.FOREIGN_PORT: status_text = "Port %d held by another process" % ClientConfigurator.http_port() status_color = Color.RED elif state == ServerStateScript.NO_COMMAND: status_text = "No server command found" status_color = Color.RED elif Time.get_ticks_msec() < _startup_grace_until_msec: ## Inside startup grace — distinguish from real disconnect so ## first-run users don't assume it's broken while uvx downloads. status_text = "Starting server…" status_color = COLOR_AMBER else: status_text = "Disconnected" status_color = Color.RED _update_crash_panel(server_status) _refresh_server_version_label(server_status) var changed: bool = connected != _last_connected or status_text != _last_status_text if not changed: return _last_connected = connected _last_status_text = status_text _status_icon.color = status_color _status_label.text = status_text _update_dev_section_buttons() ## Render the diagnostic panel body for a given spawn state. The top ## status label already names the problem; this answers "what do I do?". ## Panel shows for any non-OK state; picker shows only when moving the HTTP ## port alone is a valid recovery. Incompatible godot-ai servers commonly ## hold both HTTP and WS ports, so their message points to Editor Settings ## instead of offering the HTTP-only quick picker. func _update_crash_panel(server_status: Dictionary) -> void: var state: int = int(server_status.get("state", ServerStateScript.UNINITIALIZED)) if not ServerStateScript.is_terminal_diagnosis(state): if _crash_panel.visible: _crash_panel.visible = false _last_server_status = {} return if server_status == _last_server_status: return _last_server_status = server_status.duplicate() _crash_panel.visible = true _crash_output.clear() _crash_output.add_text(_crash_body_for_state(state, server_status)) var show_recovery_restart := ( state == ServerStateScript.INCOMPATIBLE and bool(server_status.get("can_recover_incompatible", false)) ) if _crash_restart_btn != null: _crash_restart_btn.visible = show_recovery_restart _crash_restart_btn.disabled = _server_restart_in_progress _crash_restart_btn.text = "Restarting..." if _server_restart_in_progress else "Restart Server" if _crash_reload_btn != null: _crash_reload_btn.visible = ( not show_recovery_restart and state != ServerStateScript.INCOMPATIBLE ) var port_picker_visible := ( state == ServerStateScript.PORT_EXCLUDED or state == ServerStateScript.FOREIGN_PORT ) _port_picker_panel.visible = port_picker_visible if port_picker_visible: ## Seed the spinbox with a suggested non-reserved port each time the ## panel surfaces. Idempotent when the user already has a good ## candidate queued up. _port_picker_panel.seed_suggested_port() static func _crash_body_for_state(state: int, server_status: Dictionary = {}) -> String: ## Single sentence per state. The top status label already names the ## problem; don't repeat it here. This copy answers "what do I do?". var port := ClientConfigurator.http_port() match state: ServerStateScript.PORT_EXCLUDED: return "Windows (Hyper-V / WSL2 / Docker) reserved port %d. Pick a free port or try `net stop winnat; net start winnat` in an admin shell." % port ServerStateScript.INCOMPATIBLE: var message := str(server_status.get("message", "")) if bool(server_status.get("can_recover_incompatible", false)): var expected := str(server_status.get("expected_version", "")) if expected.is_empty(): expected = ClientConfigurator.get_plugin_version() if not message.is_empty(): return "%s Click Restart Server below to replace it with godot-ai v%s." % [message, expected] return "Port %d is occupied by an older godot-ai server. Click Restart Server below to replace it with godot-ai v%s." % [port, expected] if not message.is_empty(): return message return "Port %d is occupied by an incompatible server. Stop it or change both HTTP and WS ports." % port ServerStateScript.FOREIGN_PORT: return "Another process is already bound to port %d. Pick a free port or stop the other process." % port ServerStateScript.CRASHED: ## Both spawn attempts failed on the uvx tier — almost always ## means PyPI hasn't propagated this version yet (~10 min after ## publish). `_start_server` already tried `--refresh` once, so ## the next realistic move is to wait and reload. if ClientConfigurator.get_server_launch_mode() == "uvx": var version := ClientConfigurator.get_plugin_version() return "The server exited before the WebSocket handshake, even after a `uvx --refresh` retry. If this is a brand-new release, PyPI's index may still be propagating (~10 min). Wait a moment and click Reload Plugin to retry, or check Godot's output log for Python's traceback. Target: godot-ai==%s." % version return "The server exited before the WebSocket handshake. Check Godot's output log (bottom panel) for Python's traceback." ServerStateScript.NO_COMMAND: return "No godot-ai server found. Install `uv` via the Setup panel above, or run `pip install godot-ai`." _: return "" ## Build the mixed-state banner. Hidden until `_refresh_mixed_state_banner` ## confirms `*.update_backup` files exist in the addons tree. Mirrors the ## issue #354 fix shape: structured, agent-readable diagnostic that survives ## a normal editor restart so the user can act on it instead of re-running ## the update. func _build_mixed_state_banner() -> void: _mixed_state_banner = VBoxContainer.new() _mixed_state_banner.add_theme_constant_override("separation", 4) _mixed_state_banner.visible = false _mixed_state_label = Label.new() _mixed_state_label.autowrap_mode = TextServer.AUTOWRAP_WORD_SMART _mixed_state_label.size_flags_horizontal = Control.SIZE_EXPAND_FILL _mixed_state_label.add_theme_color_override("font_color", Color.RED) _mixed_state_banner.add_child(_mixed_state_label) _mixed_state_files = RichTextLabel.new() _mixed_state_files.bbcode_enabled = false _mixed_state_files.fit_content = true _mixed_state_files.autowrap_mode = TextServer.AUTOWRAP_OFF _mixed_state_files.selection_enabled = true _mixed_state_files.scroll_active = true _mixed_state_files.custom_minimum_size = Vector2(0, 90) _mixed_state_files.add_theme_color_override("default_color", COLOR_AMBER) _mixed_state_banner.add_child(_mixed_state_files) _mixed_state_rescan_btn = Button.new() _mixed_state_rescan_btn.text = "Re-scan" _mixed_state_rescan_btn.tooltip_text = ( "Scan addons/godot_ai/ for *.update_backup files again." + " Click after restoring the addon manually to dismiss this banner." ) _mixed_state_rescan_btn.pressed.connect(func(): _refresh_mixed_state_banner(true)) _mixed_state_banner.add_child(_mixed_state_rescan_btn) _mixed_state_banner.add_child(HSeparator.new()) add_child(_mixed_state_banner) func _refresh_mixed_state_banner(force: bool = false) -> void: ## Re-scan button passes `force=true` to bypass the scanner's TTL ## cache so a manual fix is reflected immediately. _apply_mixed_state_banner_diagnostic(UpdateMixedStateScript.diagnose( UpdateMixedStateScript.ADDON_DIR, force )) ## Render seam exposed for testing — the GDScript test suite drives this ## directly with synthetic diagnostics so dock banner contracts can be ## pinned without polluting the real `addons/godot_ai/` tree with backup ## files. Callers from production go through `_refresh_mixed_state_banner`. func _apply_mixed_state_banner_diagnostic(diag: Dictionary) -> void: if _mixed_state_banner == null: return if diag.is_empty(): _mixed_state_banner.visible = false return _mixed_state_banner.visible = true ## `Dictionary.get(...)` returns Variant; Label.text is typed String. ## Explicit cast keeps the type contract honest and dodges some Godot ## 4.x point-release quirks around Variant→typed-property assignment. _mixed_state_label.text = String(diag.get("message", "")) _mixed_state_files.clear() for path in diag.get("backup_files", []): _mixed_state_files.add_text(String(path)) _mixed_state_files.newline() if bool(diag.get("truncated", false)): _mixed_state_files.add_text( "… (list truncated at %d entries)" % UpdateMixedStateScript.MAX_BACKUP_RESULTS ) _mixed_state_files.newline() ## Signal handler for the extracted LogViewer — the panel owns its own ## display visibility, the dock owns dispatcher logging routing. func _on_log_logging_enabled_changed(enabled: bool) -> void: if _connection and _connection.dispatcher: _connection.dispatcher.mcp_logging = enabled ## Signal handler for the extracted PortPickerPanel — the panel range-validates ## the spinbox value before emitting, so we just write the EditorSetting and ## reload the plugin here. func _on_port_apply_requested(new_port: int) -> void: var es := EditorInterface.get_editor_settings() if es != null: es.set_setting(McpSettings.SETTING_HTTP_PORT, new_port) ## Every saved client config now points at the old port. Re-sweep so the ## drift banner appears in the same frame the user committed the change — ## the plugin reload below will run a second sweep on its own first paint, ## but we want the banner up immediately rather than after the reload ## handshake races to completion. See #166. _refresh_all_client_statuses() ## Reload after the setting is committed so `_start_server` reads the new ## port on the re-enabled plugin instance. _on_reload_plugin() func _refresh_server_label() -> void: if _server_label == null: return var ws_port := ClientConfigurator.ws_port() if _plugin != null and _plugin.has_method("get_resolved_ws_port"): ws_port = int(_plugin.get_resolved_ws_port()) _server_label.text = "WS: %d HTTP: %d" % [ws_port, ClientConfigurator.http_port()] # --- Telemetry setting persistence --- ## Returns true if GODOT_AI_DISABLE_TELEMETRY or DISABLE_TELEMETRY is set ## to a truthy value, false if either is set and non-truthy, null if neither ## env var is present at all. func _is_telemetry_disabled_via_env() -> Variant: if not (OS.has_environment("GODOT_AI_DISABLE_TELEMETRY") or OS.has_environment("DISABLE_TELEMETRY")): return null return McpSettings.env_truthy("GODOT_AI_DISABLE_TELEMETRY") or McpSettings.env_truthy("DISABLE_TELEMETRY") ## Reads the telemetry preference, applying env-var override when present. ## Initialises _telemetry_pending_enabled / _telemetry_saved_enabled and ## sets the checkbox state + locked tooltip. Call after _telemetry_toggle ## has been created. func _load_telemetry_setting() -> void: var es := EditorInterface.get_editor_settings() var env_disabled = _is_telemetry_disabled_via_env() var enabled: bool if env_disabled != null: ## Env var present: resolve and save to EditorSettings so future sessions without ## the env var honour the last-set value. enabled = not bool(env_disabled) if es != null: es.set_setting(McpSettings.SETTING_TELEMETRY_ENABLED, enabled) else: ## No env var: read (or create) the EditorSettings key. if es != null and es.has_setting(McpSettings.SETTING_TELEMETRY_ENABLED): enabled = bool(es.get_setting(McpSettings.SETTING_TELEMETRY_ENABLED)) else: enabled = true if es != null: es.set_setting(McpSettings.SETTING_TELEMETRY_ENABLED, true) _telemetry_pending_enabled = enabled _telemetry_saved_enabled = enabled if _telemetry_toggle == null: return _telemetry_toggle.set_pressed_no_signal(enabled) if env_disabled != null: _telemetry_toggle.disabled = true _telemetry_toggle.tooltip_text = ( "Telemetry is controlled by an environment variable " + "(GODOT_AI_DISABLE_TELEMETRY / DISABLE_TELEMETRY)." ) else: _telemetry_toggle.disabled = false _telemetry_toggle.tooltip_text = "" func _on_telemetry_toggled(pressed: bool) -> void: _telemetry_pending_enabled = pressed _refresh_tools_ui_state() # --- Dev mode persistence --- func _load_dev_mode() -> bool: # Default OFF for every install (including dev checkouts). Contributors # who want the extra diagnostic UI (Reload Plugin, MCP log # panel, Start/Stop Dev Server) can flip the toggle once — editor # settings persist across sessions. var es := EditorInterface.get_editor_settings() if es == null: return false if not es.has_setting(DEV_MODE_SETTING): es.set_setting(DEV_MODE_SETTING, false) return false return bool(es.get_setting(DEV_MODE_SETTING)) func _on_dev_mode_toggled(enabled: bool) -> void: var es := EditorInterface.get_editor_settings() if es != null: es.set_setting(DEV_MODE_SETTING, enabled) _apply_dev_mode_visibility() _refresh_setup_status() func _apply_dev_mode_visibility() -> void: var dev := _dev_mode_toggle.button_pressed _dev_section.visible = dev if _log_viewer != null: _log_viewer.visible = dev # Setup section: visible in dev mode, OR in user mode when uv is missing # (so users can install uv from the dock). var is_dev := ClientConfigurator.is_dev_checkout() var uv_missing := not is_dev and ClientConfigurator.check_uv_version().is_empty() _setup_section.visible = dev or uv_missing # --- Button handlers --- func _do_plugin_reload() -> void: EditorInterface.set_plugin_enabled("res://addons/godot_ai/plugin.cfg", false) EditorInterface.set_plugin_enabled("res://addons/godot_ai/plugin.cfg", true) func _on_reload_plugin() -> void: # Persist a pending plugin_reload telemetry event *before* the # disable kills the live WebSocket — the new plugin's _enter_tree # flushes it via `_telemetry.flush_pending_plugin_reload()`. Telemetry.record_pending_plugin_reload("dock_button") # Defer the toggle so any in-flight input event finishes propagating # before the dock (and its Window children) leave the tree. Calling # set_plugin_enabled synchronously from a button press frees the # viewport mid-dispatch. _do_plugin_reload.call_deferred() ## Setup-section "Server" row: always report the TRUE running server ## version (from the handshake_ack) rather than the plugin's expected ## version, and highlight the mismatch so self-update drift is visible ## at a glance instead of silently masked by a green label. ## ## Render states, keyed off live version metadata: ## - empty (pre-ack): show the expected version only as an unverified target ## - matches plugin: show it green, no Restart button ## - dev mismatch: show amber with an explicit dev marker ## - release mismatch: show actual vs expected; only surface Restart when the ## plugin has ownership proof for the process func _refresh_server_version_label(server_status: Dictionary = {}) -> void: if _setup_server_label == null: return var plugin_ver := ClientConfigurator.get_plugin_version() if server_status.is_empty(): ## Re-fetch only when called outside `_update_status`'s frame ## (e.g. from `_apply_new_port`, `_on_restart_*`). Inside the ## per-frame loop, the caller threads its cached snapshot through ## so we don't allocate a fresh Dictionary every frame. server_status = ( _plugin.get_server_status() if _plugin != null and _plugin.has_method("get_server_status") else {} ) var server_ver: String = _connection.server_version if _connection != null else "" if server_ver.is_empty(): server_ver = str(server_status.get("actual_version", "")) var expected_ver := str(server_status.get("expected_version", "")) if expected_ver.is_empty(): expected_ver = plugin_ver var state: int = int(server_status.get("state", ServerStateScript.UNINITIALIZED)) if _server_restart_in_progress and ( server_ver == expected_ver or ( ServerStateScript.is_terminal_diagnosis(state) and state != ServerStateScript.INCOMPATIBLE ) ): _server_restart_in_progress = false var text: String var color: Color var show_restart := false if _server_restart_in_progress: text = "restarting server..." color = COLOR_AMBER show_restart = true elif server_ver.is_empty(): text = "checking live version (expected godot-ai == %s)" % expected_ver color = COLOR_MUTED elif server_ver == expected_ver: text = "godot-ai == %s" % server_ver color = Color.GREEN else: text = "godot-ai == %s (expected %s)" % [server_ver, expected_ver] var is_incompatible: bool = state == ServerStateScript.INCOMPATIBLE color = Color.RED if is_incompatible else COLOR_AMBER var has_managed_proof: bool = ( _plugin != null and _plugin.has_method("can_restart_managed_server") and _plugin.can_restart_managed_server() ) var can_recover: bool = bool(server_status.get("can_recover_incompatible", false)) show_restart = ( (not is_incompatible and has_managed_proof) ## Recoverable incompatible servers get the primary action in ## the top error panel. Duplicating it in Setup made the UI ## look like it had multiple restart paths. or (is_incompatible and can_recover and _crash_restart_btn == null) ) if text == _last_rendered_server_text: _setup_server_label.add_theme_color_override("font_color", color) _update_restart_button(show_restart) return _last_rendered_server_text = text _setup_server_label.text = text _setup_server_label.add_theme_color_override("font_color", color) _update_restart_button(show_restart) func _update_restart_button(visible: bool) -> void: if _version_restart_btn != null: _version_restart_btn.visible = visible _version_restart_btn.disabled = _server_restart_in_progress _version_restart_btn.text = "Restarting..." if _server_restart_in_progress else "Restart" if _crash_restart_btn != null: _crash_restart_btn.disabled = _server_restart_in_progress _crash_restart_btn.text = "Restarting..." if _server_restart_in_progress else "Restart Server" func _on_restart_stale_server() -> void: if _plugin == null or _server_restart_in_progress: return _server_restart_in_progress = true _last_rendered_server_text = "" _refresh_server_version_label() if not is_inside_tree(): _dispatch_stale_server_restart() _server_restart_in_progress = false _last_rendered_server_text = "" _refresh_server_version_label() return call_deferred("_restart_stale_server_after_feedback") func _restart_stale_server_after_feedback() -> void: await get_tree().create_timer(0.15).timeout if not _dispatch_stale_server_restart(): _server_restart_in_progress = false _last_rendered_server_text = "" _refresh_server_version_label() func _dispatch_stale_server_restart() -> bool: if _plugin == null: return false var status: Dictionary = ( _plugin.get_server_status() if _plugin.has_method("get_server_status") else {} ) if int(status.get("state", ServerStateScript.UNINITIALIZED)) == ServerStateScript.INCOMPATIBLE: if _plugin.has_method("recover_incompatible_server"): return bool(_plugin.recover_incompatible_server()) elif _plugin.has_method("force_restart_server"): _plugin.force_restart_server() return true return false # --- Setup section --- func _refresh_setup_status() -> void: if _setup_container == null: return for child in _setup_container.get_children(): child.queue_free() _dev_primary_btn = null _dev_stop_btn = null var is_dev := ClientConfigurator.is_dev_checkout() if is_dev: _setup_container.add_child(_make_status_row("Mode", "Dev (venv)", Color.CYAN)) var btn_row := HBoxContainer.new() btn_row.add_theme_constant_override("separation", 4) btn_row.size_flags_horizontal = Control.SIZE_EXPAND_FILL _dev_primary_btn = Button.new() _dev_primary_btn.text = "Restart Dev Server" _dev_primary_btn.size_flags_horizontal = Control.SIZE_EXPAND_FILL _dev_primary_btn.pressed.connect(_on_dev_primary_pressed) btn_row.add_child(_dev_primary_btn) _dev_stop_btn = Button.new() _dev_stop_btn.text = "✕" _dev_stop_btn.tooltip_text = "Stop the dev server without spawning a replacement." _dev_stop_btn.pressed.connect(_on_dev_stop_pressed) btn_row.add_child(_dev_stop_btn) _setup_container.add_child(btn_row) _update_dev_section_buttons() return # User mode — check for uv var uv_version := ClientConfigurator.check_uv_version() if not uv_version.is_empty(): _setup_container.add_child(_make_status_row("uv", uv_version, Color.GREEN)) ## Build the Server row with a placeholder label we can update every ## frame. `_refresh_server_version_label` replaces the text + color ## once `McpConnection.server_version` lands via `handshake_ack`, and ## flips to amber + "(plugin X)" on drift. Pre-ack we show the ## plugin's expected version so the row isn't blank. var server_row := HBoxContainer.new() server_row.add_theme_constant_override("separation", 8) var key_label := Label.new() key_label.text = "Server" key_label.add_theme_color_override("font_color", COLOR_MUTED) key_label.custom_minimum_size = Vector2(60, 0) server_row.add_child(key_label) _setup_server_label = Label.new() _setup_server_label.size_flags_horizontal = Control.SIZE_EXPAND_FILL server_row.add_child(_setup_server_label) _version_restart_btn = Button.new() _version_restart_btn.text = "Restart" _version_restart_btn.tooltip_text = "Kill the server on port %d and respawn with the plugin's bundled version" % ClientConfigurator.http_port() _version_restart_btn.pressed.connect(_on_restart_stale_server) _version_restart_btn.visible = false server_row.add_child(_version_restart_btn) _setup_container.add_child(server_row) _last_rendered_server_text = "" _refresh_server_version_label() else: _setup_container.add_child(_make_status_row("uv", "not found", Color.RED)) var install_btn := Button.new() install_btn.text = "Install uv" install_btn.pressed.connect(_on_install_uv) _setup_container.add_child(install_btn) func _install_mode_text() -> String: if ClientConfigurator.is_dev_checkout(): return "Install: dev checkout — update via git pull" return "Install: v%s" % ClientConfigurator.get_plugin_version() func _install_mode_tooltip() -> String: if not ClientConfigurator.is_dev_checkout(): return "Plugin installed from a release ZIP, Asset Library, or source copy. Update button in this dock downloads the latest GitHub release." var target := _resolve_plugin_symlink_target() if target.is_empty(): return "Plugin source tree resolved via local .venv — press Reload Plugin after editing." return "Plugin source: %s\nPress Reload Plugin after editing." % target func _resolve_plugin_symlink_target() -> String: var addons_path := ProjectSettings.globalize_path("res://addons/godot_ai") var dir := DirAccess.open(addons_path.get_base_dir()) if dir == null or not dir.is_link(addons_path): return "" var target := dir.read_link(addons_path) if target.is_empty(): return "" if target.is_relative_path(): target = addons_path.get_base_dir().path_join(target).simplify_path() return target func _make_status_row(label_text: String, value_text: String, value_color: Color) -> HBoxContainer: var row := HBoxContainer.new() row.add_theme_constant_override("separation", 6) var label := Label.new() label.text = label_text label.add_theme_color_override("font_color", COLOR_MUTED) label.custom_minimum_size.x = 60 row.add_child(label) var value := Label.new() value.text = value_text value.add_theme_color_override("font_color", value_color) row.add_child(value) return row ## Pure helper for the primary "Restart Dev Server" button. Always enabled ## (clicking with nothing running just spawns fresh); tooltip adapts to ## whether a kill+respawn or fresh spawn is what'll happen. static func _dev_primary_btn_state(has_managed: bool, dev_running: bool) -> Dictionary: var port := ClientConfigurator.http_port() if has_managed or dev_running: return { "text": "Restart Dev Server", "tooltip": ( "Kill the server on port %d and start a fresh --reload dev server. " + "Use this to pick up Python source changes that don't bump the version." ) % port, } return { "text": "Start Dev Server", "tooltip": "Spawn a --reload dev server on port %d. Auto-restarts when you edit Python sources." % port, } ## Pure helper for the small "✕" stop button — only enabled when a dev ## server is actually running. Stops without respawning; intentionally ## never targets a managed server (that's the lifecycle's responsibility). static func _dev_stop_btn_state(dev_running: bool) -> Dictionary: if dev_running: return {"enabled": true, "tooltip": "Stop the dev server without spawning a replacement."} return {"enabled": false, "tooltip": "No --reload dev server to stop."} func _on_dev_primary_pressed() -> void: if _plugin == null or _server_restart_in_progress: return if not _plugin.has_method("force_restart_or_start_dev_server"): return if _plugin.has_method("record_dev_server_toggle"): _plugin.record_dev_server_toggle("start") _server_restart_in_progress = true _update_dev_section_buttons() if not is_inside_tree(): ## Test path — no scene tree means no timer; run synchronously ## so suite assertions see the dispatch without `await`. _plugin.force_restart_or_start_dev_server() _server_restart_in_progress = false return call_deferred("_perform_dev_restart_after_feedback") func _on_dev_stop_pressed() -> void: if _plugin == null: return if _plugin.has_method("stop_dev_server"): _plugin.stop_dev_server() if _plugin.has_method("record_dev_server_toggle"): _plugin.record_dev_server_toggle("stop") _update_dev_section_buttons.call_deferred() func _perform_dev_restart_after_feedback() -> void: ## Brief paint cycle so the user sees "Restarting..." before the ## blocking _wait_for_port_free freezes the editor for up to 5s. await get_tree().create_timer(0.15).timeout ## Re-check has_method post-await — a self-update mixed-state window ## could swap _plugin's script class while we were sleeping, leaving ## the old reference pointing at a class that no longer carries the ## new method. Same #168 guard pattern as _update_dev_section_buttons. if _plugin != null and _plugin.has_method("force_restart_or_start_dev_server"): _plugin.force_restart_or_start_dev_server() ## start_dev_server's spawn happens via a 0.5s SceneTree timer; give ## it time to land plus a buffer for the WS reconnect before clearing ## the busy state. The unconditional clear matches sibling restart ## buttons — overshoot is fine because subsequent _update_status calls ## refresh the button against live plugin state. await get_tree().create_timer(2.0).timeout _server_restart_in_progress = false _update_dev_section_buttons() ## Single-scan refresh of every dev-section button state. Both buttons ## key off the same `has_managed_server` / `is_dev_server_running` pair, ## and the latter scrapes lsof/ps — so doing the discovery once and ## applying to both avoids the duplicate subprocess fork on every ## connection-state transition. func _update_dev_section_buttons() -> void: if _plugin == null: return if not (_plugin.has_method("has_managed_server") and _plugin.has_method("is_dev_server_running")): return var has_managed: bool = _plugin.has_managed_server() var dev_running: bool = _plugin.is_dev_server_running() if _dev_primary_btn != null: if _server_restart_in_progress: _dev_primary_btn.disabled = true _dev_primary_btn.text = "Restarting..." _dev_primary_btn.tooltip_text = "Killing the current server and respawning..." else: var primary_state := _dev_primary_btn_state(has_managed, dev_running) _dev_primary_btn.disabled = false _dev_primary_btn.text = primary_state["text"] _dev_primary_btn.tooltip_text = primary_state["tooltip"] if _dev_stop_btn != null: var stop_state := _dev_stop_btn_state(dev_running) _dev_stop_btn.disabled = (not stop_state["enabled"]) or _server_restart_in_progress _dev_stop_btn.tooltip_text = stop_state["tooltip"] func _on_install_uv() -> void: match OS.get_name(): "Windows": OS.execute("powershell", ["-ExecutionPolicy", "ByPass", "-c", "irm https://astral.sh/uv/install.ps1 | iex"], [], false) _: OS.execute("bash", ["-c", "curl -LsSf https://astral.sh/uv/install.sh | sh"], [], false) ## Drop the cached uvx path AND the cached `uvx --version` so the ## next `_refresh_setup_status` finds and reads the freshly-installed ## binary instead of returning the pre-install "not found" result. ## Routing through the configurator here matters on Windows, where ## the CLI-finder cache key is `uvx.exe` — invalidating just `"uvx"` ## would leave the cache stale and the dock would keep showing ## "uv: not found" for the rest of the session. ClientConfigurator.invalidate_uvx_cli_cache() ClientConfigurator.invalidate_uv_version_cache() _refresh_setup_status.call_deferred() # --- Client section --- func _on_configure_client(client_id: String) -> void: if _server_blocks_client_health(): _apply_row_status(client_id, Client.Status.ERROR, _server_blocked_client_message()) _refresh_clients_summary() return _dispatch_client_action(client_id, "configure") func _on_remove_client(client_id: String) -> void: _dispatch_client_action(client_id, "remove") ## Spawn a worker thread for Configure / Remove so a hung CLI can't lock ## the editor (issue #239). The action verbs are: "configure" → calls ## `ClientConfigurator.configure`; "remove" → calls ## `ClientConfigurator.remove`. Both routes shell out to the per-client ## CLI via `McpCliExec.run`, which is wall-clock-bounded. ## ## Per-row in-flight rules: ## - One worker at a time per client (the row's slot). ## - Both buttons disabled while the slot is busy — prevents a ## double-click queueing a stale Configure on top of a still-running ## Remove. ## - The dot turns amber and the row label gets a "Configuring…" / ## "Removing…" suffix so the user can see the click was registered. func _dispatch_client_action(client_id: String, action: String) -> void: if _is_self_update_in_progress(): ## Same gate as the refresh worker — the install window overwrites ## plugin scripts on disk, and a worker mid-call into them would ## SIGABRT in `GDScriptFunction::call`. See `_update_manager`. return if _client_action_threads.has(client_id): return var row: Dictionary = _client_rows.get(client_id, {}) if row.is_empty(): return _set_row_action_in_flight(client_id, action) ## Snapshot `server_url` on main: `http_url()` reads ## `EditorInterface.get_editor_settings()`, which is main-thread-only. ## The status-refresh worker uses the same pattern — see ## `_perform_initial_client_status_refresh` and ## `_request_client_status_refresh`. var server_url := ClientConfigurator.http_url() var generation := int(_client_action_generations.get(client_id, 0)) + 1 _client_action_generations[client_id] = generation var thread := Thread.new() _client_action_threads[client_id] = thread var err := thread.start( Callable(self, "_run_client_action_worker").bind(client_id, action, server_url, generation) ) if err != OK: _client_action_threads.erase(client_id) _finalize_action_buttons(client_id) _apply_row_status(client_id, Client.Status.ERROR, "couldn't start worker thread") _refresh_clients_summary() func _run_client_action_worker(client_id: String, action: String, server_url: String, generation: int) -> void: var result: Dictionary if action == "remove": result = ClientConfigurator.remove(client_id, server_url) else: result = ClientConfigurator.configure(client_id, server_url) if _refresh_state != ClientRefreshStateScript.SHUTTING_DOWN: call_deferred("_apply_client_action_result", client_id, action, result, generation) func _apply_client_action_result(client_id: String, action: String, result: Dictionary, generation: int) -> void: if int(_client_action_generations.get(client_id, 0)) != generation: return if _refresh_state == ClientRefreshStateScript.SHUTTING_DOWN: return if _client_action_threads.has(client_id): var t: Thread = _client_action_threads[client_id] if t != null: t.wait_to_finish() _client_action_threads.erase(client_id) _finalize_action_buttons(client_id) if _server_blocks_client_health(): _apply_row_status(client_id, Client.Status.ERROR, _server_blocked_client_message()) _refresh_clients_summary() return var success_status := Client.Status.NOT_CONFIGURED if action == "remove" else Client.Status.CONFIGURED if result.get("status") == "ok": _apply_row_status(client_id, success_status) var row: Dictionary = _client_rows.get(client_id, {}) if not row.is_empty(): (row["manual_panel"] as VBoxContainer).visible = false else: _apply_row_status(client_id, Client.Status.ERROR, str(result.get("message", "failed"))) if action == "configure": _show_manual_command_for(client_id) _refresh_clients_summary() ## In-flight visual: rewrite the verb onto the button the user just ## clicked ("Configuring…" / "Removing…") so the feedback lands where ## their attention already is. Don't pollute the row label — that'd ## clobber any drift hint ("URL out of date") still relevant to the row. ## The dot turns amber so the row reads as "busy" at a glance, not as ## green (premature success) or red (premature failure). Both buttons ## go disabled so a double-click or second action can't queue stale ## work behind the in-flight worker. func _set_row_action_in_flight(client_id: String, action: String) -> void: var row: Dictionary = _client_rows.get(client_id, {}) if row.is_empty(): return var configure_btn: Button = row["configure_btn"] var remove_btn: Button = row["remove_btn"] configure_btn.disabled = true remove_btn.disabled = true if action == "remove": remove_btn.text = "Removing…" else: configure_btn.text = "Configuring…" (row["dot"] as ColorRect).color = COLOR_AMBER ## Re-enable both buttons and reset their text back to canonical labels. ## `_apply_row_status` sets `configure_btn.text` per the resulting ## Status (Configure / Reconfigure / Retry), so we only need to reset ## `remove_btn.text` here — its sibling visibility toggle already ## handles whether to show it at all. func _finalize_action_buttons(client_id: String) -> void: var row: Dictionary = _client_rows.get(client_id, {}) if row.is_empty(): return (row["configure_btn"] as Button).disabled = false var remove_btn: Button = row["remove_btn"] remove_btn.disabled = false remove_btn.text = "Remove" func _on_refresh_clients_pressed() -> void: _request_client_status_refresh(true) func _on_configure_all_clients() -> void: if _server_blocks_client_health(): for client_id in _client_rows: _apply_row_status(String(client_id), Client.Status.ERROR, _server_blocked_client_message()) _refresh_clients_summary() return if ClientRefreshStateScript.has_worker_alive(_refresh_state): return for client_id in _client_rows: var status: Client.Status = _client_rows[client_id].get("status", Client.Status.NOT_CONFIGURED) if status == Client.Status.CONFIGURED: continue _on_configure_client(String(client_id)) _refresh_clients_summary() func _on_open_clients_window() -> void: if _clients_window == null: return ## Re-sweep before the user has time to act on stale dot colors. The request ## is async/stale-while-refreshing so the popup paints immediately with ## last-known state; the fresh colors land when the background worker returns. ## This is an explicit user action, so it bypasses the focus-in cooldown. _request_client_status_refresh(true) ## Also re-sync the Tools tab from the persisted setting — another ## editor instance (or a hand-edit of editor_settings-4.tres) may have ## changed the excluded list while the window was closed. _reset_tools_pending_from_setting() _refresh_tools_ui_state() # popup_centered() with a minsize forces the window to that size and # centers on the parent viewport. Setting .size on a hidden Window # doesn't always take effect, so we force it at popup time here. _clients_window.popup_centered(Vector2i(640, 600)) func _settings_are_dirty() -> bool: return _tools_pending_excluded != _tools_saved_excluded or _telemetry_pending_enabled != _telemetry_saved_enabled func _on_clients_window_close_requested() -> void: if _clients_window == null: return ## If the user has unapplied settings, a close would silently throw the ## pending state away. Prompt before discarding current options and if ## they confirm, reset pending → saved so the window shows the persisted ## state the next time they open it. if _settings_are_dirty(): _show_tools_close_confirm() return _clients_window.hide() # --- Tools tab (domain exclusion) --- func _build_tools_tab(tabs: TabContainer) -> void: ## Tab 2 — domain-exclusion checkboxes. Rendered once, on dock construction. ## `_reset_tools_pending_from_setting()` re-syncs checkbox state from the ## saved setting each time the window opens. var tools_tab := VBoxContainer.new() tools_tab.add_theme_constant_override("separation", 8) var tools_margin := _build_margin_container() tools_margin.name = "Settings" tools_margin.add_child(tools_tab) tabs.add_child(tools_margin) var intro := Label.new() intro.text = ( "Some MCP clients cap tools per connection (Antigravity: 100). " + "Uncheck a domain to drop its non-core tools from this server. " + "Core tools stay on. Changes require a server restart." ) intro.autowrap_mode = TextServer.AUTOWRAP_WORD_SMART intro.add_theme_color_override("font_color", COLOR_MUTED) intro.size_flags_horizontal = Control.SIZE_EXPAND_FILL tools_tab.add_child(intro) var count_row := HBoxContainer.new() count_row.add_theme_constant_override("separation", 8) var count_header := Label.new() count_header.text = "Tools Enabled:" count_header.add_theme_color_override("font_color", COLOR_MUTED) count_row.add_child(count_header) _tools_count_label = Label.new() _tools_count_label.add_theme_font_size_override("font_size", 15) count_row.add_child(_tools_count_label) _tools_dirty_warning = Label.new() _tools_dirty_warning.add_theme_color_override("font_color", COLOR_AMBER) _tools_dirty_warning.size_flags_horizontal = Control.SIZE_EXPAND_FILL _tools_dirty_warning.horizontal_alignment = HORIZONTAL_ALIGNMENT_RIGHT _tools_dirty_warning.visible = false _tools_dirty_warning.text = "Unapplied changes" count_row.add_child(_tools_dirty_warning) tools_tab.add_child(count_row) tools_tab.add_child(HSeparator.new()) var scroll := ScrollContainer.new() scroll.size_flags_horizontal = Control.SIZE_EXPAND_FILL scroll.size_flags_vertical = Control.SIZE_EXPAND_FILL scroll.horizontal_scroll_mode = ScrollContainer.SCROLL_MODE_DISABLED tools_tab.add_child(scroll) var grid := VBoxContainer.new() grid.add_theme_constant_override("separation", 4) grid.size_flags_horizontal = Control.SIZE_EXPAND_FILL scroll.add_child(grid) ## Core pseudo-row — disabled checkbox, always checked. Shows the 5 ## always-loaded tools as a single line item so the user can see where ## their baseline tool budget goes without listing individual core names ## inline (tooltip has them). var core_row := HBoxContainer.new() core_row.add_theme_constant_override("separation", 8) var core_chk := CheckBox.new() core_chk.button_pressed = true core_chk.disabled = true core_chk.focus_mode = Control.FOCUS_NONE core_row.add_child(core_chk) var core_label := Label.new() core_label.text = "Core (always on)" core_label.size_flags_horizontal = Control.SIZE_EXPAND_FILL core_row.add_child(core_label) var core_count := Label.new() core_count.text = "%d tools" % ToolCatalog.CORE_TOOLS.size() core_count.add_theme_color_override("font_color", COLOR_MUTED) core_row.add_child(core_count) core_row.tooltip_text = ", ".join(ToolCatalog.CORE_TOOLS) grid.add_child(core_row) grid.add_child(HSeparator.new()) _tools_domain_checkboxes.clear() for entry in ToolCatalog.DOMAINS: _build_tools_domain_row(grid, entry) tools_tab.add_child(HSeparator.new()) var telemetry_row := HBoxContainer.new() telemetry_row.add_theme_constant_override("separation", 8) var telemetry_label := Label.new() telemetry_label.text = "Telemetry" telemetry_label.size_flags_horizontal = Control.SIZE_EXPAND_FILL telemetry_row.add_child(telemetry_label) _telemetry_toggle = CheckButton.new() _telemetry_toggle.toggled.connect(_on_telemetry_toggled) telemetry_row.add_child(_telemetry_toggle) tools_tab.add_child(telemetry_row) tools_tab.add_child(HSeparator.new()) var footer := HBoxContainer.new() footer.add_theme_constant_override("separation", 8) _tools_apply_btn = Button.new() _tools_apply_btn.text = "Apply && Restart Server" _tools_apply_btn.tooltip_text = "Save the excluded list to Editor Settings and reload the plugin so the server respawns with --exclude-domains." _tools_apply_btn.size_flags_horizontal = Control.SIZE_EXPAND_FILL _tools_apply_btn.pressed.connect(_on_tools_apply) footer.add_child(_tools_apply_btn) _tools_reset_btn = Button.new() _tools_reset_btn.text = "Reset to defaults" _tools_reset_btn.tooltip_text = "Re-enable every domain (no --exclude-domains flag). Still needs Apply." _tools_reset_btn.pressed.connect(_on_tools_reset) footer.add_child(_tools_reset_btn) tools_tab.add_child(footer) _tools_close_confirm = ConfirmationDialog.new() _tools_close_confirm.title = "Discard unapplied changes?" _tools_close_confirm.dialog_text = ( "You've checked/unchecked domains but haven't clicked Apply.\n" + "Close the window and discard those changes?" ) _tools_close_confirm.ok_button_text = "Discard" _tools_close_confirm.confirmed.connect(_on_tools_discard_confirmed) add_child(_tools_close_confirm) _reset_tools_pending_from_setting() _refresh_tools_ui_state() func _build_tools_domain_row(parent: VBoxContainer, entry: Dictionary) -> void: var row := HBoxContainer.new() row.add_theme_constant_override("separation", 8) var chk := CheckBox.new() chk.button_pressed = true # default; `_reset_tools_pending_from_setting` corrects chk.toggled.connect(_on_tools_domain_toggled.bind(String(entry["id"]))) row.add_child(chk) var name_label := Label.new() name_label.text = String(entry["label"]) name_label.size_flags_horizontal = Control.SIZE_EXPAND_FILL row.add_child(name_label) var count_label := Label.new() count_label.text = "%d tools" % int(entry["count"]) count_label.add_theme_color_override("font_color", COLOR_MUTED) row.add_child(count_label) ## Hover tooltip = flat list of tool names in this domain. Lets the ## user decide without leaving the dock (e.g. "I just want to drop ## `animation_preset_*` — do I lose anything else?"). var tools_list: Array = entry.get("tools", []) row.tooltip_text = ", ".join(tools_list) name_label.tooltip_text = row.tooltip_text count_label.tooltip_text = row.tooltip_text parent.add_child(row) _tools_domain_checkboxes[String(entry["id"])] = chk func _reset_tools_pending_from_setting() -> void: ## Read the saved setting → pending/saved arrays, then sync checkbox state. ## Unknown domain names in the setting (e.g. from an older plugin ## version) are silently dropped — matches the Python side's ## warn-and-continue behavior when it sees an unknown name. var saved_raw := ClientConfigurator.excluded_domains() var saved := PackedStringArray() if not saved_raw.is_empty(): for part in saved_raw.split(","): var t := part.strip_edges() if t.is_empty(): continue if _tools_domain_checkboxes.has(t) and saved.find(t) == -1: saved.append(t) saved.sort() _tools_saved_excluded = saved _tools_pending_excluded = saved.duplicate() for id in _tools_domain_checkboxes: var chk: CheckBox = _tools_domain_checkboxes[id] ## `set_pressed_no_signal` — mutating programmatically should not ## fire the toggled handler, which would mutate pending back. chk.set_pressed_no_signal(_tools_pending_excluded.find(id) == -1) ## Also reset telemetry pending state from the persisted setting. if _telemetry_toggle != null: _load_telemetry_setting() func _on_tools_domain_toggled(pressed: bool, domain_id: String) -> void: var idx := _tools_pending_excluded.find(domain_id) if pressed and idx != -1: _tools_pending_excluded.remove_at(idx) elif not pressed and idx == -1: _tools_pending_excluded.append(domain_id) _tools_pending_excluded.sort() _refresh_tools_ui_state() func _refresh_tools_ui_state() -> void: if _tools_count_label == null: return var enabled := ToolCatalog.enabled_tool_count(_tools_pending_excluded) var total := ToolCatalog.total_tool_count() _tools_count_label.text = "%d / %d" % [enabled, total] var dirty := _settings_are_dirty() _tools_dirty_warning.visible = dirty _tools_apply_btn.disabled = not dirty ## Color the count when the user is over Antigravity's cap — a soft ## signal that their selection still won't fit. 100 is the Antigravity ## limit; other clients may cap higher, so this is advisory only. if enabled > 100: _tools_count_label.add_theme_color_override("font_color", COLOR_AMBER) else: _tools_count_label.remove_theme_color_override("font_color") func _on_tools_apply() -> void: var canonical_excluded := ToolCatalog.canonical(_tools_pending_excluded) var es := EditorInterface.get_editor_settings() if es != null: es.set_setting(McpSettings.SETTING_EXCLUDED_DOMAINS, canonical_excluded) es.set_setting(McpSettings.SETTING_TELEMETRY_ENABLED, _telemetry_pending_enabled) _tools_saved_excluded = _tools_pending_excluded.duplicate() _telemetry_saved_enabled = _telemetry_pending_enabled _refresh_tools_ui_state() ## Plugin reload respawns the server with the new `--exclude-domains` flag ## (see `plugin.gd::_build_server_flags`) and telemetry option. Mirrors the ## port-change Apply flow. _on_reload_plugin() func _on_tools_reset() -> void: ## Resets only the tool-domain exclusions, not the telemetry toggle. ## Telemetry is a privacy preference users typically want to set once ## and have honored — flipping it back to "on" via a generic Reset ## button would be a surprising privacy regression. The button label ## is scoped to tools accordingly. _tools_pending_excluded = PackedStringArray() for id in _tools_domain_checkboxes: var chk: CheckBox = _tools_domain_checkboxes[id] chk.set_pressed_no_signal(true) _refresh_tools_ui_state() func _show_tools_close_confirm() -> void: if _tools_close_confirm == null: return _tools_close_confirm.popup_centered() func _on_tools_discard_confirmed() -> void: _reset_tools_pending_from_setting() _refresh_tools_ui_state() if _clients_window != null: _clients_window.hide() func _refresh_clients_summary() -> void: # Count from cached row status values — `_apply_row_status` is the single # source of truth, and reading cached status avoids re-running # filesystem/CLI-hitting checks on every refresh. The same cache re-derives # the drift banner so per-row mutations (Configure/Reconfigure/Remove on a # row in the Clients & Tools window) keep the dock-level banner in sync # without an extra sweep. See #166 and #226. if _clients_summary_label == null: return var configured := 0 var mismatched_ids: Array[String] = [] for client_id in _client_rows: var status: Client.Status = _client_rows[client_id].get("status", Client.Status.NOT_CONFIGURED) if status == Client.Status.CONFIGURED: configured += 1 elif status == Client.Status.CONFIGURED_MISMATCH: mismatched_ids.append(client_id) var text := "%d / %d configured" % [configured, _client_rows.size()] if mismatched_ids.size() > 0: text += " (%d stale)" % mismatched_ids.size() if ClientRefreshStateScript.should_show_checking_badge(_refresh_state): text += ( " (checking...)" if _refresh_state != ClientRefreshStateScript.RUNNING_TIMED_OUT else " (client probe still running)" ) _clients_summary_label.text = text if _client_configure_all_btn != null: _client_configure_all_btn.disabled = ClientRefreshStateScript.has_worker_alive(_refresh_state) _refresh_drift_banner(mismatched_ids) func _show_manual_command_for(client_id: String) -> void: var row: Dictionary = _client_rows.get(client_id, {}) if row.is_empty(): return var cmd := ClientConfigurator.manual_command(client_id) if cmd.is_empty(): row["manual_panel"].visible = false return row["manual_text"].text = cmd row["manual_panel"].visible = true func _on_copy_manual_command(client_id: String) -> void: var row: Dictionary = _client_rows.get(client_id, {}) if row.is_empty(): return DisplayServer.clipboard_set(row["manual_text"].text) func _refresh_all_client_statuses() -> void: ## Compatibility wrapper for older explicit call sites. Treat this as a manual ## refresh: it bypasses focus-in cooldown but still runs probes off the editor ## main thread. if _server_blocks_client_health(): for client_id in _client_rows: _apply_row_status(String(client_id), Client.Status.ERROR, _server_blocked_client_message()) _refresh_clients_summary() return _request_client_status_refresh(true) func _is_client_status_refresh_in_cooldown() -> bool: if _last_client_status_refresh_completed_msec <= 0: return false return Time.get_ticks_msec() - _last_client_status_refresh_completed_msec < CLIENT_STATUS_REFRESH_COOLDOWN_MSEC func _has_client_status_refresh_timed_out() -> bool: if not ClientRefreshStateScript.has_worker_alive(_refresh_state): return false if _client_status_refresh_started_msec <= 0: return false return Time.get_ticks_msec() - _client_status_refresh_started_msec >= CLIENT_STATUS_REFRESH_TIMEOUT_MSEC func _check_client_status_refresh_timeout() -> void: if not _has_client_status_refresh_timed_out(): return if _refresh_state == ClientRefreshStateScript.RUNNING_TIMED_OUT: return _refresh_state = ClientRefreshStateScript.RUNNING_TIMED_OUT _refresh_clients_summary() func _abandon_client_status_refresh_thread() -> void: ## GDScript cannot interrupt a blocking `OS.execute(..., true)` call in a ## worker. If a CLI probe hangs, orphan this run, bump the generation so any ## late result becomes a no-op, and let a forced/manual refresh start a fresh ## probe slot. Completed orphan threads are pruned from `_process`. _client_status_refresh_generation += 1 if _client_status_refresh_thread != null: _orphaned_client_status_refresh_threads.append(_client_status_refresh_thread) _client_status_refresh_thread = null if _refresh_state != ClientRefreshStateScript.SHUTTING_DOWN: _refresh_state = ClientRefreshStateScript.IDLE ## Reset the full pending-request triplet, not just the ## focus-in / cooldown half. A timed-out worker has already ## warmed bytecode, so any stale `_pending_initial` from an ## earlier deferred-during-busy startup is no longer load-bearing ## — leaving it set would cause `_retry_deferred_*` to dispatch ## `_perform_initial_*` a second time after this abandon ## (which would then no-op because no fresh worker is needed ## but still re-warm bytecode and walk the row set redundantly). _client_status_refresh_pending = false _client_status_refresh_pending_force = false _client_status_refresh_pending_initial = false _client_status_refresh_started_msec = 0 _refresh_clients_summary() func _prune_orphaned_client_status_refresh_threads() -> void: for i in range(_orphaned_client_status_refresh_threads.size() - 1, -1, -1): var thread := _orphaned_client_status_refresh_threads[i] if thread == null: _orphaned_client_status_refresh_threads.remove_at(i) elif not thread.is_alive(): thread.wait_to_finish() _orphaned_client_status_refresh_threads.remove_at(i) func _perform_initial_client_status_refresh() -> void: ## Pre-warm strategy bytecode on main, then hand every client probe ## (JSON / TOML / CLI alike) to the worker. ## ## Godot's GDScript hot-reload of overwritten plugin files is lazy: the ## bytecode swap happens on first dereference, not at `set_plugin_enabled` ## time. A worker thread spawned from a fresh `_build_ui` walks into ## `_json_strategy.*` / `_cli_strategy.*` / `client_configurator.*` while ## bytecode pages are mid-swap → SIGABRT. Dereferencing those scripts on ## main first forces the swap to complete here; the worker then finds ## stable bytecode. Filesystem signals don't bracket the swap window ## (they fire before bytecode replacement), and FOCUS_IN doesn't fire on ## in-place plugin reload because the editor stays focused — so neither ## works as a gate. See #233 / #235. ## ## Phase 1 (sync, on main): a single explicit `_warm_strategy_bytecode` ## call invokes a pure-memory helper on each strategy script — ## `_json_strategy.gd`, `_toml_strategy.gd`, `_cli_strategy.gd`, plus ## `client_configurator.gd` via `client_ids()` / `get_by_id`. No disk, ## no `OS.execute`, no JSON parse on main. `client_status_probe_snapshot` ## per client adds the `installed` flag and (for CLI clients) a cached ## CLI path to each probe. ## ## Phase 2 (worker): every probe — JSON, TOML, CLI — runs through the ## same `_run_client_status_refresh_worker` pipeline. Disk reads + JSON ## parses for the ~17 non-CLI clients now happen off the main thread, ## so the dock paints immediately on cold open instead of stalling ## behind ~16 sync `FileAccess.open` + `JSON.parse_string` calls. ## ## No-op outside the tree — GDScript tests instantiate via `new()`. if not is_inside_tree(): return if _client_rows.is_empty(): return if _refresh_state == ClientRefreshStateScript.SHUTTING_DOWN: return if _is_self_update_in_progress(): return if _is_editor_filesystem_busy(): _defer_initial_client_status_refresh_until_filesystem_ready() return if ClientRefreshStateScript.has_worker_alive(_refresh_state): return if _server_blocks_client_health(): for client_id in _client_rows: _apply_row_status(String(client_id), Client.Status.ERROR, _server_blocked_client_message()) _refresh_clients_summary() return _warm_strategy_bytecode() var generation := _begin_client_status_refresh_run() var server_url := ClientConfigurator.http_url() var all_probes: Array[Dictionary] = [] for client_id in _client_rows: var probe := ClientConfigurator.client_status_probe_snapshot(String(client_id)) if probe.is_empty(): continue all_probes.append(probe) _refresh_clients_summary() if all_probes.is_empty(): _finalize_completed_refresh() return _client_status_refresh_thread = Thread.new() var err := _client_status_refresh_thread.start( Callable(self, "_run_client_status_refresh_worker").bind( all_probes, server_url, generation ) ) if err != OK: _refresh_state = ClientRefreshStateScript.IDLE _client_status_refresh_thread = null _refresh_clients_summary() ## Force GDScript's lazy bytecode swap to complete for every script the ## worker thread will reach into. Each call is pure-memory — no disk, no ## network, no `OS.execute` — so it only costs the bytecode dereference ## itself. See `_perform_initial_client_status_refresh` for context and ## #233 / #235 for the SIGABRT this exists to prevent. func _warm_strategy_bytecode() -> void: var ids := ClientConfigurator.client_ids() if ids.is_empty(): return var any_client := ClientRegistry.get_by_id(String(ids[0])) if any_client != null: JsonStrategy.verify_entry(any_client, {}, "") TomlStrategy.format_body(PackedStringArray(), "") CliStrategy.format_args(PackedStringArray(), "", "") func _begin_client_status_refresh_run() -> int: ## Marks a refresh as starting and returns the new generation token. ## Generation is bumped here (not at completion) so that a worker callback ## arriving after `_abandon_client_status_refresh_thread` or `_exit_tree` ## fires can be detected as stale via generation mismatch. _refresh_state = ClientRefreshStateScript.RUNNING _client_status_refresh_pending = false _client_status_refresh_pending_force = false _client_status_refresh_started_msec = Time.get_ticks_msec() _client_status_refresh_generation += 1 _refresh_clients_summary() return _client_status_refresh_generation func _finalize_completed_refresh() -> void: ## Stamps cooldown and clears in-flight state. Called at the end of every ## refresh that successfully applied results — the worker callback path ## and the no-CLI fast path in `_perform_initial_client_status_refresh`. _last_client_status_refresh_completed_msec = Time.get_ticks_msec() if _refresh_state != ClientRefreshStateScript.SHUTTING_DOWN: _refresh_state = ClientRefreshStateScript.IDLE _refresh_clients_summary() func _request_client_status_refresh(force: bool = false) -> bool: ## Stale-while-refreshing: do not clear dots, summary, or the drift banner ## when a refresh is requested. The existing UI remains visible until the ## background worker's result is applied on the main thread. if _server_blocks_client_health(): for client_id in _client_rows: _apply_row_status(String(client_id), Client.Status.ERROR, _server_blocked_client_message()) _refresh_clients_summary() return false if _is_self_update_in_progress(): ## Self-update is overwriting plugin scripts on disk; spawning a worker ## now would crash it inside `GDScriptFunction::call` once the bytecode ## swap reaches a script the worker is mid-call into. Focus-in / ## manual button / cooldown timer all funnel through here, so one ## gate covers every spawn path during the install window. The flag ## lives on `_update_manager` and dies with the dock instance during ## `set_plugin_enabled(false)`. return false if ClientRefreshStateScript.has_worker_alive(_refresh_state): if force and _has_client_status_refresh_timed_out(): _abandon_client_status_refresh_thread() else: _client_status_refresh_pending = true _client_status_refresh_pending_force = _client_status_refresh_pending_force or force _refresh_clients_summary() return false if _refresh_state == ClientRefreshStateScript.SHUTTING_DOWN: return false if not force and _is_client_status_refresh_in_cooldown(): return false if _client_rows.is_empty(): return false if _is_editor_filesystem_busy(): if force: _defer_client_status_refresh_until_filesystem_ready(force) return false ## Manual refresh (any `force=true` path: button click, popup open, ## external API caller) implies "may have installed a CLI since the ## last sweep" — flush CliFinder so freshly-installed binaries get ## re-detected. Focus-in (`force=false`) stays cached so the cheap ## case stays cheap. Per-CLI invalidation ## (`invalidate_uvx_cli_cache`) still pairs with specific events ## like `_on_install_uv` where the binary name is known. if force: ClientConfigurator.invalidate_cli_cache() ## Force the bytecode swap on the same scripts the worker will reach ## into — same #233/#235 guard `_perform_initial_*` already had. ## Without this, a manual refresh dispatched before the initial sweep ## has run (e.g. user clicks Refresh during the deferred-initial ## window after `_defer_client_status_refresh_until_filesystem_ready` ## cleared `_pending_initial`) walks into mid-swap bytecode and ## SIGABRTs. _warm_strategy_bytecode() var client_probes: Array[Dictionary] = [] for client_id in _client_rows: client_probes.append(ClientConfigurator.client_status_probe_snapshot(String(client_id))) var server_url := ClientConfigurator.http_url() var generation := _begin_client_status_refresh_run() _client_status_refresh_thread = Thread.new() var err := _client_status_refresh_thread.start( Callable(self, "_run_client_status_refresh_worker").bind(client_probes, server_url, generation) ) if err != OK: _refresh_state = ClientRefreshStateScript.IDLE _client_status_refresh_thread = null _refresh_clients_summary() return false return true func _is_editor_filesystem_busy() -> bool: var fs := EditorInterface.get_resource_filesystem() return fs != null and fs.is_scanning() func _defer_initial_client_status_refresh_until_filesystem_ready() -> void: _refresh_state = ClientRefreshStateScript.DEFERRED_FOR_FILESYSTEM _client_status_refresh_pending_initial = true func _defer_client_status_refresh_until_filesystem_ready(force: bool) -> void: ## Godot can still be reparsing/reloading plugin scripts while the editor ## filesystem is busy. Do not spawn a worker into that window: the worker ## can call plugin GDScript while the main thread is reloading it, which ## crashes in `GDScriptFunction::call`. ## ## A manual refresh request is more recent intent than any earlier ## deferred-initial sweep, so we clear `_pending_initial` here. ## `_request_client_status_refresh` warms strategy bytecode itself ## now (see #233/#235), so the safety net the initial path provided ## still applies to the replayed manual refresh. _refresh_state = ClientRefreshStateScript.DEFERRED_FOR_FILESYSTEM _client_status_refresh_pending_force = _client_status_refresh_pending_force or force _client_status_refresh_pending_initial = false func _retry_deferred_client_status_refresh() -> void: if _refresh_state != ClientRefreshStateScript.DEFERRED_FOR_FILESYSTEM: return if _is_self_update_in_progress(): return if _is_editor_filesystem_busy(): return var initial := _client_status_refresh_pending_initial var force := _client_status_refresh_pending_force _refresh_state = ClientRefreshStateScript.IDLE _client_status_refresh_pending_force = false _client_status_refresh_pending_initial = false if initial: _perform_initial_client_status_refresh() else: _request_client_status_refresh(force) func _run_client_status_refresh_worker(client_probes: Array[Dictionary], server_url: String, generation: int) -> void: var results: Dictionary = {} for probe in client_probes: var client_id := String(probe.get("id", "")) if client_id.is_empty(): continue var details := ClientConfigurator.check_status_details_for_url_with_cli_path( client_id, server_url, String(probe.get("cli_path", "")) ) var installed := bool(probe.get("installed", false)) results[client_id] = { "status": details.get("status", Client.Status.NOT_CONFIGURED), "installed": installed, "error_msg": details.get("error_msg", ""), } if _refresh_state != ClientRefreshStateScript.SHUTTING_DOWN: call_deferred("_apply_client_status_refresh_results", results, generation) func _apply_client_status_refresh_results(results: Dictionary, generation: int) -> void: if generation != _client_status_refresh_generation or _refresh_state == ClientRefreshStateScript.SHUTTING_DOWN: return if _client_status_refresh_thread != null: _client_status_refresh_thread.wait_to_finish() _client_status_refresh_thread = null if _server_blocks_client_health(): for client_id in _client_rows: _apply_row_status(String(client_id), Client.Status.ERROR, _server_blocked_client_message()) _finalize_completed_refresh() return for client_id in results: ## Skip rows whose Configure / Remove worker is still running so the ## status refresh doesn't overwrite the "Configuring…" / "Removing…" ## badge with a stale dot color. The action's own completion handler ## will repaint the row when it lands. if _client_action_threads.has(String(client_id)): continue var result: Dictionary = results[client_id] _apply_row_status( String(client_id), result.get("status", Client.Status.NOT_CONFIGURED), str(result.get("error_msg", "")), result.get("installed", false) ) _finalize_completed_refresh() if _client_status_refresh_pending: var pending_force := _client_status_refresh_pending_force _client_status_refresh_pending = false _client_status_refresh_pending_force = false _request_client_status_refresh(pending_force) func _server_blocks_client_health() -> bool: if _plugin == null or not _plugin.has_method("get_server_status"): return false var status: Dictionary = _plugin.get_server_status() return ServerStateScript.blocks_client_health( int(status.get("state", ServerStateScript.UNINITIALIZED)) ) func _server_blocked_client_message() -> String: if _plugin == null or not _plugin.has_method("get_server_status"): return "server incompatible" var status: Dictionary = _plugin.get_server_status() var message := str(status.get("message", "")) return message if not message.is_empty() else "server incompatible" func _refresh_drift_banner(mismatched_ids: Array[String]) -> void: if _drift_banner == null: return ## Sort so set-equality is order-independent — `_client_rows` iteration ## order is dict-insertion order, but a future change to the iteration ## site shouldn't make us repaint identical content. mismatched_ids = mismatched_ids.duplicate() mismatched_ids.sort() if mismatched_ids == _last_mismatched_ids: return _last_mismatched_ids = mismatched_ids if mismatched_ids.is_empty(): _drift_banner.visible = false return var names: Array[String] = [] for id in mismatched_ids: names.append(ClientConfigurator.client_display_name(id)) ## Active server URL is already shown on the WS:/HTTP: line above the ## Clients section, so it doesn't need to repeat here. Lead with the ## client names — that's the only thing the user can act on. var verb := "needs" if mismatched_ids.size() == 1 else "need" _drift_label.text = "%s %s to be reconfigured." % [", ".join(names), verb] _drift_banner.visible = true func _on_reconfigure_mismatched() -> void: ## Re-Configure every client whose URL is currently stale. Iterates the ## cached list from the most recent sweep instead of re-running ## `check_status` per row (saves ~18 filesystem reads per click). The ## trailing `_refresh_all_client_statuses()` re-sweeps anyway, so any ## entries the user manually fixed between sweep and click get re-counted ## as CONFIGURED there. for client_id in _last_mismatched_ids: if _client_rows.has(client_id): _on_configure_client(client_id) _refresh_all_client_statuses() func _apply_row_status( client_id: String, status: Client.Status, error_msg: String = "", installed_override: Variant = null, ) -> void: var row: Dictionary = _client_rows.get(client_id, {}) if row.is_empty(): return row["status"] = status var dot: ColorRect = row["dot"] var configure_btn: Button = row["configure_btn"] var remove_btn: Button = row["remove_btn"] var name_label: Label = row["name_label"] var base_name := ClientConfigurator.client_display_name(client_id) match status: Client.Status.CONFIGURED: dot.color = Color.GREEN configure_btn.text = "Reconfigure" remove_btn.visible = true name_label.text = base_name Client.Status.NOT_CONFIGURED: dot.color = COLOR_MUTED configure_btn.text = "Configure" remove_btn.visible = false var installed: bool = installed_override if installed_override != null else ClientConfigurator.is_installed(client_id) name_label.text = base_name if installed else "%s (not detected)" % base_name Client.Status.CONFIGURED_MISMATCH: ## Amber matches the dock-level drift banner so a glance at the ## row + the banner read as the same condition. dot.color = COLOR_AMBER configure_btn.text = "Reconfigure" remove_btn.visible = true name_label.text = "%s (URL out of date)" % base_name _: dot.color = Color.RED configure_btn.text = "Retry" remove_btn.visible = false name_label.text = "%s — %s" % [base_name, error_msg] if not error_msg.is_empty() else base_name # --- Update check & self-update --- ## Tolerates a null manager so test fixtures that build the dock without ## `_build_ui()` don't false-positive on the worker-spawn gate. func _is_self_update_in_progress() -> bool: return _update_manager != null and bool(_update_manager.is_install_in_flight()) func _on_update_pressed() -> void: if _update_manager != null: _update_manager.start_install() func _on_update_check_result(result: Dictionary) -> void: _update_label.text = String(result.get("label_text", "")) _update_banner.visible = true ## Apply only the keys present so the manager can ship partial updates ## (e.g. button-text-only during the download phase) without clobbering ## banner state. func _on_install_state_changed(state: Dictionary) -> void: if state.has("button_text") and _update_btn != null: _update_btn.text = String(state["button_text"]) if state.has("button_disabled") and _update_btn != null: _update_btn.disabled = bool(state["button_disabled"]) if state.has("label_text") and _update_label != null: _update_label.text = String(state["label_text"]) if state.has("banner_visible") and _update_banner != null: _update_banner.visible = bool(state["banner_visible"]) if String(state.get("outcome", "")) == "success" and _update_label != null: ## Visual confirmation for the pre-4.4 "Updated! Restart the editor." ## terminal state — the only outcome the manager paints green for. _update_label.add_theme_color_override("font_color", Color.GREEN)