Editor UI dock for the MCP (MCP dashboard) and its menu entries. Defines menu commands to open the dashboard and start/stop the MCP server, and the McpDock widget that hosts header, tab bar and pages, handles layout, resizing, active tab switching and per-frame UI updates.
using System;
using Editor;
using Sandbox;
using static Sandbox.Internal.GlobalToolsNamespace;
using SboxMcp.Integration;
namespace SboxMcp.UI;
/// <summary>
/// Top-level "MCP" menu in the editor menu bar (lands next to Help).
/// </summary>
public static class McpMenu
{
[Menu( "Editor", "MCP/Open Dashboard", "hub" )]
public static void OpenDashboard() => McpDock.Open();
[Menu( "Editor", "MCP/Start Server", "play_arrow" )]
public static void StartServer() => McpHost.Start();
[Menu( "Editor", "MCP/Stop Server", "stop" )]
public static void StopServer() => McpHost.Stop();
}
/// <summary>
/// The MCP dashboard: header with live status, tab bar, and the four pages.
/// Open it from the MCP menu in the menu bar.
/// </summary>
public class McpDock : Widget
{
static McpDock _instance;
/// <summary>The open dashboard instance, if any.</summary>
public static McpDock Instance => _instance.IsValid() ? _instance : null;
readonly HeaderBar _header;
readonly TabButton[] _tabs;
readonly Widget[] _pages;
readonly OverviewPage _overview;
readonly ActivityPage _activity;
readonly ToolsPage _tools;
int _active;
readonly RealTimeSince _sinceCreated = 0;
// Widget.MinimumWidth is a no-op for docks; Qt asks this instead
protected override Vector2 MinimumSizeHint() => new( 360, 220 );
protected override void OnResize()
{
base.OnResize();
// remember the user's size for future sessions; the settle delay keeps
// the initial open/layout resizes from clobbering the saved value
if ( _sinceCreated > 1f && Width > 100 && Height > 100 )
McpSettings.DockSize = Size;
}
/// <summary>Opens (or raises) the dashboard.</summary>
public static McpDock Open()
{
var dock = Instance;
if ( dock is null )
{
dock = new McpDock( EditorWindow );
// restore the last size the user resized it to
dock.Size = McpSettings.DockSize;
// floating by default - the user can dock it wherever they like
EditorWindow.DockManager.AddDock( null, dock, DockArea.Floating );
dock.Size = McpSettings.DockSize;
}
EditorWindow.DockManager.RaiseDock( dock );
return dock;
}
public McpDock( Widget parent ) : base( parent )
{
_instance ??= this;
Name = "McpDock";
WindowTitle = "MCP";
SetWindowIcon( "hub" );
Layout = Layout.Column();
_header = Layout.Add( new HeaderBar( this ) );
var tabRow = Layout.AddRow();
tabRow.Margin = new Sandbox.UI.Margin( 8, 4, 8, 0 );
tabRow.Spacing = 2;
_tabs = new[]
{
new TabButton( "Overview", "dashboard", this ),
new TabButton( "Activity", "bolt", this ),
new TabButton( "Tools", "construction", this ),
new TabButton( "Settings", "tune", this )
};
for ( var i = 0; i < _tabs.Length; i++ )
{
var index = i;
_tabs[i].Clicked = () => SetActive( index );
tabRow.Add( _tabs[i] );
}
tabRow.AddStretchCell();
var content = Layout.Add( new Widget( this ), 1 );
content.Layout = Layout.Column();
_overview = new OverviewPage( content );
_activity = new ActivityPage( content );
_tools = new ToolsPage( content );
var settings = new SettingsPage( content );
_pages = new Widget[] { _overview, _activity, _tools, settings };
foreach ( var page in _pages )
content.Layout.Add( page, 1 );
SetActive( 0 );
// no EditorEvent.Register(this) - QObject already registers every
// widget; doing it again would run Tick twice per frame
}
public override void OnDestroyed()
{
base.OnDestroyed();
if ( _instance == this )
_instance = null;
}
void SetActive( int index )
{
_active = index;
for ( var i = 0; i < _pages.Length; i++ )
{
_pages[i].Visible = i == index;
_tabs[i].Active = i == index;
_tabs[i].Update();
}
}
[EditorEvent.Frame]
public void Tick()
{
if ( !IsValid )
return;
var server = McpHost.Server;
var running = server?.IsRunning ?? false;
var sessions = server?.Sessions.Count ?? 0;
_header.StatusColor = running ? Palette.Running : (McpHost.LastError is null ? Palette.Stopped : Palette.Error);
_header.StatusText = !running
? (McpHost.LastError is null ? "stopped" : "error")
: sessions > 0 ? $"running · {sessions} client{(sessions == 1 ? "" : "s")}" : "running";
_header.Pulse = running ? (MathF.Sin( RealTime.Now * 3f ) + 1f) * 0.5f : 0f;
_header.Update();
// badge pending approvals on the Activity tab
var pending = PermissionGate.Pending.Count;
if ( _tabs[1].Badge != pending )
{
_tabs[1].Badge = pending;
_tabs[1].Update();
}
_overview.Tick();
_activity.Tick();
_tools.Tick();
}
}