2880 lines
118 KiB
GDScript
2880 lines
118 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.
|
|
##
|
|
## Client workers and update orchestration have plugin lifetime and are owned
|
|
## by plugin.gd. This replaceable view emits intents and paints copied
|
|
## snapshots/outcomes; detaching it cannot destroy domain work.
|
|
|
|
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 Client := preload("res://addons/godot_ai/clients/_base.gd")
|
|
const PortResolver := preload("res://addons/godot_ai/utils/port_resolver.gd")
|
|
const ClientConfigurator := preload("res://addons/godot_ai/client_configurator.gd")
|
|
const ClientRegistry := preload("res://addons/godot_ai/clients/_registry.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 VisionRoutingScript := preload("res://addons/godot_ai/vision_routing.gd")
|
|
|
|
const DEV_MODE_SETTING := "godot_ai/dev_mode"
|
|
## "Change the port + reconfigure your clients" guide. Surfaced from the crash
|
|
## panel when a foreign process holds the HTTP port — the one piece of recovery
|
|
## (per-client config rewrite) that doesn't fit in the inline crash body.
|
|
## Resolved against the installed plugin version at click time (see
|
|
## `_port_conflict_docs_url`) so a shipped build opens the guide as it shipped,
|
|
## not tip-of-main, which may have drifted from that build's UI.
|
|
const PORT_CONFLICT_DOCS_PATH := "docs/port-conflicts.md"
|
|
const REPO_BLOB_BASE := "https://github.com/hi-godot/godot-ai/blob"
|
|
const RELEASES_PAGE := "https://github.com/hi-godot/godot-ai/releases/latest"
|
|
## Opened by the "How to install uv" button. See _on_install_uv for why the
|
|
## dock links here instead of running an installer itself.
|
|
const UV_INSTALL_DOCS_URL := "https://docs.astral.sh/uv/getting-started/installation/"
|
|
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)
|
|
|
|
signal update_requested
|
|
signal client_action_requested(client_id: String, action: String)
|
|
signal client_action_cancel_requested(client_id: String)
|
|
signal client_status_refresh_requested(client_ids: Array[String], force: bool)
|
|
signal status_snapshot_requested
|
|
signal live_server_probe_requested(port: int)
|
|
signal lifecycle_action_requested(action: int)
|
|
signal dev_server_action_requested(action: int)
|
|
signal mcp_logging_changed(enabled: bool)
|
|
signal log_snapshot_requested(after_sequence: int)
|
|
signal plugin_reload_requested(reason: String)
|
|
signal settings_apply_requested(changes: Dictionary, reload: bool)
|
|
signal post_update_action_requested(action: String)
|
|
|
|
enum LifecycleAction { RECOVER_INCOMPATIBLE, RESTART_SERVER }
|
|
enum DevServerAction { START_OR_RESTART, STOP }
|
|
|
|
## Copied root-owned values only. The Dock never retains Connection, lifecycle,
|
|
## plugin, log-buffer, or process owners.
|
|
var _transport_snapshot: Dictionary = {
|
|
"connected": false,
|
|
"server_version": "",
|
|
"status": {},
|
|
}
|
|
var _lifecycle_snapshot: Dictionary = {
|
|
"state": ServerStateScript.UNINITIALIZED,
|
|
"server_pid": -1,
|
|
"resolved_ws_port": 0,
|
|
"can_restart_managed": false,
|
|
"can_recover_incompatible": false,
|
|
"normal_start_released": false,
|
|
}
|
|
var _live_server_probe_result: Dictionary = {}
|
|
|
|
# Always visible
|
|
var _redock_btn: Button
|
|
var _status_icon: ColorRect
|
|
var _status_label: Label
|
|
var _body_scroll: ScrollContainer
|
|
var _body: VBoxContainer
|
|
var _client_grid: VBoxContainer
|
|
var _client_configure_all_btn: Button
|
|
var _client_empty_cta_btn: Button
|
|
var _clients_summary_label: Label
|
|
var _clients_window: Window
|
|
var _dev_mode_toggle: CheckButton
|
|
var _install_label: Label
|
|
|
|
# Tools 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 and 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()
|
|
## Custom (addon-registered) tools list — rebuilt live on registry
|
|
## tools_changed; per-tool checkboxes apply immediately (no restart).
|
|
var _custom_tools_list: VBoxContainer
|
|
var _custom_tools_count_label: Label
|
|
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
|
|
## Update saves the project and relaunches the editor; the user says so first.
|
|
var _update_confirm: ConfirmationDialog
|
|
var _update_candidate_version := ""
|
|
var _telemetry_toggle: CheckButton
|
|
var _telemetry_pending_enabled: bool = true
|
|
var _telemetry_saved_enabled: bool = true
|
|
|
|
# Settings tab (secondary window, Tab 3) — Vision Routing section plus the
|
|
# LAN opt-in (#507): "Allow remote hosts (CIDR)" behind a collapsed
|
|
# "Remote access (advanced)" disclosure (auto-expands when a non-empty
|
|
# allowlist is configured). The value feeds `--allow-host` at server spawn
|
|
# (see plugin.gd::_build_server_flags). The LineEdit's live text is the
|
|
# pending state; `_allow_hosts_saved` mirrors the persisted EditorSetting,
|
|
# same pending/saved shape as the Tools tab above.
|
|
var _allow_hosts_section: VBoxContainer
|
|
var _allow_hosts_fold: FoldableContainer
|
|
var _allow_hosts_edit: LineEdit
|
|
var _allow_hosts_hint: Label
|
|
var _allow_hosts_apply_btn: Button
|
|
var _allow_hosts_saved: String = ""
|
|
|
|
## Per-client UI handles, keyed by client id. Each entry holds the row's
|
|
## status dot, configure/remove buttons, config-file buttons, and manual panel.
|
|
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
|
|
## Set when the user clicks "How to install uv"; consumed by the next
|
|
## application focus-in so the uv row is re-probed after the user has had a
|
|
## chance to install, not immediately. See _on_install_uv and _notification.
|
|
## (Deliberately spelled without the focus-in constant name: the guard in
|
|
## tests/unit/test_editor_focus_refocus.py locates the notification handler
|
|
## by first occurrence of that token.)
|
|
var _uv_recheck_pending := false
|
|
## Handles for the Setup section's "Server" row. `_update_status_label` 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
|
|
## 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] = []
|
|
## Copied plugin-lifetime client-job state. This contains no Thread, mutex,
|
|
## cancellation map, generation, or mutable owner record.
|
|
var _client_work_snapshot: Dictionary = {
|
|
"accepting_work": false,
|
|
"refresh_state": ClientRefreshStateScript.IDLE,
|
|
"refresh_completed": false,
|
|
"busy_actions": [],
|
|
"action_names": {},
|
|
"action_phases": {},
|
|
}
|
|
var _update_install_in_flight := false
|
|
|
|
# Dev-mode only
|
|
var _dev_section: VBoxContainer
|
|
var _server_label: Label
|
|
var _reload_btn: Button
|
|
var _setup_section: VBoxContainer
|
|
var _setup_container: VBoxContainer
|
|
## Developer controls are a view of the ordinary lifecycle owner. They never
|
|
## start a second process topology or kill an externally launched server.
|
|
var _dev_primary_btn: Button
|
|
## Small "✕" affordance next to the primary — enabled only for the exact
|
|
## plugin-owned process grant.
|
|
var _dev_stop_btn: Button
|
|
var _log_viewer: LogViewerScript
|
|
## Vision Routing (optional) - set by plugin.gd; builds the "Vision Routing"
|
|
## tab in Clients & Tools and the quick toggle under Developer mode.
|
|
var vision_routing: VisionRoutingScript = null
|
|
|
|
var _last_connected := false
|
|
var _last_status_text := ""
|
|
var _last_status_tooltip := ""
|
|
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
|
|
## Help link — visible only for the genuinely-foreign-occupant INCOMPATIBLE
|
|
## case (no `can_recover_incompatible` proof). The inline body names a free
|
|
## port; this button carries the per-client reconfigure steps that don't fit
|
|
## inline. See `PORT_CONFLICT_DOCS` and `_update_crash_panel`.
|
|
var _crash_docs_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. Release polling, ZIP download, and
|
|
# installation remain root-owned and arrive here as copied presentation state.
|
|
var _update_banner: VBoxContainer
|
|
var _update_label: Label
|
|
var _update_status_label: Label
|
|
var _update_btn: Button
|
|
## The button is an action, never a status line: progress and failure text
|
|
## goes to `_update_status_label`, and the button only enables or disables.
|
|
const _UPDATE_ACTION_TEXT := "Update"
|
|
const _UPDATE_LABEL_COLOR := Color(1.0, 0.85, 0.3)
|
|
var _post_update_action := ""
|
|
## True from the moment an update starts its client migration until the
|
|
## server it then starts is connected. The transport reads "blocked" for
|
|
## that whole window (the migration barrier, then the launch), which is
|
|
## not a fault: name the phase instead of alarming the user (#999).
|
|
var _post_update_server_pending := false
|
|
|
|
func _ready() -> void:
|
|
_startup_grace_until_msec = Time.get_ticks_msec() + STARTUP_GRACE_MSEC
|
|
_build_ui()
|
|
|
|
|
|
func _process(_delta: float) -> void:
|
|
_update_status()
|
|
if _log_viewer != null and _log_viewer.visible:
|
|
log_snapshot_requested.emit(_log_viewer.sequence())
|
|
|
|
|
|
func present_transport_snapshot(snapshot: Dictionary) -> void:
|
|
_transport_snapshot = snapshot.duplicate(true)
|
|
|
|
|
|
func present_lifecycle_snapshot(snapshot: Dictionary) -> void:
|
|
_lifecycle_snapshot = snapshot.duplicate(true)
|
|
|
|
|
|
func present_live_server_probe_result(result: Dictionary) -> void:
|
|
_live_server_probe_result = result.duplicate(true)
|
|
|
|
|
|
func present_log_snapshot(snapshot: Dictionary) -> void:
|
|
if _log_viewer != null:
|
|
_log_viewer.present_snapshot(snapshot)
|
|
|
|
|
|
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)
|
|
## Re-probe uv only when the user actually went off to install it
|
|
## (see _on_install_uv). `check_uv_version()` is cached, so an
|
|
## ungated refresh here would usually be free — but after the
|
|
## button invalidated that cache it costs one blocking
|
|
## `uvx --version`, and this notification must not grow a probe on
|
|
## the common focus-in path. One-shot: clear before refreshing.
|
|
if _uv_recheck_pending:
|
|
_uv_recheck_pending = false
|
|
_refresh_setup_status.call_deferred()
|
|
|
|
|
|
## Godot can leave its shared progress dialog under one of our modal windows.
|
|
## Return it before removing the dock: freeing it leaves the editor's pointer dangling.
|
|
func release_editor_progress_dialog() -> void:
|
|
if not is_inside_tree():
|
|
return
|
|
for dialog in find_children("*", "ProgressDialog", true, false):
|
|
dialog.reparent(get_tree().root)
|
|
|
|
|
|
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_label 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)
|
|
|
|
_body_scroll = ScrollContainer.new()
|
|
_body_scroll.name = "DockBodyScroll"
|
|
_body_scroll.size_flags_horizontal = Control.SIZE_EXPAND_FILL
|
|
_body_scroll.size_flags_vertical = Control.SIZE_EXPAND_FILL
|
|
_body_scroll.custom_minimum_size = Vector2(0, 48)
|
|
_body_scroll.horizontal_scroll_mode = ScrollContainer.SCROLL_MODE_DISABLED
|
|
add_child(_body_scroll)
|
|
|
|
_body = VBoxContainer.new()
|
|
_body.name = "DockBody"
|
|
_body.add_theme_constant_override("separation", 8)
|
|
_body.size_flags_horizontal = Control.SIZE_EXPAND_FILL
|
|
_body_scroll.add_child(_body)
|
|
|
|
# --- 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_docs_btn = Button.new()
|
|
_crash_docs_btn.text = "How to change the port"
|
|
_crash_docs_btn.tooltip_text = "Open the guide: change ports in Godot AI settings and reconfigure your MCP clients"
|
|
_crash_docs_btn.visible = false
|
|
_crash_docs_btn.pressed.connect(func(): OS.shell_open(_port_conflict_docs_url()))
|
|
_crash_panel.add_child(_crash_docs_btn)
|
|
|
|
_crash_panel.add_child(HSeparator.new())
|
|
_body.add_child(_crash_panel)
|
|
|
|
# --- 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", _UPDATE_LABEL_COLOR)
|
|
## Wrap long banner text (e.g. the < 4.5 support-floor 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)
|
|
|
|
_update_status_label = Label.new()
|
|
_update_status_label.add_theme_font_size_override("font_size", 13)
|
|
_update_status_label.autowrap_mode = TextServer.AUTOWRAP_WORD_SMART
|
|
_update_status_label.size_flags_horizontal = Control.SIZE_FILL
|
|
_update_status_label.custom_minimum_size = Vector2(0, 0)
|
|
_update_status_label.visible = false
|
|
_update_banner.add_child(_update_status_label)
|
|
|
|
var update_btn_row := HBoxContainer.new()
|
|
update_btn_row.add_theme_constant_override("separation", 6)
|
|
|
|
_update_btn = Button.new()
|
|
_update_btn.text = _UPDATE_ACTION_TEXT
|
|
_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(RELEASES_PAGE))
|
|
update_btn_row.add_child(release_link)
|
|
|
|
_update_banner.add_child(update_btn_row)
|
|
_update_banner.add_child(HSeparator.new())
|
|
|
|
_body.add_child(_update_banner)
|
|
|
|
# --- Dev-only connection extras (server label + reload button) ---
|
|
_dev_section = VBoxContainer.new()
|
|
_dev_section.add_theme_constant_override("separation", 6)
|
|
_body.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)
|
|
_body.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)
|
|
|
|
_body.add_child(HSeparator.new())
|
|
|
|
# --- Clients ---
|
|
var clients_header_row := HBoxContainer.new()
|
|
clients_header_row.add_theme_constant_override("separation", 8)
|
|
|
|
var clients_header := _make_header("Clients")
|
|
clients_header_row.add_child(clients_header)
|
|
|
|
_clients_summary_label = Label.new()
|
|
_clients_summary_label.add_theme_color_override("font_color", COLOR_MUTED)
|
|
_clients_summary_label.clip_text = true
|
|
_clients_summary_label.text_overrun_behavior = TextServer.OVERRUN_TRIM_ELLIPSIS
|
|
_clients_summary_label.size_flags_horizontal = Control.SIZE_EXPAND_FILL
|
|
clients_header_row.add_child(_clients_summary_label)
|
|
|
|
var clients_actions := HFlowContainer.new()
|
|
clients_actions.add_theme_constant_override("h_separation", 8)
|
|
clients_actions.add_theme_constant_override("v_separation", 4)
|
|
|
|
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_actions.add_child(clients_refresh_btn)
|
|
|
|
var clients_open_btn := Button.new()
|
|
clients_open_btn.text = "Clients & Tools"
|
|
clients_open_btn.tooltip_text = "Open the Clients & Tools 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_actions.add_child(clients_open_btn)
|
|
|
|
_body.add_child(clients_header_row)
|
|
_body.add_child(clients_actions)
|
|
|
|
_client_empty_cta_btn = Button.new()
|
|
_client_empty_cta_btn.text = "Configure an AI client ->"
|
|
_client_empty_cta_btn.tooltip_text = "Open the Clients tab to configure an AI coding client for this Godot AI server."
|
|
_client_empty_cta_btn.size_flags_horizontal = Control.SIZE_EXPAND_FILL
|
|
_client_empty_cta_btn.visible = false
|
|
_client_empty_cta_btn.pressed.connect(_on_open_clients_window)
|
|
_body.add_child(_client_empty_cta_btn)
|
|
|
|
# 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)
|
|
_body.add_child(_drift_banner)
|
|
|
|
_clients_window = Window.new()
|
|
_clients_window.title = "Godot AI 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)
|
|
|
|
## Tabbed secondary window: Clients (per-client rows), Tools (domain-
|
|
## exclusion checkboxes for clients that cap total tool count, like
|
|
## Antigravity at 100), and Settings (allow-host LAN opt-in, #507).
|
|
## Adding another tab is one more _build_*_tab call — 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)
|
|
_build_settings_tab(tabs)
|
|
|
|
_body.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)
|
|
_body.add_child(dev_toggle_row)
|
|
|
|
# --- Log section (dev-only) ---
|
|
_log_viewer = LogViewerScript.new()
|
|
_log_viewer.setup()
|
|
_log_viewer.logging_enabled_changed.connect(_on_log_logging_enabled_changed)
|
|
_body.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
|
|
## Every advertised client uses the authenticated local attach bridge.
|
|
var transport_tag := Label.new()
|
|
transport_tag.text = _client_transport_tag(client_id)
|
|
transport_tag.add_theme_color_override("font_color", COLOR_MUTED)
|
|
transport_tag.tooltip_text = "Configure writes a local `godot-ai attach` launch command for this client."
|
|
## 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)
|
|
row.add_child(transport_tag)
|
|
|
|
var configure_btn := Button.new()
|
|
configure_btn.text = "Configure"
|
|
configure_btn.pressed.connect(_on_client_action_button_pressed.bind(client_id, "configure"))
|
|
row.add_child(configure_btn)
|
|
|
|
var remove_btn := Button.new()
|
|
remove_btn.text = "Remove"
|
|
remove_btn.visible = false
|
|
remove_btn.pressed.connect(_on_client_action_button_pressed.bind(client_id, "remove"))
|
|
row.add_child(remove_btn)
|
|
|
|
# F-3-4: use the authoritative facade so Open/Reveal land on the same
|
|
# file `_check_status_merged` drives (last-wins across project tiers,
|
|
# matching the F2 status fix).
|
|
var config_path := ClientConfigurator.effective_authoritative_path(client_id)
|
|
var open_config_btn := Button.new()
|
|
_apply_editor_icon(open_config_btn, "ExternalLink", "Open")
|
|
open_config_btn.custom_minimum_size = Vector2(28, 28)
|
|
open_config_btn.visible = not config_path.is_empty()
|
|
open_config_btn.pressed.connect(_on_open_config_file.bind(client_id))
|
|
row.add_child(open_config_btn)
|
|
|
|
var reveal_btn := Button.new()
|
|
_apply_editor_icon(reveal_btn, "Folder", "Reveal")
|
|
reveal_btn.custom_minimum_size = Vector2(28, 28)
|
|
reveal_btn.visible = not config_path.is_empty()
|
|
reveal_btn.pressed.connect(_on_reveal_config_folder.bind(client_id))
|
|
row.add_child(reveal_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,
|
|
"open_config_btn": open_config_btn,
|
|
"reveal_btn": reveal_btn,
|
|
"config_path": config_path,
|
|
"manual_panel": manual_panel,
|
|
"manual_text": manual_text,
|
|
}
|
|
_refresh_client_config_file_buttons(client_id)
|
|
|
|
|
|
func _apply_editor_icon(button: Button, icon_name: String, fallback_text: String) -> void:
|
|
if has_theme_icon(icon_name, "EditorIcons"):
|
|
button.icon = get_theme_icon(icon_name, "EditorIcons")
|
|
else:
|
|
button.text = fallback_text
|
|
|
|
|
|
# --- Status updates ---
|
|
|
|
func _update_status() -> void:
|
|
## Signal delivery is synchronous: the composition root copies current
|
|
## transport/lifecycle values into the presentation methods below before
|
|
## this frame renders. If no root is attached, the last snapshot remains.
|
|
status_snapshot_requested.emit()
|
|
var connected := bool(_transport_snapshot.get("connected", false))
|
|
var transport_status: Dictionary = _transport_snapshot.get("status", {})
|
|
var server_status := _lifecycle_snapshot
|
|
var state: int = int(server_status.get("state", ServerStateScript.UNINITIALIZED))
|
|
if ServerStateScript.blocks_client_health(state):
|
|
connected = false
|
|
if connected:
|
|
_post_update_server_pending = 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`.
|
|
## Default covers both a missing/old Connection instance and an unknown
|
|
## future transport phase. Every recognized state below overrides it, so
|
|
## startup grace and settled disconnect have one rendering path.
|
|
var inside_startup_grace := Time.get_ticks_msec() < _startup_grace_until_msec
|
|
var status_text := "Starting server…" if inside_startup_grace else "Disconnected"
|
|
var status_color := COLOR_AMBER if inside_startup_grace else Color.RED
|
|
if _server_restart_in_progress:
|
|
status_text = "Restarting server..."
|
|
status_color = COLOR_AMBER
|
|
elif connected:
|
|
status_text = _connected_status_text()
|
|
status_color = Color.GREEN
|
|
elif bool(server_status.get("handoff_retry_pending", false)):
|
|
status_text = "Recovering after update…"
|
|
status_color = COLOR_AMBER
|
|
elif state == ServerStateScript.UNSUPPORTED_CONFIG:
|
|
status_text = "Unsupported remote access configuration"
|
|
status_color = Color.RED
|
|
elif state == ServerStateScript.CRASHED and str(server_status.get("reason", "")) == "endpoint_lost":
|
|
var recovery_pending := bool(server_status.get("recovery_pending", false))
|
|
status_text = "Connection lost; reconnecting..." if recovery_pending else "Connection lost"
|
|
status_color = COLOR_AMBER if recovery_pending else Color.RED
|
|
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:
|
|
## #647: the post-crash probe names the actual conflicting port
|
|
## (HTTP or WS) — don't blame port 8000 when 9500 is the occupant.
|
|
var conflict_port: int = int(server_status.get("conflict_port", 0))
|
|
if conflict_port <= 0:
|
|
conflict_port = ClientConfigurator.http_port()
|
|
status_text = (
|
|
"Windows port discovery unavailable" if str(server_status.get("episode_reason", "")) == "port_occupancy_unknown"
|
|
else "Port %d held by another process" % conflict_port
|
|
)
|
|
status_color = Color.RED
|
|
elif state == ServerStateScript.NO_COMMAND:
|
|
status_text = "No server command found"
|
|
status_color = Color.RED
|
|
elif _post_update_server_pending:
|
|
## Every terminal spawn failure matched above; what is left is the
|
|
## post-update window where the server is being brought back.
|
|
status_text = "Finishing update — starting server…"
|
|
status_color = COLOR_AMBER
|
|
elif state == ServerStateScript.SPAWNING:
|
|
status_text = "Starting server…"
|
|
status_color = COLOR_AMBER
|
|
elif not transport_status.is_empty():
|
|
var transport_phase := str(transport_status.get("phase", ""))
|
|
if transport_phase == "connecting":
|
|
status_text = _transport_status_text(transport_status)
|
|
status_color = COLOR_AMBER
|
|
elif transport_phase == "retrying":
|
|
status_text = _transport_status_text(transport_status)
|
|
status_color = COLOR_AMBER
|
|
elif transport_phase == "closing":
|
|
status_text = _transport_status_text(transport_status)
|
|
status_color = COLOR_AMBER
|
|
elif transport_phase == "blocked":
|
|
## Exact terminal labels come from lifecycle state above. This is a
|
|
## generic fallback for a blocked connection without a diagnosis.
|
|
status_text = _transport_status_text(transport_status)
|
|
status_color = Color.RED
|
|
|
|
## keep_server_on_exit (#800): the reaper env opt-outs are staged at
|
|
## spawn, so a mid-session toggle only lands on the next server start —
|
|
## say so while the running server still carries the old behavior.
|
|
if connected and ClientConfigurator.keep_server_on_exit() != bool(server_status.get("keep_alive", false)):
|
|
status_text += " — keep-server-on-exit applies after Restart"
|
|
|
|
_update_crash_panel(server_status)
|
|
_refresh_server_version_label(server_status)
|
|
_refresh_server_label(server_status)
|
|
|
|
## A transient disconnect reason remains in the transport snapshot until
|
|
## handshake_ack. Once the dock renders the connection as OPEN, do not pair
|
|
## its green label with the previous peer's recovery diagnostic.
|
|
var status_tooltip := "" if connected else str(transport_status.get("reason", ""))
|
|
var changed: bool = (
|
|
connected != _last_connected
|
|
or status_text != _last_status_text
|
|
or status_tooltip != _last_status_tooltip
|
|
)
|
|
if not changed:
|
|
return
|
|
var just_connected: bool = connected and not _last_connected
|
|
_last_connected = connected
|
|
_last_status_text = status_text
|
|
_last_status_tooltip = status_tooltip
|
|
_status_icon.color = status_color
|
|
_status_label.text = status_text
|
|
_status_label.tooltip_text = status_tooltip
|
|
if just_connected:
|
|
## #739: the server just came up. If the startup uv probe failed
|
|
## (the reporter's screenshot: green "Server connected" beside a
|
|
## red "uv: not found" row), the failure was transient — re-probe
|
|
## instead of pinning the red row for the whole session. Runs
|
|
## AFTER the label writes above and via the deferred queue, so the
|
|
## status-machine state is committed before the probe can block.
|
|
_schedule_uv_reprobe()
|
|
|
|
## Status transitions are exactly when "is the launch still settling?"
|
|
## can change (Starting server… -> connected / Disconnected / terminal
|
|
## diagnosis), so re-evaluate the Setup section's visibility here (#744).
|
|
## Cheap: runs only on `changed`, and the uv probe result is cached.
|
|
_apply_dev_mode_visibility()
|
|
|
|
_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
|
|
)
|
|
## Docs link only for the genuinely-foreign occupant: a recoverable
|
|
## (older godot-ai) server gets Restart Server instead, and the inline
|
|
## body already names a free port — the link carries the per-client
|
|
## reconfigure steps that don't fit inline.
|
|
if _crash_docs_btn != null:
|
|
_crash_docs_btn.visible = (
|
|
state == ServerStateScript.INCOMPATIBLE
|
|
and not bool(server_status.get("can_recover_incompatible", false))
|
|
)
|
|
|
|
## The picker moves both ports (#647 hid it for a WebSocket-side
|
|
## conflict when it could only move the HTTP port), seeded with the
|
|
## diagnosed conflict so only the contested port changes.
|
|
var conflict_port := int(server_status.get("conflict_port", 0))
|
|
var port_picker_visible := (
|
|
state == ServerStateScript.PORT_EXCLUDED or state == ServerStateScript.FOREIGN_PORT
|
|
or (state == ServerStateScript.INCOMPATIBLE
|
|
and not bool(server_status.get("can_recover_incompatible", false)))
|
|
)
|
|
_port_picker_panel.visible = port_picker_visible
|
|
if port_picker_visible:
|
|
_port_picker_panel.seed_suggested_ports(conflict_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.UNSUPPORTED_CONFIG:
|
|
return str(server_status.get("message", "Use an IPv4 allowlist or clear Allow remote hosts, then reload the plugin."))
|
|
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]
|
|
## Genuinely foreign occupant (no recovery proof). Name a concrete
|
|
## free port so the user doesn't have to hunt for one, and let the
|
|
## crash panel's "How to change the port" link carry the per-client
|
|
## reconfigure steps. `suggest_free_port` already routes through the
|
|
## Windows reservation table, so the named port won't itself fail
|
|
## with WinError 10013.
|
|
var hint := _free_port_hint(port)
|
|
if not message.is_empty():
|
|
return "%s %s" % [message, hint]
|
|
return "Port %d is occupied by an incompatible server. %s" % [port, hint]
|
|
ServerStateScript.FOREIGN_PORT:
|
|
## #647: prefer the lifecycle's diagnosis (it names the right
|
|
## port — HTTP vs WS — and the Editor Setting to change) over
|
|
## the generic HTTP-port fallback.
|
|
var foreign_message := str(server_status.get("message", ""))
|
|
if not foreign_message.is_empty():
|
|
return foreign_message
|
|
return "Another process is already bound to port %d. Pick a free port or stop the other process." % port
|
|
ServerStateScript.CRASHED:
|
|
## #805: a specific crash diagnosis from the lifecycle (e.g. the
|
|
## flapping-occupant latch) beats the generic launch-mode copy.
|
|
## Generic crash paths clear the message, so stale text from an
|
|
## earlier state can't leak in here.
|
|
var crash_message := str(server_status.get("message", ""))
|
|
if not crash_message.is_empty():
|
|
return crash_message
|
|
## Both spawn attempts failed on the uvx tier — stock releases:
|
|
## PyPI lag. Local builds (version with +metadata): almost always the
|
|
## dev venv was not found (unresolved junction/symlink) so uvx tried
|
|
## a pin that may lack checkout-local extras.
|
|
if ClientConfigurator.get_server_launch_mode() == "uvx":
|
|
var version := ClientConfigurator.get_plugin_version()
|
|
var pin := ClientConfigurator._pypi_pin_version(version)
|
|
if pin != version:
|
|
## `%` binds tighter than `+` in GDScript — format the fully
|
|
## concatenated string, never the last fragment alone.
|
|
return (
|
|
"The server exited before the WebSocket handshake. "
|
|
+ "Local plugin version is %s (PEP 440 local build metadata) — uvx pins PyPI godot-ai==%s. "
|
|
+ "If you need checkout-local server code, ensure addons/godot_ai resolves to your "
|
|
+ "dev tree (symlink/junction) with a `.venv`, or set GODOT_AI_VENV_PYTHON to that "
|
|
+ "venv's python binary, then Reload Plugin. Log should show 'MCP | using dev venv: ...'."
|
|
) % [version, pin]
|
|
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."
|
|
) % pin
|
|
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 ""
|
|
|
|
|
|
## One sentence naming concrete free ports for the user to switch to. Names
|
|
## BOTH http and ws: this branch also fires for an incompatible godot-ai
|
|
## server we can't prove we own, which commonly holds both ports — moving only
|
|
## http would then leave the new server unable to bind ws. Both suggestions are
|
|
## routed through `suggest_free_port` so they clear Windows' winnat reservation
|
|
## table (no point suggesting a port that 10013s on bind). Both ports reach
|
|
## the client's attach command, so clients must be reconfigured afterwards.
|
|
## The per-client reconfigure steps live behind the crash panel's docs link.
|
|
static func _free_port_hint(port: int) -> String:
|
|
var occupancy := PortResolver.windows_listener_snapshot() if OS.get_name() == "Windows" else {}
|
|
var free_http := ClientConfigurator.suggest_free_port(port + 1, 2048, occupancy)
|
|
var free_ws := ClientConfigurator.suggest_free_port(ClientConfigurator.ws_port() + 1, 2048, occupancy)
|
|
if free_http == 0 or free_ws == 0:
|
|
return "Automatic port selection is unavailable. Choose HTTP and WS ports manually below, or retry."
|
|
return "Suggested ports: %d (HTTP) and %d (WS). Choose both ports below, click Apply + Reload, then Configure your AI clients to use the new pair." % [free_http, free_ws]
|
|
|
|
|
|
## URL for the port-conflict guide, pinned to the release tag that matches the
|
|
## installed plugin version (releases are tagged `v<version>`). The crash-panel
|
|
## button only exists in builds that ship `docs/port-conflicts.md`, so the
|
|
## versioned ref always resolves — and a shipped build never points users at a
|
|
## tip-of-main guide that has drifted from its own UI.
|
|
static func _port_conflict_docs_url() -> String:
|
|
var version := ClientConfigurator.get_plugin_version()
|
|
var git_ref := ("v%s" % version) if not version.is_empty() else "main"
|
|
return "%s/%s/%s" % [REPO_BLOB_BASE, git_ref, PORT_CONFLICT_DOCS_PATH]
|
|
|
|
|
|
## Signal handler for the extracted LogViewer — the panel owns its own
|
|
## display visibility, the dock owns logging routing. Routes to BOTH the
|
|
## dispatcher (gates [recv]/[send] recording) and the log buffer's console
|
|
## echo — the connection logs [event]/[defer] lines directly to the buffer,
|
|
## bypassing the dispatcher, so gating only `mcp_logging` left the console
|
|
## spamming with the toggle off (#626). Ring recording is unaffected, so
|
|
## the dock's log panel keeps working while the console stays quiet.
|
|
func _on_log_logging_enabled_changed(enabled: bool) -> void:
|
|
mcp_logging_changed.emit(enabled)
|
|
|
|
|
|
## Signal handler for the extracted PortPickerPanel. The replaceable Dock emits
|
|
## a copied value intent; the composition root owns persistence and reload.
|
|
func _on_port_apply_requested(new_http_port: int, new_ws_port: int) -> void:
|
|
settings_apply_requested.emit({"http_port": new_http_port, "ws_port": new_ws_port}, true)
|
|
|
|
|
|
func _refresh_server_label(server_status: Dictionary = {}) -> void:
|
|
if _server_label == null:
|
|
return
|
|
var ws_port := int(_lifecycle_snapshot.get("resolved_ws_port", 0))
|
|
if ws_port <= 0:
|
|
ws_port = ClientConfigurator.ws_port()
|
|
var text := "WS: %d HTTP: %d" % [ws_port, ClientConfigurator.http_port()]
|
|
if server_status.is_empty():
|
|
server_status = _lifecycle_snapshot
|
|
var ownership := _server_ownership_tag(
|
|
int(server_status.get("state", ServerStateScript.UNINITIALIZED)),
|
|
int(server_status.get("server_pid", -1)),
|
|
)
|
|
if not ownership.is_empty():
|
|
text += " · %s" % ownership
|
|
_server_label.text = text
|
|
|
|
|
|
## #838/#816 step 11: name which backend flavor the editor is riding.
|
|
## Diagnostic display only — never kill proof (external adoption clears PID
|
|
## authority, see server_lifecycle.gd::adopt_compatible_server / #669).
|
|
static func _server_ownership_tag(state: int, server_pid: int) -> String:
|
|
if state != ServerStateScript.READY:
|
|
return ""
|
|
return "plugin-managed backend" if server_pid > 0 else "externally adopted backend"
|
|
|
|
|
|
static func _client_transport_tag(client_id: String) -> String:
|
|
var client := ClientRegistry.get_by_id(client_id)
|
|
if client == null:
|
|
return ""
|
|
return "attach"
|
|
|
|
|
|
# --- 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 env_disabled = _is_telemetry_disabled_via_env()
|
|
|
|
var enabled: bool
|
|
if env_disabled != null:
|
|
## Environment policy controls this activation but does not rewrite the
|
|
## user's persisted preference.
|
|
enabled = not bool(env_disabled)
|
|
else:
|
|
## Defaults are registered by the composition root before Dock creation.
|
|
var es := EditorInterface.get_editor_settings()
|
|
if es != null and es.has_setting(McpSettings.SETTING_TELEMETRY_ENABLED):
|
|
enabled = bool(es.get_setting(McpSettings.SETTING_TELEMETRY_ENABLED))
|
|
else:
|
|
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 = _live_telemetry_tooltip(enabled)
|
|
|
|
|
|
func _on_telemetry_toggled(pressed: bool) -> void:
|
|
_telemetry_pending_enabled = pressed
|
|
if _telemetry_toggle != null:
|
|
_telemetry_toggle.tooltip_text = _live_telemetry_tooltip(pressed)
|
|
_refresh_tools_ui_state()
|
|
|
|
|
|
## Report the running server's telemetry state, not just this editor's
|
|
## checkbox, so the two can never silently disagree (#913). Applying an
|
|
## opt-out reaches the live server over the authenticated WebSocket and
|
|
## latches there, so both directions of a disagreement are real states worth
|
|
## explaining — not just the unreachable one.
|
|
func _live_telemetry_tooltip(local_enabled: bool) -> String:
|
|
_live_server_probe_result = {}
|
|
live_server_probe_requested.emit(ClientConfigurator.http_port())
|
|
var live := _live_server_probe_result
|
|
if not (live.get("telemetry_enabled") is bool):
|
|
## Absent means "too old to publish it", not false. Say nothing.
|
|
return ""
|
|
var server_enabled: bool = live.get("telemetry_enabled")
|
|
if server_enabled == local_enabled:
|
|
return "Running server telemetry is %s." % ("on" if server_enabled else "off")
|
|
if local_enabled:
|
|
return (
|
|
"The running server has telemetry off and will stay off until it is "
|
|
+ "replaced. An opt-out — this editor's earlier one, another editor "
|
|
+ "sharing this server, or GODOT_AI_DISABLE_TELEMETRY in its "
|
|
+ "environment — latched it. Restart the server to apply this."
|
|
)
|
|
return (
|
|
"The running server still has telemetry on. Applying sends it an "
|
|
+ "opt-out over the authenticated connection, which it honors for the "
|
|
+ "rest of its life."
|
|
)
|
|
|
|
|
|
# --- 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:
|
|
if _dev_mode_toggle == null:
|
|
return ## dock UI not built yet (unit tests, teardown window)
|
|
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) — but not while the server
|
|
# launch is still settling (#744): mid-launch a red "uv: not found" row
|
|
# is usually a transient probe failure (#739) or irrelevant because the
|
|
# launch is succeeding via the .venv or system tiers. `_update_status_label`
|
|
# re-applies visibility on every status transition, so the section
|
|
# appears the moment the launch outcome makes it relevant.
|
|
var is_dev := ClientConfigurator.is_dev_checkout()
|
|
var uv_missing := not is_dev and ClientConfigurator.check_uv_version().is_empty()
|
|
_setup_section.visible = _setup_section_should_show(dev, uv_missing, _server_launch_pending())
|
|
|
|
|
|
## Pure visibility decision for the Setup section (#744). Split out so the
|
|
## truth table is unit-testable without faking the uv probe or a dev
|
|
## checkout: dev toggle always shows the section; a missing uv only shows
|
|
## it once the server launch has settled.
|
|
static func _setup_section_should_show(
|
|
dev_toggle: bool, uv_missing: bool, launch_pending: bool
|
|
) -> bool:
|
|
return dev_toggle or (uv_missing and not launch_pending)
|
|
|
|
|
|
## True while the server launch outcome is still unknown: not connected,
|
|
## no terminal diagnosis yet, and the startup grace window ("Starting
|
|
## server…" in the status row) is still running. Mirrors the status-label
|
|
## logic in `_update_status_label` so the Setup section and the amber status
|
|
## text agree on what "still launching" means.
|
|
func _server_launch_pending() -> bool:
|
|
if _last_connected:
|
|
return false
|
|
var state := int(_lifecycle_snapshot.get("state", ServerStateScript.UNINITIALIZED))
|
|
if ServerStateScript.is_terminal_diagnosis(state):
|
|
return false
|
|
return Time.get_ticks_msec() < _startup_grace_until_msec
|
|
|
|
|
|
# --- Button handlers ---
|
|
|
|
|
|
func _on_reload_plugin() -> void:
|
|
plugin_reload_requested.emit("dock_button")
|
|
|
|
|
|
## 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():
|
|
server_status = _lifecycle_snapshot
|
|
var server_ver := str(_transport_snapshot.get("server_version", ""))
|
|
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(server_status.get("can_restart_managed", false))
|
|
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)
|
|
)
|
|
## Runs every frame, so write only what changed (#1121). `Label.text`
|
|
## already ignores same-value writes; `add_theme_color_override` does
|
|
## not — every call re-sends NOTIFICATION_THEME_CHANGED and queues a
|
|
## redraw, which kept the idle editor redrawing while this row was
|
|
## visible. Compare against the label itself so a rebuilt row still
|
|
## gets its color and a same-text state change (amber drift -> red
|
|
## incompatible) still repaints.
|
|
_setup_server_label.text = text
|
|
if (
|
|
not _setup_server_label.has_theme_color_override("font_color")
|
|
or _setup_server_label.get_theme_color("font_color") != color
|
|
):
|
|
_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 _server_restart_in_progress:
|
|
return
|
|
_server_restart_in_progress = true
|
|
_refresh_server_version_label()
|
|
if not is_inside_tree():
|
|
_dispatch_stale_server_restart()
|
|
_server_restart_in_progress = false
|
|
_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
|
|
_refresh_server_version_label()
|
|
|
|
|
|
func _dispatch_stale_server_restart() -> bool:
|
|
if int(_lifecycle_snapshot.get("state", ServerStateScript.UNINITIALIZED)) == ServerStateScript.INCOMPATIBLE:
|
|
lifecycle_action_requested.emit(LifecycleAction.RECOVER_INCOMPATIBLE)
|
|
return true
|
|
if not bool(_lifecycle_snapshot.get("can_restart_managed", false)):
|
|
return false
|
|
lifecycle_action_requested.emit(LifecycleAction.RESTART_SERVER)
|
|
return true
|
|
|
|
|
|
## Root acknowledgement for an asynchronous lifecycle intent. Successful
|
|
## restarts stay busy until the next lifecycle/transport snapshot settles;
|
|
## rejection restores the controls immediately.
|
|
func present_lifecycle_action_result(accepted: bool) -> void:
|
|
if accepted:
|
|
return
|
|
_server_restart_in_progress = false
|
|
_refresh_server_version_label()
|
|
|
|
|
|
# --- Setup section ---
|
|
|
|
## #739: a `uvx --version` probe that failed once at editor startup used
|
|
## to pin "uv: not found" for the whole session — the Install-uv click
|
|
## was the only invalidation path, so the fix users discovered was
|
|
## re-clicking Install on every launch. Re-probe on events that suggest
|
|
## the failure was transient (server-connect transition, manual Refresh).
|
|
## No-op once uv has been found, so this costs nothing in the healthy
|
|
## steady state; when uv is genuinely absent, the re-probe is a fast
|
|
## negative (CliFinder's well-known-dir walk plus one bounded `where`).
|
|
## Runs on the main thread like the initial probe — same wall-clock
|
|
## bound, and the triggering events are rare (once per connect / click).
|
|
##
|
|
## Callers go through _schedule_uv_reprobe() rather than calling this
|
|
## inline: the cache-miss probe shells out (bounded at 3s) on the
|
|
## calling thread, and both call sites sit mid-flow in UI handlers —
|
|
## the connect transition wants its status-label writes committed
|
|
## first, and the Refresh click wants the client sweep dispatched
|
|
## without waiting on the probe. Same deferred convention as
|
|
## _on_install_uv. (The deferred queue still flushes on the main
|
|
## thread, so a worst-case 3s probe delays that frame — acceptable for
|
|
## a rare, bounded event; a worker thread would be the heavier cure.)
|
|
func _schedule_uv_reprobe() -> void:
|
|
_reprobe_uv_if_negative.call_deferred()
|
|
|
|
|
|
func _reprobe_uv_if_negative() -> void:
|
|
if not ClientConfigurator.uv_probe_negative():
|
|
return
|
|
ClientConfigurator.invalidate_uv_detection()
|
|
_refresh_setup_status()
|
|
_apply_dev_mode_visibility()
|
|
|
|
|
|
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 Managed 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 exact plugin-owned server."
|
|
_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():
|
|
var compact_uv_version := _compact_uv_version_text(uv_version)
|
|
var uv_tooltip := uv_version if compact_uv_version != uv_version else ""
|
|
_setup_container.add_child(_make_status_row("uv", compact_uv_version, Color.GREEN, uv_tooltip))
|
|
## 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)
|
|
_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 = "How to install uv"
|
|
install_btn.tooltip_text = (
|
|
"Opens the official uv installation docs. Godot AI deliberately does "
|
|
+ "not run the installer for you — see _on_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 logical := ProjectSettings.globalize_path("res://addons/godot_ai").rstrip("/").rstrip("\\")
|
|
var resolved := ClientConfigurator.resolve_addons_realpath()
|
|
if resolved.is_empty() or resolved == logical:
|
|
return ""
|
|
return resolved
|
|
|
|
|
|
static func _compact_uv_version_text(uv_version: String) -> String:
|
|
var text := uv_version.strip_edges()
|
|
if text.ends_with(")"):
|
|
var metadata_start := text.rfind(" (")
|
|
if metadata_start >= 0:
|
|
return text.substr(0, metadata_start).strip_edges()
|
|
return text
|
|
|
|
|
|
func _make_status_row(
|
|
label_text: String,
|
|
value_text: String,
|
|
value_color: Color,
|
|
tooltip_text: String = ""
|
|
) -> HBoxContainer:
|
|
var row := HBoxContainer.new()
|
|
row.add_theme_constant_override("separation", 6)
|
|
if not tooltip_text.is_empty():
|
|
row.tooltip_text = tooltip_text
|
|
|
|
var label := Label.new()
|
|
label.text = label_text
|
|
label.add_theme_color_override("font_color", COLOR_MUTED)
|
|
label.custom_minimum_size.x = 60
|
|
if not tooltip_text.is_empty():
|
|
label.tooltip_text = tooltip_text
|
|
row.add_child(label)
|
|
|
|
var value := Label.new()
|
|
value.text = value_text
|
|
value.add_theme_color_override("font_color", value_color)
|
|
if not tooltip_text.is_empty():
|
|
value.tooltip_text = tooltip_text
|
|
row.add_child(value)
|
|
|
|
return row
|
|
|
|
|
|
## Pure view of the lifecycle's process authority. "External" means transport
|
|
## may be adopted, but this plugin has no right to stop its launcher.
|
|
static func _dev_primary_btn_state(managed: bool, external: bool) -> Dictionary:
|
|
var port := ClientConfigurator.http_port()
|
|
if managed:
|
|
return {
|
|
"text": "Restart Managed Server",
|
|
"enabled": true,
|
|
"tooltip": "Restart the exact plugin-owned server on port %d." % port,
|
|
}
|
|
if external:
|
|
return {
|
|
"text": "External Server Running",
|
|
"enabled": false,
|
|
"tooltip": "Stop the server on port %d from the process that launched it." % port,
|
|
}
|
|
return {
|
|
"text": "Start Managed Server",
|
|
"enabled": true,
|
|
"tooltip": "Start a plugin-owned server on port %d from the current checkout." % port,
|
|
}
|
|
|
|
|
|
static func _dev_stop_btn_state(managed: bool) -> Dictionary:
|
|
if managed:
|
|
return {"enabled": true, "tooltip": "Stop the exact plugin-owned server."}
|
|
return {"enabled": false, "tooltip": "No plugin-owned server to stop."}
|
|
|
|
|
|
func _on_dev_primary_pressed() -> void:
|
|
if _server_restart_in_progress:
|
|
return
|
|
_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`.
|
|
dev_server_action_requested.emit(DevServerAction.START_OR_RESTART)
|
|
_server_restart_in_progress = false
|
|
return
|
|
call_deferred("_perform_dev_restart_after_feedback")
|
|
|
|
|
|
func _on_dev_stop_pressed() -> void:
|
|
dev_server_action_requested.emit(DevServerAction.STOP)
|
|
_update_dev_section_buttons.call_deferred()
|
|
|
|
|
|
func _perform_dev_restart_after_feedback() -> void:
|
|
## Brief paint cycle so the user sees "Restarting..." before dispatch.
|
|
await get_tree().create_timer(0.15).timeout
|
|
dev_server_action_requested.emit(DevServerAction.START_OR_RESTART)
|
|
## Lifecycle snapshots normally repaint first; this is a bounded UI fallback.
|
|
await get_tree().create_timer(2.0).timeout
|
|
_server_restart_in_progress = false
|
|
_update_dev_section_buttons()
|
|
|
|
|
|
## Derive both buttons from the already-copied lifecycle value. No second port
|
|
## scan or dev-only ownership state exists.
|
|
func _update_dev_section_buttons() -> void:
|
|
var managed := int(_lifecycle_snapshot.get("server_pid", -1)) > 1
|
|
var normal_start_released := bool(
|
|
_lifecycle_snapshot.get("normal_start_released", false)
|
|
)
|
|
var state := int(_lifecycle_snapshot.get("state", ServerStateScript.UNINITIALIZED))
|
|
var external := not managed and (
|
|
str(_lifecycle_snapshot.get("ready_kind", "")) == "adopted"
|
|
or state in [ServerStateScript.INCOMPATIBLE, ServerStateScript.FOREIGN_PORT]
|
|
)
|
|
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 = "Restarting the plugin-owned server..."
|
|
elif not normal_start_released:
|
|
_dev_primary_btn.disabled = true
|
|
_dev_primary_btn.text = "Server Start Blocked"
|
|
_dev_primary_btn.tooltip_text = (
|
|
"Complete post-update client migration before starting the server."
|
|
)
|
|
else:
|
|
var primary_state := _dev_primary_btn_state(managed, external)
|
|
_dev_primary_btn.disabled = not bool(primary_state["enabled"])
|
|
_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(managed)
|
|
_dev_stop_btn.disabled = (not stop_state["enabled"]) or _server_restart_in_progress
|
|
_dev_stop_btn.tooltip_text = stop_state["tooltip"]
|
|
|
|
|
|
func _client_status_refresh_has_completed() -> bool:
|
|
return bool(_client_work_snapshot.get("refresh_completed", false))
|
|
|
|
|
|
func _client_refresh_state() -> int:
|
|
return int(_client_work_snapshot.get("refresh_state", ClientRefreshStateScript.IDLE))
|
|
|
|
|
|
func _connected_status_text() -> String:
|
|
return "Server connected"
|
|
|
|
|
|
static func _transport_status_text(snapshot: Dictionary) -> String:
|
|
## Total over the transport enum for isolated consumers/tests. The dock's
|
|
## connected fast path renders `_connected_status_text()` before calling it.
|
|
var phase := str(snapshot.get("phase", ""))
|
|
var attempt := maxi(1, int(snapshot.get("attempt", 0)))
|
|
match phase:
|
|
"connected":
|
|
return "Server connected"
|
|
"connecting":
|
|
return "Connecting — attempt %d" % attempt
|
|
"retrying":
|
|
var retry_in_sec := ceili(maxf(0.0, float(snapshot.get("retry_in_sec", 0.0))))
|
|
return "Retrying in %ds — attempt %d" % [retry_in_sec, attempt]
|
|
"closing":
|
|
return "Disconnecting…"
|
|
"blocked":
|
|
return "Connection blocked"
|
|
return "Disconnected"
|
|
|
|
|
|
## Open uv's official install documentation rather than executing an
|
|
## installer on the user's behalf.
|
|
##
|
|
## This used to shell out to `curl -LsSf https://astral.sh/uv/install.sh | sh`
|
|
## (and the PowerShell `irm … | iex` equivalent). That is arbitrary remote
|
|
## code execution as the editor user, one dock click deep, with no version
|
|
## pin, no checksum, and no signature — while this same plugin verifies its
|
|
## OWN updates with an RSA-4096 signature over a SHA-256 sidecar, pinned to a
|
|
## GitHub host and this repo's release-asset path. Holding a third-party
|
|
## installer to a weaker standard than our own payload is the wrong trade,
|
|
## and pinning a digest here would only cover the bootstrap script, not the
|
|
## uv binary it goes on to fetch.
|
|
##
|
|
## Opening the docs keeps the discovery value of the button (the user still
|
|
## learns uv is missing and how to get it) while leaving the decision to
|
|
## install — and the choice of install method — with the user. Mirrors the
|
|
## dock's existing "Run this manually" fallback for client CLIs.
|
|
func _on_install_uv() -> void:
|
|
OS.shell_open(UV_INSTALL_DOCS_URL)
|
|
## Drop the cached uvx path AND the cached `uvx --version` so that once
|
|
## the user has installed uv (in a terminal, from the docs we just
|
|
## opened), the dock finds the new binary instead of replaying the
|
|
## cached "not found" result for the rest of the session.
|
|
## Routing through the configurator 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_uv_detection()
|
|
## Deliberately do NOT refresh here. `OS.shell_open` returns as soon as
|
|
## the browser is handed the URL, so an immediate refresh would run long
|
|
## before the user could install anything and would simply re-cache
|
|
## "not found" — undoing the invalidation above. (The old shell-out was
|
|
## a blocking `OS.execute`, so refreshing straight after it was correct
|
|
## then; it stopped being correct when the installer call went away.)
|
|
## Re-probe when the editor regains focus instead — see _notification.
|
|
_uv_recheck_pending = true
|
|
|
|
|
|
# --- Client section ---
|
|
|
|
func _on_configure_client(client_id: String) -> void:
|
|
## Configure writes an explicit url + live plugin version; it does not
|
|
## need a healthy occupant. INCOMPATIBLE only suppresses status
|
|
## interpretation (#916).
|
|
_dispatch_client_action(client_id, "configure")
|
|
|
|
|
|
func _on_client_action_button_pressed(client_id: String, action: String) -> void:
|
|
if _is_self_update_in_progress():
|
|
return
|
|
if _client_work_snapshot.get("action_phases", {}).get(client_id, "") == "queued":
|
|
client_action_cancel_requested.emit(client_id)
|
|
return
|
|
_dispatch_client_action(client_id, action)
|
|
|
|
|
|
## Emit a value intent; plugin.gd routes it to the plugin-lifetime job owner.
|
|
## The Dock updates only view-local button state and never holds a Thread.
|
|
func _dispatch_client_action(client_id: String, action: String) -> void:
|
|
if _is_self_update_in_progress():
|
|
return
|
|
if _busy_client_actions().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)
|
|
client_action_requested.emit(client_id, action)
|
|
|
|
|
|
func present_client_action_result(
|
|
client_id: String,
|
|
action: String,
|
|
result: Dictionary,
|
|
prewarm: Dictionary = {},
|
|
) -> void:
|
|
_report_prewarm_outcome(client_id, prewarm)
|
|
_finalize_action_buttons(client_id)
|
|
if result.get("status") == "cancelled":
|
|
var row: Dictionary = _client_rows.get(client_id, {})
|
|
if not row.is_empty():
|
|
_apply_row_status(client_id, row.get("status", Client.Status.NOT_CONFIGURED))
|
|
_refresh_clients_summary()
|
|
return
|
|
var success_status := Client.Status.NOT_CONFIGURED if action == "remove" else Client.Status.CONFIGURED
|
|
if result.get("status") == "ok":
|
|
## #877: Remove targets only the selected scope, so a configure is the
|
|
## only action with an all-scope sweep to disclose. The manual panel
|
|
## that lists those removes is shown on the failure path below, which
|
|
## left the success path — where the sweep actually ran — silent.
|
|
var sweep_note := (
|
|
ClientConfigurator.configure_sweep_note(client_id) if action == "configure" else ""
|
|
)
|
|
_apply_row_status(client_id, success_status, sweep_note)
|
|
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()
|
|
|
|
|
|
func present_client_action_timeout(client_id: String, _action: String, detail: String) -> void:
|
|
_apply_row_status(client_id, Client.Status.ERROR, detail)
|
|
_refresh_clients_summary()
|
|
|
|
|
|
func present_client_work_snapshot(snapshot: Dictionary) -> void:
|
|
_client_work_snapshot = snapshot.duplicate(true)
|
|
var busy := _busy_client_actions()
|
|
var names: Dictionary = _client_work_snapshot.get("action_names", {})
|
|
var phases: Dictionary = _client_work_snapshot.get("action_phases", {})
|
|
for client_id in _client_rows:
|
|
var id := String(client_id)
|
|
if busy.has(id):
|
|
_set_row_action_in_flight(id, String(names.get(id, "configure")))
|
|
if String(phases.get(id, "")) == "queued":
|
|
var button := "remove_btn" if String(names.get(id, "configure")) == "remove" else "configure_btn"
|
|
(_client_rows[id][button] as Button).text = "Cancel queued"
|
|
(_client_rows[id][button] as Button).disabled = false
|
|
elif String(phases.get(id, "")) == "prewarm":
|
|
(_client_rows[id]["configure_btn"] as Button).text = "Installing…"
|
|
else:
|
|
_finalize_action_buttons(id)
|
|
_refresh_clients_summary()
|
|
|
|
|
|
func _busy_client_actions() -> Array[String]:
|
|
var result: Array[String] = []
|
|
result.assign(_client_work_snapshot.get("busy_actions", []))
|
|
return result
|
|
|
|
|
|
## One line per Configure so a cold build is attributable after the fact —
|
|
## the dock label is transient, and #851's symptom (a terminal window on
|
|
## Windows) is easiest to correlate against a timestamped log entry. Silent
|
|
## on the skip paths: a dev-venv/system tier having no env to warm is the
|
|
## normal case, not news.
|
|
func _report_prewarm_outcome(client_id: String, prewarm: Variant) -> void:
|
|
if not (prewarm is Dictionary):
|
|
return
|
|
var data := prewarm as Dictionary
|
|
if data.is_empty() or bool(data.get("skipped", false)):
|
|
return
|
|
if bool(data.get("timed_out", false)):
|
|
print(
|
|
"MCP | %s: package pre-warm timed out; the first client launch will build the environment"
|
|
% client_id
|
|
)
|
|
return
|
|
if int(data.get("exit_code", -1)) != 0:
|
|
print(
|
|
"MCP | %s: package pre-warm failed (exit %d); the first client launch will build the environment"
|
|
% [client_id, int(data.get("exit_code", -1))]
|
|
)
|
|
return
|
|
print("MCP | %s: package environment pre-warmed" % client_id)
|
|
|
|
|
|
## 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.
|
|
## Restore labels from cached row status as well as enabling the controls.
|
|
## This matters when a timed-out orphan finishes: the owner publishes a new
|
|
## snapshot but has no action result to repaint the row for us.
|
|
func _finalize_action_buttons(client_id: String) -> void:
|
|
var row: Dictionary = _client_rows.get(client_id, {})
|
|
if row.is_empty():
|
|
return
|
|
var configure_btn := row["configure_btn"] as Button
|
|
configure_btn.disabled = false
|
|
match row.get("status", Client.Status.NOT_CONFIGURED):
|
|
Client.Status.CONFIGURED, Client.Status.CONFIGURED_MISMATCH:
|
|
configure_btn.text = "Reconfigure"
|
|
Client.Status.NOT_CONFIGURED:
|
|
configure_btn.text = "Configure"
|
|
_:
|
|
configure_btn.text = "Retry"
|
|
var remove_btn: Button = row["remove_btn"]
|
|
remove_btn.disabled = false
|
|
remove_btn.text = "Remove"
|
|
|
|
|
|
func _on_refresh_clients_pressed() -> void:
|
|
## Explicit user action — also give a failed uv probe another chance
|
|
## (#739), mirroring how the same click already re-sweeps client CLIs.
|
|
_schedule_uv_reprobe()
|
|
_request_client_status_refresh(true)
|
|
|
|
|
|
func _on_configure_all_clients() -> void:
|
|
## Per-row Configure already bypasses the RUNNING gate. INCOMPATIBLE
|
|
## skips health interpretation (#916), so Configure all must do the same
|
|
## even if a status sweep is still in flight — `_set_incompatible_server`
|
|
## does not reset `_refresh_state`.
|
|
if (
|
|
ClientRefreshStateScript.should_disable_client_actions(_client_refresh_state())
|
|
and not _server_blocks_client_health()
|
|
):
|
|
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()
|
|
if vision_routing != null:
|
|
vision_routing.refresh_ui()
|
|
# 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
|
|
or _allow_hosts_is_dirty()
|
|
)
|
|
|
|
|
|
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 = "Tools"
|
|
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() + ToolCatalog.ALWAYS_ON_TOOLS.size())
|
|
core_count.add_theme_color_override("font_color", COLOR_MUTED)
|
|
core_row.add_child(core_count)
|
|
core_row.tooltip_text = "%s · always on: %s" % [
|
|
", ".join(ToolCatalog.CORE_TOOLS),
|
|
", ".join(ToolCatalog.ALWAYS_ON_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)
|
|
|
|
## Custom (addon-registered) tools — unlike the domain rows above,
|
|
## toggles apply LIVE: the registry re-pushes the filtered catalog to
|
|
## the server on every change, no restart needed.
|
|
grid.add_child(HSeparator.new())
|
|
var custom_header_row := HBoxContainer.new()
|
|
custom_header_row.add_theme_constant_override("separation", 8)
|
|
var custom_header := Label.new()
|
|
custom_header.text = "Custom tools (addon-registered)"
|
|
custom_header.size_flags_horizontal = Control.SIZE_EXPAND_FILL
|
|
custom_header_row.add_child(custom_header)
|
|
_custom_tools_count_label = Label.new()
|
|
_custom_tools_count_label.add_theme_color_override("font_color", COLOR_MUTED)
|
|
custom_header_row.add_child(_custom_tools_count_label)
|
|
grid.add_child(custom_header_row)
|
|
var custom_hint := Label.new()
|
|
custom_hint.text = "Applies immediately — disabled tools are hidden from agents and rejected if called."
|
|
custom_hint.autowrap_mode = TextServer.AUTOWRAP_WORD_SMART
|
|
custom_hint.add_theme_color_override("font_color", COLOR_MUTED)
|
|
custom_hint.size_flags_horizontal = Control.SIZE_EXPAND_FILL
|
|
grid.add_child(custom_hint)
|
|
_custom_tools_list = VBoxContainer.new()
|
|
_custom_tools_list.add_theme_constant_override("separation", 4)
|
|
_custom_tools_list.size_flags_horizontal = Control.SIZE_EXPAND_FILL
|
|
grid.add_child(_custom_tools_list)
|
|
_connect_custom_tool_registry()
|
|
_refresh_custom_tools_rows()
|
|
|
|
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 and 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?"
|
|
## Generic wording: _settings_are_dirty() covers domain toggles, the
|
|
## telemetry switch, AND the Settings tab's allow-host field (#507) —
|
|
## the old "checked/unchecked domains" text misled non-domain edits.
|
|
_tools_close_confirm.dialog_text = (
|
|
"You have unapplied changes in this window.\n"
|
|
+ "Close it 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)
|
|
|
|
_update_confirm = ConfirmationDialog.new()
|
|
_update_confirm.title = "Update Godot AI?"
|
|
_update_confirm.ok_button_text = "Update plugin"
|
|
_update_confirm.cancel_button_text = "Later"
|
|
_update_confirm.confirmed.connect(_on_update_confirmed)
|
|
add_child(_update_confirm)
|
|
|
|
_reset_tools_pending_from_setting()
|
|
_refresh_tools_ui_state()
|
|
|
|
|
|
## --- Custom (addon-registered) tools section ---
|
|
|
|
func _connect_custom_tool_registry() -> void:
|
|
var registry := McpToolRegistry.get_instance()
|
|
if registry == null:
|
|
return
|
|
if not registry.tools_changed.is_connected(_on_custom_tool_registry_changed):
|
|
registry.tools_changed.connect(_on_custom_tool_registry_changed)
|
|
|
|
|
|
func _on_custom_tool_registry_changed() -> void:
|
|
## Deferred: tools_changed can fire from inside a checkbox toggle in
|
|
## this very list — rebuilding synchronously would free the emitting
|
|
## control mid-signal.
|
|
_refresh_custom_tools_rows.call_deferred()
|
|
|
|
|
|
func _refresh_custom_tools_rows() -> void:
|
|
if _custom_tools_list == null or not is_instance_valid(_custom_tools_list):
|
|
return
|
|
for child in _custom_tools_list.get_children():
|
|
child.queue_free()
|
|
var registry := McpToolRegistry.get_instance()
|
|
var specs: Array = [] if registry == null else registry.all()
|
|
specs.sort_custom(func(a, b): return String(a.name) < String(b.name))
|
|
var enabled_count := 0
|
|
for spec in specs:
|
|
if registry.is_tool_enabled(spec.name):
|
|
enabled_count += 1
|
|
_custom_tools_list.add_child(_build_custom_tool_row(registry, spec))
|
|
if _custom_tools_count_label != null and is_instance_valid(_custom_tools_count_label):
|
|
_custom_tools_count_label.text = "%d/%d enabled" % [enabled_count, specs.size()]
|
|
if specs.is_empty():
|
|
var empty := Label.new()
|
|
empty.text = "None registered. Addons add tools via McpToolRegistry — see docs/plugin-architecture.md."
|
|
empty.add_theme_color_override("font_color", COLOR_MUTED)
|
|
empty.autowrap_mode = TextServer.AUTOWRAP_WORD_SMART
|
|
empty.size_flags_horizontal = Control.SIZE_EXPAND_FILL
|
|
_custom_tools_list.add_child(empty)
|
|
|
|
|
|
func _build_custom_tool_row(registry: McpToolRegistry, spec: McpCustomToolSpec) -> HBoxContainer:
|
|
var row := HBoxContainer.new()
|
|
row.add_theme_constant_override("separation", 8)
|
|
var chk := CheckBox.new()
|
|
chk.button_pressed = registry.is_tool_enabled(spec.name)
|
|
chk.toggled.connect(func(pressed: bool): registry.set_tool_enabled(spec.name, pressed))
|
|
row.add_child(chk)
|
|
var name_label := Label.new()
|
|
name_label.text = spec.name + (" · promoted" if spec.promoted else "")
|
|
name_label.size_flags_horizontal = Control.SIZE_EXPAND_FILL
|
|
row.add_child(name_label)
|
|
var source_label := Label.new()
|
|
source_label.text = spec.source if not spec.source.is_empty() else spec.source_path.get_base_dir().get_file()
|
|
source_label.add_theme_color_override("font_color", COLOR_MUTED)
|
|
row.add_child(source_label)
|
|
## Tooltip = the agent-facing description plus provenance, so the user
|
|
## can judge what they're enabling without leaving the dock.
|
|
row.tooltip_text = "%s\n\nsource: %s%s" % [
|
|
spec.description,
|
|
spec.source_path,
|
|
"\npromoted: registers as first-class MCP tool custom_%s" % spec.name if spec.promoted else "",
|
|
]
|
|
name_label.tooltip_text = row.tooltip_text
|
|
return row
|
|
|
|
|
|
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 dropped from the display here (only ids with a checkbox
|
|
## survive). The startup path is protected separately:
|
|
## `ClientConfigurator.excluded_domains()` filters unknown names before
|
|
## they reach `--exclude-domains`, whose `parse_exclude_list` hard-fails
|
|
## on them.
|
|
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()
|
|
## And the Settings tab's allow-host field (#507) — same window-open /
|
|
## discard-confirm re-sync contract as the tools checkboxes.
|
|
_reset_allow_hosts_from_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)
|
|
_tools_saved_excluded = _tools_pending_excluded.duplicate()
|
|
_telemetry_saved_enabled = _telemetry_pending_enabled
|
|
_refresh_tools_ui_state()
|
|
settings_apply_requested.emit({
|
|
"excluded_domains": canonical_excluded,
|
|
"telemetry_enabled": _telemetry_pending_enabled,
|
|
}.duplicate(true), true)
|
|
|
|
|
|
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()
|
|
|
|
|
|
# --- Settings tab (allow-host LAN opt-in, #507) ---
|
|
|
|
func _build_settings_tab(tabs: TabContainer) -> void:
|
|
## Tab 3 — settings-style controls that don't fit Clients or Tools: the
|
|
## Vision Routing section plus the `--allow-host` LAN opt-in behind a
|
|
## collapsed "Remote access (advanced)" disclosure, so its security
|
|
## warning renders exactly at the point of configuration. Rendered once
|
|
## on dock construction, mirroring `_build_tools_tab`;
|
|
## `_reset_allow_hosts_from_setting()` and `vision_routing.refresh_ui()`
|
|
## re-sync each time the window opens (via
|
|
## `_reset_tools_pending_from_setting` / `_on_open_clients_window`).
|
|
var settings_tab := VBoxContainer.new()
|
|
settings_tab.add_theme_constant_override("separation", 8)
|
|
settings_tab.size_flags_horizontal = Control.SIZE_EXPAND_FILL
|
|
## The section stack below (Vision Routing + Remote access) can outgrow
|
|
## the window's minimum height, especially at larger editor scales —
|
|
## scroll instead of growing the window, same idiom as the Clients and
|
|
## Tools tabs (#1090).
|
|
var settings_scroll := ScrollContainer.new()
|
|
settings_scroll.size_flags_horizontal = Control.SIZE_EXPAND_FILL
|
|
settings_scroll.size_flags_vertical = Control.SIZE_EXPAND_FILL
|
|
settings_scroll.horizontal_scroll_mode = ScrollContainer.SCROLL_MODE_DISABLED
|
|
settings_scroll.add_child(settings_tab)
|
|
var settings_margin := _build_margin_container()
|
|
settings_margin.name = "Settings"
|
|
settings_margin.add_child(settings_scroll)
|
|
tabs.add_child(settings_margin)
|
|
|
|
## Vision Routing is configuration, not status — it lives here rather
|
|
## than in the dock. Not dev-gated: it is the Settings tab's primary
|
|
## content and must work for every user.
|
|
if vision_routing != null:
|
|
vision_routing.build_section(settings_tab)
|
|
|
|
## Remote access (advanced): collapsed by default; auto-expands when a
|
|
## non-empty CIDR allowlist is already configured so an active
|
|
## off-loopback bind is never hidden behind a collapsed header. The
|
|
## disclosure replaces the former developer-mode gate for this block.
|
|
_allow_hosts_fold = FoldableContainer.new()
|
|
_allow_hosts_fold.title = "Remote access (advanced)"
|
|
_allow_hosts_fold.folded = true
|
|
settings_tab.add_child(_allow_hosts_fold)
|
|
|
|
_allow_hosts_section = VBoxContainer.new()
|
|
_allow_hosts_section.add_theme_constant_override("separation", 6)
|
|
_allow_hosts_fold.add_child(_allow_hosts_section)
|
|
|
|
_allow_hosts_section.add_child(_make_header("Allow remote hosts (CIDR)"))
|
|
|
|
var intro := Label.new()
|
|
intro.text = (
|
|
"Comma-separated CIDRs or bare IPs (e.g. 192.168.1.0/24, 10.0.0.5). "
|
|
+ "When non-empty, the server binds off loopback and accepts MCP "
|
|
+ "connections from these ranges (--allow-host)."
|
|
)
|
|
intro.autowrap_mode = TextServer.AUTOWRAP_WORD_SMART
|
|
intro.add_theme_color_override("font_color", COLOR_MUTED)
|
|
intro.size_flags_horizontal = Control.SIZE_EXPAND_FILL
|
|
_allow_hosts_section.add_child(intro)
|
|
|
|
## Warning banner — the DNS-rebinding guard is widened to every machine
|
|
## in the named ranges, so make the user name a network they trust
|
|
## instead of offering a blanket "expose everything" toggle (#507).
|
|
var warning := Label.new()
|
|
warning.text = (
|
|
"Warning: every machine in these ranges can drive this Godot editor, "
|
|
+ "and the DNS-rebinding guard's Host allowlist is widened to match. "
|
|
+ "Only name networks you trust. On untrusted or shared networks, "
|
|
+ "prefer an SSH tunnel or Tailscale instead of exposing the port."
|
|
)
|
|
warning.autowrap_mode = TextServer.AUTOWRAP_WORD_SMART
|
|
warning.add_theme_color_override("font_color", COLOR_AMBER)
|
|
warning.size_flags_horizontal = Control.SIZE_EXPAND_FILL
|
|
_allow_hosts_section.add_child(warning)
|
|
|
|
_allow_hosts_edit = LineEdit.new()
|
|
_allow_hosts_edit.placeholder_text = "e.g. 192.168.1.0/24, 10.0.0.5 — empty = loopback only"
|
|
_allow_hosts_edit.size_flags_horizontal = Control.SIZE_EXPAND_FILL
|
|
_allow_hosts_edit.text_changed.connect(_on_allow_hosts_text_changed)
|
|
_allow_hosts_section.add_child(_allow_hosts_edit)
|
|
|
|
_allow_hosts_hint = Label.new()
|
|
_allow_hosts_hint.autowrap_mode = TextServer.AUTOWRAP_WORD_SMART
|
|
_allow_hosts_hint.add_theme_color_override("font_color", Color.RED)
|
|
_allow_hosts_hint.size_flags_horizontal = Control.SIZE_EXPAND_FILL
|
|
_allow_hosts_hint.visible = false
|
|
_allow_hosts_section.add_child(_allow_hosts_hint)
|
|
|
|
_allow_hosts_apply_btn = Button.new()
|
|
_allow_hosts_apply_btn.text = "Apply and Restart Server"
|
|
_allow_hosts_apply_btn.tooltip_text = (
|
|
"Save the allowlist to Editor Settings and reload the plugin so the "
|
|
+ "server respawns with --allow-host. Clear the field and Apply to "
|
|
+ "return to loopback-only."
|
|
)
|
|
_allow_hosts_apply_btn.size_flags_horizontal = Control.SIZE_EXPAND_FILL
|
|
_allow_hosts_apply_btn.pressed.connect(_on_allow_hosts_apply)
|
|
_allow_hosts_section.add_child(_allow_hosts_apply_btn)
|
|
|
|
_reset_allow_hosts_from_setting()
|
|
|
|
|
|
func _reset_allow_hosts_from_setting() -> void:
|
|
_allow_hosts_saved = ClientConfigurator.allow_hosts()
|
|
_refresh_allow_hosts_fold_state()
|
|
if _allow_hosts_edit == null:
|
|
return
|
|
_allow_hosts_edit.text = _allow_hosts_saved
|
|
_refresh_allow_hosts_ui_state()
|
|
|
|
|
|
## Auto-expands the "Remote access (advanced)" disclosure whenever a
|
|
## non-empty allowlist is configured, so an active off-loopback bind is
|
|
## never hidden behind a collapsed header.
|
|
func _refresh_allow_hosts_fold_state() -> void:
|
|
if _allow_hosts_fold == null:
|
|
return
|
|
if ClientConfigurator.allow_hosts().is_empty():
|
|
_allow_hosts_fold.fold()
|
|
else:
|
|
_allow_hosts_fold.expand()
|
|
|
|
|
|
func _allow_hosts_is_dirty() -> bool:
|
|
if _allow_hosts_edit == null:
|
|
return false
|
|
return McpAllowHosts.normalize(_allow_hosts_edit.text) != _allow_hosts_saved
|
|
|
|
|
|
func _on_allow_hosts_text_changed(_new_text: String) -> void:
|
|
_refresh_allow_hosts_ui_state()
|
|
|
|
|
|
func _refresh_allow_hosts_ui_state() -> void:
|
|
if _allow_hosts_edit == null or _allow_hosts_apply_btn == null:
|
|
return
|
|
var error := McpAllowHosts.configuration_error(_allow_hosts_edit.text)
|
|
_allow_hosts_hint.text = error
|
|
_allow_hosts_hint.visible = not error.is_empty()
|
|
_allow_hosts_apply_btn.disabled = not _allow_hosts_is_dirty() or not error.is_empty()
|
|
|
|
|
|
func _on_allow_hosts_apply() -> void:
|
|
if _allow_hosts_edit == null:
|
|
return
|
|
var normalized := McpAllowHosts.normalize(_allow_hosts_edit.text)
|
|
if not McpAllowHosts.configuration_error(normalized).is_empty():
|
|
return
|
|
_allow_hosts_saved = normalized
|
|
_allow_hosts_edit.text = normalized
|
|
_refresh_allow_hosts_ui_state()
|
|
settings_apply_requested.emit({"allow_hosts": normalized}, true)
|
|
|
|
|
|
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()
|
|
var refresh_state := _client_refresh_state()
|
|
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.should_disable_client_actions(refresh_state)
|
|
and not _server_blocks_client_health()
|
|
)
|
|
if _client_empty_cta_btn != null:
|
|
_client_empty_cta_btn.visible = configured == 0 and _client_status_refresh_has_completed()
|
|
_refresh_drift_banner(mismatched_ids)
|
|
_update_status()
|
|
|
|
|
|
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
|
|
## #680: for rows low in the list the panel materializes below the
|
|
## visible scroll area and the Configure click looks like a no-op.
|
|
## Deferred so the just-shown panel has a settled rect to scroll to.
|
|
_scroll_manual_panel_into_view.call_deferred(row["manual_panel"])
|
|
|
|
|
|
func _scroll_manual_panel_into_view(panel: Control) -> void:
|
|
if panel == null or not panel.is_inside_tree():
|
|
return
|
|
var ancestor := panel.get_parent()
|
|
while ancestor != null and not (ancestor is ScrollContainer):
|
|
ancestor = ancestor.get_parent()
|
|
if ancestor != null:
|
|
(ancestor as ScrollContainer).ensure_control_visible(panel)
|
|
|
|
|
|
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 _on_open_config_file(client_id: String) -> void:
|
|
var path := _client_config_path_for_row(client_id)
|
|
if path.is_empty():
|
|
return
|
|
if FileAccess.file_exists(path):
|
|
OS.shell_open(path)
|
|
return
|
|
_reveal_config_folder(path)
|
|
|
|
|
|
func _on_reveal_config_folder(client_id: String) -> void:
|
|
var path := _client_config_path_for_row(client_id)
|
|
if path.is_empty():
|
|
return
|
|
_reveal_config_folder(path)
|
|
|
|
|
|
func _client_config_path_for_row(client_id: String) -> String:
|
|
var row: Dictionary = _client_rows.get(client_id, {})
|
|
if row.is_empty():
|
|
return ""
|
|
return String(row.get("config_path", ""))
|
|
|
|
|
|
func _reveal_config_folder(path: String) -> void:
|
|
var dir := path.get_base_dir()
|
|
if dir.is_empty():
|
|
return
|
|
OS.shell_open(dir)
|
|
|
|
|
|
func _refresh_all_client_statuses() -> void:
|
|
_request_client_status_refresh(true)
|
|
|
|
|
|
func _perform_initial_client_status_refresh() -> void:
|
|
if not is_inside_tree():
|
|
return
|
|
_request_client_status_refresh(false)
|
|
|
|
|
|
func _request_client_status_refresh(force: bool = false) -> bool:
|
|
if _server_blocks_client_health():
|
|
return false
|
|
if _is_self_update_in_progress() or _client_rows.is_empty():
|
|
return false
|
|
var ids: Array[String] = []
|
|
for client_id in _client_rows:
|
|
ids.append(String(client_id))
|
|
client_status_refresh_requested.emit(ids, force)
|
|
return true
|
|
|
|
|
|
func present_client_status_refresh_results(results: Dictionary) -> void:
|
|
if _server_blocks_client_health():
|
|
return
|
|
var busy := _busy_client_actions()
|
|
for client_id in results:
|
|
if busy.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)
|
|
)
|
|
_refresh_clients_summary()
|
|
|
|
|
|
func _server_blocks_client_health() -> bool:
|
|
return ServerStateScript.blocks_client_health(
|
|
int(_lifecycle_snapshot.get("state", ServerStateScript.UNINITIALIZED))
|
|
)
|
|
|
|
|
|
func _server_blocked_client_message() -> String:
|
|
var message := str(_lifecycle_snapshot.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)
|
|
_refresh_client_config_file_buttons(client_id)
|
|
match status:
|
|
Client.Status.CONFIGURED:
|
|
dot.color = Color.GREEN
|
|
configure_btn.text = "Reconfigure"
|
|
remove_btn.visible = true
|
|
## `error_msg` doubles as a detail slot on the green path: a
|
|
## successful configure passes the sweep note (#877). Transient by
|
|
## design — the next status refresh re-applies CONFIGURED with no
|
|
## detail, so the row settles back to its plain name.
|
|
name_label.text = (
|
|
"%s (%s)" % [base_name, error_msg] if not error_msg.is_empty() else 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
|
|
## Drift is usually a stale URL, but a `{scope}` client can also be
|
|
## registered in a scope the user did not select (#872), and
|
|
## "URL out of date" would be a wrong description of that. Prefer
|
|
## the probe's own words whenever it supplied any.
|
|
name_label.text = (
|
|
"%s (%s)" % [base_name, error_msg]
|
|
if not error_msg.is_empty()
|
|
else "%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
|
|
|
|
|
|
func _refresh_client_config_file_buttons(client_id: String) -> void:
|
|
var row: Dictionary = _client_rows.get(client_id, {})
|
|
if row.is_empty():
|
|
return
|
|
# Re-resolve on every refresh so the Open/Reveal buttons follow the entry
|
|
# when it lands in (or moves to) a higher-precedence merge tier (codex F3).
|
|
# F-3-4: switched from `effective_config_path` to the authoritative facade
|
|
# so multi-project-tier cases resolve to the LATEST tier (matching F2
|
|
# status semantics) instead of failing closed to `path_template`.
|
|
row["config_path"] = ClientConfigurator.effective_authoritative_path(client_id)
|
|
var config_path := String(row["config_path"])
|
|
var has_path := not config_path.is_empty()
|
|
var open_config_btn: Button = row["open_config_btn"]
|
|
var reveal_btn: Button = row["reveal_btn"]
|
|
open_config_btn.visible = has_path
|
|
reveal_btn.visible = has_path
|
|
open_config_btn.disabled = not has_path
|
|
reveal_btn.disabled = not has_path
|
|
if has_path:
|
|
open_config_btn.tooltip_text = "Open config file:\n%s" % config_path
|
|
reveal_btn.tooltip_text = "Reveal in folder:\n%s" % config_path.get_base_dir()
|
|
else:
|
|
open_config_btn.tooltip_text = ""
|
|
reveal_btn.tooltip_text = ""
|
|
|
|
|
|
# --- 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_install_in_flight
|
|
|
|
|
|
func _on_update_pressed() -> void:
|
|
if not _post_update_action.is_empty():
|
|
post_update_action_requested.emit(_post_update_action)
|
|
return
|
|
## Updating briefly disconnects AI tools while the add-on is replaced.
|
|
## A dock outside the scene tree has no dialog and proceeds directly.
|
|
if _update_confirm != null and is_inside_tree():
|
|
_update_confirm.dialog_text = update_confirm_text(
|
|
_update_candidate_version, ClientConfigurator.get_plugin_version()
|
|
)
|
|
_update_confirm.popup_centered()
|
|
return
|
|
update_requested.emit()
|
|
|
|
|
|
func _on_update_confirmed() -> void:
|
|
update_requested.emit()
|
|
|
|
|
|
static func update_confirm_text(version: String, current_version: String) -> String:
|
|
var target := "Godot AI v%s" % version if not version.is_empty() else "the new Godot AI"
|
|
var text := "Update to %s? Unsaved changes are kept." % target
|
|
if McpServerVersionCheck.attached_bridges_follow(current_version, version):
|
|
text += "\n\nRestart AI clients older than v4.0.4."
|
|
else:
|
|
text += "\n\nRestart your AI client after updating."
|
|
return text
|
|
|
|
|
|
func present_update_check(result: Dictionary) -> void:
|
|
_update_candidate_version = String(result.get("version", ""))
|
|
_update_label.text = String(result.get("label_text", ""))
|
|
_update_label.add_theme_color_override("font_color", _UPDATE_LABEL_COLOR)
|
|
_update_banner.visible = true
|
|
## A fresh candidate re-arms the action. The restarted editor after an
|
|
## update shows "Godot AI installed" with the button disabled; a newer release
|
|
## found later in that same session must still be installable. A running
|
|
## install or a pending post-update action keeps ownership of the button.
|
|
if _update_install_in_flight or not _post_update_action.is_empty():
|
|
return
|
|
_set_update_status("")
|
|
if _update_btn != null:
|
|
_update_btn.text = _UPDATE_ACTION_TEXT
|
|
_update_btn.disabled = false
|
|
|
|
|
|
## Apply only the keys present so the manager can ship partial updates
|
|
## (e.g. status-only during the download phase) without clobbering banner
|
|
## state. `button_text` names an action ("Retry client migration");
|
|
## progress and failure messages arrive as `status_text` and never replace
|
|
## the button label.
|
|
func present_update_state(state: Dictionary) -> void:
|
|
if state.has("post_update_action"):
|
|
_post_update_action = String(state["post_update_action"])
|
|
if _post_update_action.is_empty() and _update_btn != null:
|
|
_update_btn.text = _UPDATE_ACTION_TEXT
|
|
if state.has("install_in_flight"):
|
|
_update_install_in_flight = bool(state["install_in_flight"])
|
|
if String(state.get("post_update_action", "")) == "retry":
|
|
## The migration barrier refused: the connection really is blocked.
|
|
_post_update_server_pending = false
|
|
elif bool(state.get("install_in_flight", false)) or String(state.get("outcome", "")) == "success":
|
|
_post_update_server_pending = true
|
|
if _status_label != null:
|
|
_update_status()
|
|
elif state.has("install_in_flight"):
|
|
## The install ended without a swap (`_fail_update`): the previous
|
|
## version is live and the transport status is the truth again.
|
|
_post_update_server_pending = false
|
|
if _status_label != null:
|
|
_update_status()
|
|
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("status_text"):
|
|
_set_update_status(String(state["status_text"]))
|
|
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 state.has("label_text") and _update_label != null:
|
|
## Installation is distinct from server/client readiness. Instructions
|
|
## can still name failed migrations or clients that must reconnect.
|
|
_update_label.add_theme_color_override("font_color", _UPDATE_LABEL_COLOR)
|
|
|
|
|
|
func _set_update_status(text: String) -> void:
|
|
if _update_status_label == null:
|
|
return
|
|
_update_status_label.text = text
|
|
_update_status_label.visible = not text.is_empty()
|