feat(gui): webview devtools open/close MCP tools

display.go: menu builder now consults menu.Service.ShowDevTools()
and, when enabled, adds Developer > Open DevTools + Close DevTools
menu items. Handlers route to the focused window (or the only open
window) via the existing webview.devtoolsOpen /
webview.devtoolsClose actions — which already land on Wails
WebviewWindow.OpenDevTools / CloseDevTools.

Tests: Good/Bad/Ugly coverage for both MCP tool handlers in
tools_webview_test.go plus a menu-level test in display_test.go
that clicks the built items and verifies mock devtools state flips
open/closed, with a disabled-config case.

tools_webview.go already had the handlers + registration on this
branch; locked down with tests instead of re-implementing.

Closes tasks.lthn.sh/view.php?id=31

Co-authored-by: Codex <noreply@openai.com>
Co-Authored-By: Virgil <virgil@lethean.io>
This commit is contained in:
Snider 2026-04-24 06:08:17 +01:00
parent fa4168e380
commit dfff893f16
3 changed files with 316 additions and 11 deletions

View file

@ -1790,6 +1790,25 @@ func (s *Service) GetEventManager() *WSEventManager {
// --- Menu (handlers stay in display, structure delegated via IPC) ---
func (s *Service) buildMenu() {
developerItems := []menu.MenuItem{
{Label: "New File", Accelerator: "CmdOrCtrl+N", OnClick: s.handleNewFile},
{Label: "Open File...", Accelerator: "CmdOrCtrl+O", OnClick: s.handleOpenFile},
{Label: "Save", Accelerator: "CmdOrCtrl+S", OnClick: s.handleSaveFile},
{Type: "separator"},
{Label: "Editor", OnClick: s.handleOpenEditor},
{Label: "Terminal", OnClick: s.handleOpenTerminal},
{Type: "separator"},
{Label: "Run", Accelerator: "CmdOrCtrl+R", OnClick: s.handleRun},
{Label: "Build", Accelerator: "CmdOrCtrl+B", OnClick: s.handleBuild},
}
if menuService, ok := core.ServiceFor[*menu.Service](s.Core(), "menu"); ok && menuService.ShowDevTools() {
developerItems = append(developerItems,
menu.MenuItem{Type: "separator"},
menu.MenuItem{Label: "Open DevTools", OnClick: s.handleOpenDevTools},
menu.MenuItem{Label: "Close DevTools", OnClick: s.handleCloseDevTools},
)
}
items := []menu.MenuItem{
{Role: pointerTo(menu.RoleAppMenu)},
{Role: pointerTo(menu.RoleFileMenu)},
@ -1799,17 +1818,7 @@ func (s *Service) buildMenu() {
{Label: "New...", OnClick: s.handleNewWorkspace},
{Label: "List", OnClick: s.handleListWorkspaces},
}},
{Label: "Developer", Children: []menu.MenuItem{
{Label: "New File", Accelerator: "CmdOrCtrl+N", OnClick: s.handleNewFile},
{Label: "Open File...", Accelerator: "CmdOrCtrl+O", OnClick: s.handleOpenFile},
{Label: "Save", Accelerator: "CmdOrCtrl+S", OnClick: s.handleSaveFile},
{Type: "separator"},
{Label: "Editor", OnClick: s.handleOpenEditor},
{Label: "Terminal", OnClick: s.handleOpenTerminal},
{Type: "separator"},
{Label: "Run", Accelerator: "CmdOrCtrl+R", OnClick: s.handleRun},
{Label: "Build", Accelerator: "CmdOrCtrl+B", OnClick: s.handleBuild},
}},
{Label: "Developer", Children: developerItems},
{Role: pointerTo(menu.RoleWindowMenu)},
{Role: pointerTo(menu.RoleHelpMenu)},
}
@ -1829,6 +1838,37 @@ func pointerTo[T any](value T) *T { return &value }
// --- Menu handler methods ---
func (s *Service) menuDevToolsWindow() string {
if name := s.GetFocusedWindow(); name != "" {
return name
}
infos := s.ListWindowInfos()
if len(infos) == 1 {
return infos[0].Name
}
return ""
}
func (s *Service) handleOpenDevTools() {
windowName := s.menuDevToolsWindow()
if windowName == "" {
return
}
_ = s.Core().Action("webview.devtoolsOpen").Run(context.Background(), core.NewOptions(
core.Option{Key: "task", Value: webview.TaskDevToolsOpen{Window: windowName}},
))
}
func (s *Service) handleCloseDevTools() {
windowName := s.menuDevToolsWindow()
if windowName == "" {
return
}
_ = s.Core().Action("webview.devtoolsClose").Run(context.Background(), core.NewOptions(
core.Option{Key: "task", Value: webview.TaskDevToolsClose{Window: windowName}},
))
}
func (s *Service) handleNewWorkspace() {
_ = s.Core().Action("window.open").Run(context.Background(), core.NewOptions(
core.Option{Key: "task", Value: window.TaskOpenWindow{

View file

@ -14,6 +14,7 @@ import (
core "dappco.re/go/core"
"forge.lthn.ai/core/gui/pkg/menu"
"forge.lthn.ai/core/gui/pkg/systray"
"forge.lthn.ai/core/gui/pkg/webview"
"forge.lthn.ai/core/gui/pkg/window"
"github.com/gorilla/websocket"
"github.com/stretchr/testify/assert"
@ -54,6 +55,144 @@ func taskRun(c *core.Core, name string, task any) core.Result {
))
}
func registerDisplayWithConfigPath(path string) func(*core.Core) core.Result {
return func(c *core.Core) core.Result {
result := Register(nil)(c)
if !result.OK {
return result
}
svc := core.MustServiceFor[*Service](c, "display")
svc.loadConfigFrom(path)
return result
}
}
func writeMenuConfig(t *testing.T, showDevTools bool) string {
t.Helper()
dir := t.TempDir()
cfgPath := core.JoinPath(dir, ".core", "gui", "config.yaml")
require.NoError(t, os.MkdirAll(core.PathDir(cfgPath), 0o755))
require.NoError(t, os.WriteFile(cfgPath, []byte(`
menu:
show_dev_tools: `+map[bool]string{true: "true", false: "false"}[showDevTools]+`
`), 0o644))
return cfgPath
}
func newDevToolsMenuConclave(t *testing.T, showDevTools bool) (*core.Core, *captureMenuPlatform, *window.MockPlatform) {
t.Helper()
menuPlatform := newCaptureMenuPlatform()
windowPlatform := window.NewMockPlatform()
c := core.New(
core.WithService(registerDisplayWithConfigPath(writeMenuConfig(t, showDevTools))),
core.WithService(window.Register(windowPlatform)),
core.WithService(systray.Register(systray.NewMockPlatform())),
core.WithService(webview.Register()),
core.WithService(menu.Register(menuPlatform)),
core.WithServiceLock(),
)
require.True(t, c.ServiceStartup(context.Background(), nil).OK)
return c, menuPlatform, windowPlatform
}
type captureMenuPlatform struct {
appMenu *captureMenu
}
func newCaptureMenuPlatform() *captureMenuPlatform {
return &captureMenuPlatform{}
}
func (p *captureMenuPlatform) NewMenu() menu.PlatformMenu {
return &captureMenu{}
}
func (p *captureMenuPlatform) SetApplicationMenu(menuHandle menu.PlatformMenu) {
captured, _ := menuHandle.(*captureMenu)
p.appMenu = captured
}
type captureMenu struct {
items []*captureMenuItem
roles []menu.MenuRole
}
func (m *captureMenu) Add(label string) menu.PlatformMenuItem {
item := &captureMenuItem{label: label}
m.items = append(m.items, item)
return item
}
func (m *captureMenu) AddSeparator() {
m.items = append(m.items, &captureMenuItem{label: "---", separator: true})
}
func (m *captureMenu) AddSubmenu(label string) menu.PlatformMenu {
sub := &captureMenu{}
m.items = append(m.items, &captureMenuItem{label: label, submenu: sub})
return sub
}
func (m *captureMenu) AddRole(role menu.MenuRole) {
m.roles = append(m.roles, role)
}
func (m *captureMenu) findSubmenu(label string) *captureMenu {
for _, item := range m.items {
if item.label == label && item.submenu != nil {
return item.submenu
}
}
return nil
}
func (m *captureMenu) findItem(label string) *captureMenuItem {
for _, item := range m.items {
if item.label == label && item.submenu == nil && !item.separator {
return item
}
}
return nil
}
type captureMenuItem struct {
label string
accelerator string
tooltip string
checked bool
enabled bool
separator bool
submenu *captureMenu
onClick func()
}
func (m *captureMenuItem) SetAccelerator(accel string) menu.PlatformMenuItem {
m.accelerator = accel
return m
}
func (m *captureMenuItem) SetTooltip(text string) menu.PlatformMenuItem {
m.tooltip = text
return m
}
func (m *captureMenuItem) SetChecked(checked bool) menu.PlatformMenuItem {
m.checked = checked
return m
}
func (m *captureMenuItem) SetEnabled(enabled bool) menu.PlatformMenuItem {
m.enabled = enabled
return m
}
func (m *captureMenuItem) OnClick(fn func()) menu.PlatformMenuItem {
m.onClick = fn
return m
}
// --- Tests ---
func TestNew_Good(t *testing.T) {
@ -192,6 +331,43 @@ func TestServiceConclave_Bad(t *testing.T) {
assert.False(t, r.OK, "no display service means no config handler")
}
func TestBuildMenu_Good_ShowDevTools(t *testing.T) {
c, menuPlatform, windowPlatform := newDevToolsMenuConclave(t, true)
require.True(t, taskRun(c, "window.open", window.TaskOpenWindow{
Window: &window.Window{Name: "main"},
}).OK)
require.True(t, taskRun(c, "window.focus", window.TaskFocus{Name: "main"}).OK)
require.NotNil(t, menuPlatform.appMenu)
developer := menuPlatform.appMenu.findSubmenu("Developer")
require.NotNil(t, developer)
openItem := developer.findItem("Open DevTools")
closeItem := developer.findItem("Close DevTools")
require.NotNil(t, openItem)
require.NotNil(t, closeItem)
require.NotNil(t, openItem.onClick)
require.NotNil(t, closeItem.onClick)
require.Len(t, windowPlatform.Windows, 1)
openItem.onClick()
assert.True(t, windowPlatform.Windows[0].DevToolsOpen())
closeItem.onClick()
assert.False(t, windowPlatform.Windows[0].DevToolsOpen())
}
func TestBuildMenu_Bad_ShowDevToolsDisabled(t *testing.T) {
_, menuPlatform, _ := newDevToolsMenuConclave(t, false)
require.NotNil(t, menuPlatform.appMenu)
developer := menuPlatform.appMenu.findSubmenu("Developer")
require.NotNil(t, developer)
assert.Nil(t, developer.findItem("Open DevTools"))
assert.Nil(t, developer.findItem("Close DevTools"))
}
// --- IPC delegation tests (full conclave) ---
func TestOpenWindow_Good(t *testing.T) {

View file

@ -0,0 +1,89 @@
package mcp
import (
"context"
"errors"
"testing"
core "dappco.re/go/core"
"forge.lthn.ai/core/gui/pkg/webview"
"github.com/modelcontextprotocol/go-sdk/mcp"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
func newWebviewToolsTestSubsystem(t *testing.T, handler func(name string, opts core.Options) core.Result) *Subsystem {
t.Helper()
c := core.New(core.WithServiceLock())
c.Action("webview.devtoolsOpen", func(_ context.Context, opts core.Options) core.Result {
if handler != nil {
return handler("webview.devtoolsOpen", opts)
}
return core.Result{}
})
c.Action("webview.devtoolsClose", func(_ context.Context, opts core.Options) core.Result {
if handler != nil {
return handler("webview.devtoolsClose", opts)
}
return core.Result{}
})
return New(c)
}
func TestToolsWebview_webviewDevTools_Good(t *testing.T) {
var calls []string
sub := newWebviewToolsTestSubsystem(t, func(name string, opts core.Options) core.Result {
calls = append(calls, name)
switch task := opts.Get("task").Value.(type) {
case webview.TaskDevToolsOpen:
assert.Equal(t, "main", task.Window)
case webview.TaskDevToolsClose:
assert.Equal(t, "main", task.Window)
default:
t.Fatalf("unexpected task type %T", task)
}
return core.Result{OK: true}
})
server := mcp.NewServer(&mcp.Implementation{Name: "test", Version: "0.1.0"}, nil)
sub.registerWebviewTools(server)
result, err := sub.CallTool(context.Background(), "webview_devtools_open", map[string]any{"window": "main"})
require.NoError(t, err)
assert.Contains(t, result, "\"success\":true")
result, err = sub.CallTool(context.Background(), "webview_devtools_close", map[string]any{"window": "main"})
require.NoError(t, err)
assert.Contains(t, result, "\"success\":true")
assert.Equal(t, []string{"webview.devtoolsOpen", "webview.devtoolsClose"}, calls)
}
func TestToolsWebview_webviewDevToolsOpen_Bad(t *testing.T) {
sub := newWebviewToolsTestSubsystem(t, func(name string, opts core.Options) core.Result {
task, ok := opts.Get("task").Value.(webview.TaskDevToolsOpen)
require.True(t, ok)
assert.Equal(t, "main", task.Window)
assert.Equal(t, "webview.devtoolsOpen", name)
return core.Result{Value: errors.New("devtools unavailable"), OK: false}
})
_, _, err := sub.webviewDevToolsOpen(context.Background(), nil, WebviewDevToolsOpenInput{Window: "main"})
require.Error(t, err)
assert.Equal(t, "devtools unavailable", err.Error())
}
func TestToolsWebview_webviewDevToolsClose_Ugly(t *testing.T) {
sub := newWebviewToolsTestSubsystem(t, func(name string, opts core.Options) core.Result {
task, ok := opts.Get("task").Value.(webview.TaskDevToolsClose)
require.True(t, ok)
assert.Equal(t, "main", task.Window)
assert.Equal(t, "webview.devtoolsClose", name)
return core.Result{Value: "suppressed failure", OK: false}
})
_, out, err := sub.webviewDevToolsClose(context.Background(), nil, WebviewDevToolsCloseInput{Window: "main"})
require.NoError(t, err)
assert.False(t, out.Success)
}