Files

2497 lines
101 KiB
GDScript

@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.<new_method>()` 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)