feat(gui): add missing window mutators and MCP tools
Some checks failed
Security Scan / security (push) Failing after 35s
Test / test (push) Successful in 1m34s

Co-Authored-By: Virgil <virgil@lethean.io>
This commit is contained in:
Virgil 2026-04-02 14:03:29 +00:00
parent 57fb567a68
commit a07fa49c20
7 changed files with 237 additions and 44 deletions

View file

@ -1243,13 +1243,15 @@ func (s *Service) GetSavedWindowStates() map[string]window.WindowState {
// CreateWindowOptions contains options for creating a new window.
type CreateWindowOptions struct {
Name string `json:"name"`
Title string `json:"title,omitempty"`
URL string `json:"url,omitempty"`
X int `json:"x,omitempty"`
Y int `json:"y,omitempty"`
Width int `json:"width,omitempty"`
Height int `json:"height,omitempty"`
Name string `json:"name"`
Title string `json:"title,omitempty"`
URL string `json:"url,omitempty"`
X int `json:"x,omitempty"`
Y int `json:"y,omitempty"`
Width int `json:"width,omitempty"`
Height int `json:"height,omitempty"`
AlwaysOnTop bool `json:"alwaysOnTop,omitempty"`
BackgroundColour [4]uint8 `json:"backgroundColour,omitempty"`
}
// CreateWindow creates a new window with the specified options.
@ -1258,12 +1260,16 @@ func (s *Service) CreateWindow(opts CreateWindowOptions) (*window.WindowInfo, er
return nil, fmt.Errorf("window name is required")
}
result, _, err := s.Core().PERFORM(window.TaskOpenWindow{
Opts: []window.WindowOption{
window.WithName(opts.Name),
window.WithTitle(opts.Title),
window.WithURL(opts.URL),
window.WithSize(opts.Width, opts.Height),
window.WithPosition(opts.X, opts.Y),
Window: &window.Window{
Name: opts.Name,
Title: opts.Title,
URL: opts.URL,
X: opts.X,
Y: opts.Y,
Width: opts.Width,
Height: opts.Height,
AlwaysOnTop: opts.AlwaysOnTop,
BackgroundColour: opts.BackgroundColour,
},
})
if err != nil {

View file

@ -89,22 +89,17 @@ type WindowCreateOutput struct {
}
func (s *Subsystem) windowCreate(_ context.Context, _ *mcp.CallToolRequest, input WindowCreateInput) (*mcp.CallToolResult, WindowCreateOutput, error) {
opts := []window.WindowOption{
window.WithName(input.Name),
}
if input.Title != "" {
opts = append(opts, window.WithTitle(input.Title))
}
if input.URL != "" {
opts = append(opts, window.WithURL(input.URL))
}
if input.Width > 0 || input.Height > 0 {
opts = append(opts, window.WithSize(input.Width, input.Height))
}
if input.X != 0 || input.Y != 0 {
opts = append(opts, window.WithPosition(input.X, input.Y))
}
result, _, err := s.core.PERFORM(window.TaskOpenWindow{Opts: opts})
result, _, err := s.core.PERFORM(window.TaskOpenWindow{
Window: &window.Window{
Name: input.Name,
Title: input.Title,
URL: input.URL,
Width: input.Width,
Height: input.Height,
X: input.X,
Y: input.Y,
},
})
if err != nil {
return nil, WindowCreateOutput{}, err
}
@ -163,7 +158,7 @@ type WindowSizeOutput struct {
}
func (s *Subsystem) windowSize(_ context.Context, _ *mcp.CallToolRequest, input WindowSizeInput) (*mcp.CallToolResult, WindowSizeOutput, error) {
_, _, err := s.core.PERFORM(window.TaskSetSize{Name: input.Name, W: input.Width, H: input.Height})
_, _, err := s.core.PERFORM(window.TaskSetSize{Name: input.Name, Width: input.Width, Height: input.Height})
if err != nil {
return nil, WindowSizeOutput{}, err
}
@ -188,7 +183,7 @@ func (s *Subsystem) windowBounds(_ context.Context, _ *mcp.CallToolRequest, inpu
if err != nil {
return nil, WindowBoundsOutput{}, err
}
_, _, err = s.core.PERFORM(window.TaskSetSize{Name: input.Name, W: input.Width, H: input.Height})
_, _, err = s.core.PERFORM(window.TaskSetSize{Name: input.Name, Width: input.Width, Height: input.Height})
if err != nil {
return nil, WindowBoundsOutput{}, err
}
@ -281,6 +276,27 @@ func (s *Subsystem) windowTitle(_ context.Context, _ *mcp.CallToolRequest, input
return nil, WindowTitleOutput{Success: true}, nil
}
// --- window_title_get ---
type WindowTitleGetInput struct {
Name string `json:"name"`
}
type WindowTitleGetOutput struct {
Title string `json:"title"`
}
func (s *Subsystem) windowTitleGet(_ context.Context, _ *mcp.CallToolRequest, input WindowTitleGetInput) (*mcp.CallToolResult, WindowTitleGetOutput, error) {
result, _, err := s.core.QUERY(window.QueryWindowByName{Name: input.Name})
if err != nil {
return nil, WindowTitleGetOutput{}, err
}
info, _ := result.(*window.WindowInfo)
if info == nil {
return nil, WindowTitleGetOutput{}, nil
}
return nil, WindowTitleGetOutput{Title: info.Title}, nil
}
// --- window_visibility ---
type WindowVisibilityInput struct {
@ -299,6 +315,47 @@ func (s *Subsystem) windowVisibility(_ context.Context, _ *mcp.CallToolRequest,
return nil, WindowVisibilityOutput{Success: true}, nil
}
// --- window_always_on_top ---
type WindowAlwaysOnTopInput struct {
Name string `json:"name"`
AlwaysOnTop bool `json:"alwaysOnTop"`
}
type WindowAlwaysOnTopOutput struct {
Success bool `json:"success"`
}
func (s *Subsystem) windowAlwaysOnTop(_ context.Context, _ *mcp.CallToolRequest, input WindowAlwaysOnTopInput) (*mcp.CallToolResult, WindowAlwaysOnTopOutput, error) {
_, _, err := s.core.PERFORM(window.TaskSetAlwaysOnTop{Name: input.Name, AlwaysOnTop: input.AlwaysOnTop})
if err != nil {
return nil, WindowAlwaysOnTopOutput{}, err
}
return nil, WindowAlwaysOnTopOutput{Success: true}, nil
}
// --- window_background_colour ---
type WindowBackgroundColourInput struct {
Name string `json:"name"`
Red uint8 `json:"red"`
Green uint8 `json:"green"`
Blue uint8 `json:"blue"`
Alpha uint8 `json:"alpha"`
}
type WindowBackgroundColourOutput struct {
Success bool `json:"success"`
}
func (s *Subsystem) windowBackgroundColour(_ context.Context, _ *mcp.CallToolRequest, input WindowBackgroundColourInput) (*mcp.CallToolResult, WindowBackgroundColourOutput, error) {
_, _, err := s.core.PERFORM(window.TaskSetBackgroundColour{
Name: input.Name, Red: input.Red, Green: input.Green, Blue: input.Blue, Alpha: input.Alpha,
})
if err != nil {
return nil, WindowBackgroundColourOutput{}, err
}
return nil, WindowBackgroundColourOutput{Success: true}, nil
}
// --- window_fullscreen ---
type WindowFullscreenInput struct {
@ -333,6 +390,9 @@ func (s *Subsystem) registerWindowTools(server *mcp.Server) {
mcp.AddTool(server, &mcp.Tool{Name: "window_restore", Description: "Restore a maximised or minimised window"}, s.windowRestore)
mcp.AddTool(server, &mcp.Tool{Name: "window_focus", Description: "Bring a window to the front"}, s.windowFocus)
mcp.AddTool(server, &mcp.Tool{Name: "window_title", Description: "Set the title of a window"}, s.windowTitle)
mcp.AddTool(server, &mcp.Tool{Name: "window_title_get", Description: "Get the title of a window"}, s.windowTitleGet)
mcp.AddTool(server, &mcp.Tool{Name: "window_visibility", Description: "Show or hide a window"}, s.windowVisibility)
mcp.AddTool(server, &mcp.Tool{Name: "window_always_on_top", Description: "Pin a window above others"}, s.windowAlwaysOnTop)
mcp.AddTool(server, &mcp.Tool{Name: "window_background_colour", Description: "Set a window background colour"}, s.windowBackgroundColour)
mcp.AddTool(server, &mcp.Tool{Name: "window_fullscreen", Description: "Set a window to fullscreen mode"}, s.windowFullscreen)
}

View file

@ -12,6 +12,14 @@ type WindowInfo struct {
Focused bool `json:"focused"`
}
// Bounds describes the position and size of a window.
type Bounds struct {
X int `json:"x"`
Y int `json:"y"`
Width int `json:"width"`
Height int `json:"height"`
}
// --- Queries (read-only) ---
// QueryWindowList returns all tracked windows. Result: []WindowInfo
@ -24,6 +32,9 @@ type QueryWindowByName struct{ Name string }
// Result: map[string]any
type QueryConfig struct{}
// QueryWindowBounds returns the current bounds for a window.
type QueryWindowBounds struct{ Name string }
// QueryFindSpace returns a suggested free placement for a new window.
type QueryFindSpace struct {
Width int
@ -40,7 +51,10 @@ type QueryLayoutSuggestion struct {
// --- Tasks (side-effects) ---
// TaskOpenWindow creates a new window. Result: WindowInfo
type TaskOpenWindow struct{ Opts []WindowOption }
type TaskOpenWindow struct {
Window *Window
Opts []WindowOption
}
// TaskCloseWindow closes a window. Handler persists state BEFORE emitting ActionWindowClosed.
type TaskCloseWindow struct{ Name string }
@ -53,8 +67,9 @@ type TaskSetPosition struct {
// TaskSetSize resizes a window.
type TaskSetSize struct {
Name string
W, H int
Name string
Width, Height int
W, H int
}
// TaskMaximise maximises a window.
@ -75,6 +90,21 @@ type TaskSetTitle struct {
Title string
}
// TaskSetAlwaysOnTop pins a window above others.
type TaskSetAlwaysOnTop struct {
Name string
AlwaysOnTop bool
}
// TaskSetBackgroundColour updates the window background colour.
type TaskSetBackgroundColour struct {
Name string
Red uint8
Green uint8
Blue uint8
Alpha uint8
}
// TaskSetVisibility shows or hides a window.
type TaskSetVisibility struct {
Name string
@ -157,8 +187,9 @@ type ActionWindowMoved struct {
}
type ActionWindowResized struct {
Name string
W, H int
Name string
Width, Height int
W, H int
}
type ActionWindowFocused struct{ Name string }

View file

@ -15,6 +15,9 @@ func (m *MockPlatform) CreateWindow(opts PlatformWindowOptions) PlatformWindow {
name: opts.Name, title: opts.Title, url: opts.URL,
width: opts.Width, height: opts.Height,
x: opts.X, y: opts.Y,
alwaysOnTop: opts.AlwaysOnTop,
backgroundColor: opts.BackgroundColour,
visible: !opts.Hidden,
}
m.Windows = append(m.Windows, w)
return w
@ -33,6 +36,7 @@ type MockWindow struct {
width, height, x, y int
maximised, focused bool
visible, alwaysOnTop bool
backgroundColor [4]uint8
closed bool
eventHandlers []func(WindowEvent)
fileDropHandlers []func(paths []string, targetID string)
@ -47,7 +51,7 @@ func (w *MockWindow) IsFocused() bool { return w.focused }
func (w *MockWindow) SetTitle(title string) { w.title = title }
func (w *MockWindow) SetPosition(x, y int) { w.x = x; w.y = y }
func (w *MockWindow) SetSize(width, height int) { w.width = width; w.height = height }
func (w *MockWindow) SetBackgroundColour(r, g, b, a uint8) {}
func (w *MockWindow) SetBackgroundColour(r, g, b, a uint8) { w.backgroundColor = [4]uint8{r, g, b, a} }
func (w *MockWindow) SetVisibility(visible bool) { w.visible = visible }
func (w *MockWindow) SetAlwaysOnTop(alwaysOnTop bool) { w.alwaysOnTop = alwaysOnTop }
func (w *MockWindow) Maximise() { w.maximised = true }

View file

@ -14,6 +14,9 @@ func (m *mockPlatform) CreateWindow(opts PlatformWindowOptions) PlatformWindow {
name: opts.Name, title: opts.Title, url: opts.URL,
width: opts.Width, height: opts.Height,
x: opts.X, y: opts.Y,
alwaysOnTop: opts.AlwaysOnTop,
backgroundColor: opts.BackgroundColour,
visible: !opts.Hidden,
}
m.windows = append(m.windows, w)
return w
@ -32,6 +35,7 @@ type mockWindow struct {
width, height, x, y int
maximised, focused bool
visible, alwaysOnTop bool
backgroundColor [4]uint8
closed bool
eventHandlers []func(WindowEvent)
fileDropHandlers []func(paths []string, targetID string)
@ -46,7 +50,7 @@ func (w *mockWindow) IsFocused() bool { return w.focused }
func (w *mockWindow) SetTitle(title string) { w.title = title }
func (w *mockWindow) SetPosition(x, y int) { w.x = x; w.y = y }
func (w *mockWindow) SetSize(width, height int) { w.width = width; w.height = height }
func (w *mockWindow) SetBackgroundColour(r, g, b, a uint8) {}
func (w *mockWindow) SetBackgroundColour(r, g, b, a uint8) { w.backgroundColor = [4]uint8{r, g, b, a} }
func (w *mockWindow) SetVisibility(visible bool) { w.visible = visible }
func (w *mockWindow) SetAlwaysOnTop(alwaysOnTop bool) { w.alwaysOnTop = alwaysOnTop }
func (w *mockWindow) Maximise() { w.maximised = true }

View file

@ -70,6 +70,11 @@ func (s *Service) handleQuery(c *core.Core, q core.Query) (any, bool, error) {
return s.queryWindowList(), true, nil
case QueryWindowByName:
return s.queryWindowByName(q.Name), true, nil
case QueryWindowBounds:
if info := s.queryWindowByName(q.Name); info != nil {
return &Bounds{X: info.X, Y: info.Y, Width: info.Width, Height: info.Height}, true, nil
}
return (*Bounds)(nil), true, nil
case QueryLayoutList:
return s.manager.Layout().ListLayouts(), true, nil
case QueryLayoutGet:
@ -131,7 +136,7 @@ func (s *Service) handleTask(c *core.Core, t core.Task) (any, bool, error) {
case TaskSetPosition:
return nil, true, s.taskSetPosition(t.Name, t.X, t.Y)
case TaskSetSize:
return nil, true, s.taskSetSize(t.Name, t.W, t.H)
return nil, true, s.taskSetSize(t.Name, t.Width, t.Height, t.W, t.H)
case TaskMaximise:
return nil, true, s.taskMaximise(t.Name)
case TaskMinimise:
@ -142,6 +147,10 @@ func (s *Service) handleTask(c *core.Core, t core.Task) (any, bool, error) {
return nil, true, s.taskRestore(t.Name)
case TaskSetTitle:
return nil, true, s.taskSetTitle(t.Name, t.Title)
case TaskSetAlwaysOnTop:
return nil, true, s.taskSetAlwaysOnTop(t.Name, t.AlwaysOnTop)
case TaskSetBackgroundColour:
return nil, true, s.taskSetBackgroundColour(t.Name, t.Red, t.Green, t.Blue, t.Alpha)
case TaskSetVisibility:
return nil, true, s.taskSetVisibility(t.Name, t.Visible)
case TaskFullscreen:
@ -171,7 +180,16 @@ func (s *Service) handleTask(c *core.Core, t core.Task) (any, bool, error) {
}
func (s *Service) taskOpenWindow(t TaskOpenWindow) (any, bool, error) {
pw, err := s.manager.Open(t.Opts...)
var (
pw PlatformWindow
err error
)
if t.Window != nil {
spec := *t.Window
pw, err = s.manager.Create(&spec)
} else {
pw, err = s.manager.Open(t.Opts...)
}
if err != nil {
return nil, true, err
}
@ -205,7 +223,7 @@ func (s *Service) trackWindow(pw PlatformWindow) {
if data := e.Data; data != nil {
w, _ := data["w"].(int)
h, _ := data["h"].(int)
_ = s.Core().ACTION(ActionWindowResized{Name: e.Name, W: w, H: h})
_ = s.Core().ACTION(ActionWindowResized{Name: e.Name, Width: w, Height: h, W: w, H: h})
}
case "close":
_ = s.Core().ACTION(ActionWindowClosed{Name: e.Name})
@ -243,13 +261,23 @@ func (s *Service) taskSetPosition(name string, x, y int) error {
return nil
}
func (s *Service) taskSetSize(name string, w, h int) error {
func (s *Service) taskSetSize(name string, width, height, legacyWidth, legacyHeight int) error {
pw, ok := s.manager.Get(name)
if !ok {
return fmt.Errorf("window not found: %s", name)
}
pw.SetSize(w, h)
s.manager.State().UpdateSize(name, w, h)
if width == 0 && height == 0 {
width, height = legacyWidth, legacyHeight
} else {
if width == 0 {
width = legacyWidth
}
if height == 0 {
height = legacyHeight
}
}
pw.SetSize(width, height)
s.manager.State().UpdateSize(name, width, height)
return nil
}
@ -300,6 +328,24 @@ func (s *Service) taskSetTitle(name, title string) error {
return nil
}
func (s *Service) taskSetAlwaysOnTop(name string, alwaysOnTop bool) error {
pw, ok := s.manager.Get(name)
if !ok {
return fmt.Errorf("window not found: %s", name)
}
pw.SetAlwaysOnTop(alwaysOnTop)
return nil
}
func (s *Service) taskSetBackgroundColour(name string, red, green, blue, alpha uint8) error {
pw, ok := s.manager.Get(name)
if !ok {
return fmt.Errorf("window not found: %s", name)
}
pw.SetBackgroundColour(red, green, blue, alpha)
return nil
}
func (s *Service) taskSetVisibility(name string, visible bool) error {
pw, ok := s.manager.Get(name)
if !ok {

View file

@ -54,6 +54,18 @@ func TestTaskOpenWindow_Good(t *testing.T) {
assert.Equal(t, "test", info.Name)
}
func TestTaskOpenWindowDescriptor_Good(t *testing.T) {
_, c := newTestWindowService(t)
result, handled, err := c.PERFORM(TaskOpenWindow{
Window: &Window{Name: "descriptor", Title: "Descriptor", Width: 640, Height: 480},
})
require.NoError(t, err)
assert.True(t, handled)
info := result.(WindowInfo)
assert.Equal(t, "descriptor", info.Name)
assert.Equal(t, "Descriptor", info.Title)
}
func TestTaskOpenWindow_Bad(t *testing.T) {
// No window service registered — PERFORM returns handled=false
c, err := core.New(core.WithServiceLock())
@ -141,6 +153,36 @@ func TestTaskSetSize_Good(t *testing.T) {
assert.Equal(t, 600, info.Height)
}
func TestTaskSetAlwaysOnTop_Good(t *testing.T) {
_, c := newTestWindowService(t)
_, _, _ = c.PERFORM(TaskOpenWindow{Opts: []WindowOption{WithName("test")}})
_, handled, err := c.PERFORM(TaskSetAlwaysOnTop{Name: "test", AlwaysOnTop: true})
require.NoError(t, err)
assert.True(t, handled)
svc := core.MustServiceFor[*Service](c, "window")
pw, ok := svc.Manager().Get("test")
require.True(t, ok)
assert.True(t, pw.(*mockWindow).alwaysOnTop)
}
func TestTaskSetBackgroundColour_Good(t *testing.T) {
_, c := newTestWindowService(t)
_, _, _ = c.PERFORM(TaskOpenWindow{Opts: []WindowOption{WithName("test")}})
_, handled, err := c.PERFORM(TaskSetBackgroundColour{
Name: "test", Red: 10, Green: 20, Blue: 30, Alpha: 40,
})
require.NoError(t, err)
assert.True(t, handled)
svc := core.MustServiceFor[*Service](c, "window")
pw, ok := svc.Manager().Get("test")
require.True(t, ok)
assert.Equal(t, [4]uint8{10, 20, 30, 40}, pw.(*mockWindow).backgroundColor)
}
func TestTaskStackWindows_Good(t *testing.T) {
_, c := newTestWindowService(t)
_, _, _ = c.PERFORM(TaskOpenWindow{Opts: []WindowOption{WithName("one")}})