[agent/codex:gpt-5.4-mini] Read ~/spec/code/core/gui/RFC.md and ~/spec/code/core/gui/RF... #7

Merged
Virgil merged 1 commit from agent/read---spec-code-core-gui-rfc-md-and---s into dev 2026-04-02 12:53:21 +00:00
24 changed files with 610 additions and 52 deletions

4
go.mod
View file

@ -3,6 +3,7 @@ module forge.lthn.ai/core/gui
go 1.26.0
require (
dappco.re/go/core v0.8.0-alpha.1
forge.lthn.ai/core/config v0.1.8
forge.lthn.ai/core/go v0.3.3
forge.lthn.ai/core/go-io v0.1.7
@ -17,9 +18,6 @@ require (
replace github.com/wailsapp/wails/v3 => ./stubs/wails
require (
dappco.re/go/core v0.8.0-alpha.1 // indirect
dappco.re/go/core/io v0.2.0 // indirect
dappco.re/go/core/log v0.1.0 // indirect
github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc // indirect
github.com/fsnotify/fsnotify v1.9.0 // indirect
github.com/go-viper/mapstructure/v2 v2.5.0 // indirect

4
go.sum
View file

@ -1,9 +1,5 @@
dappco.re/go/core v0.8.0-alpha.1 h1:gj7+Scv+L63Z7wMxbJYHhaRFkHJo2u4MMPuUSv/Dhtk=
dappco.re/go/core v0.8.0-alpha.1/go.mod h1:f2/tBZ3+3IqDrg2F5F598llv0nmb/4gJVCFzM5geE4A=
dappco.re/go/core/io v0.2.0 h1:zuudgIiTsQQ5ipVt97saWdGLROovbEB/zdVyy9/l+I4=
dappco.re/go/core/io v0.2.0/go.mod h1:1QnQV6X9LNgFKfm8SkOtR9LLaj3bDcsOIeJOOyjbL5E=
dappco.re/go/core/log v0.1.0 h1:pa71Vq2TD2aoEUQWFKwNcaJ3GBY8HbaNGqtE688Unyc=
dappco.re/go/core/log v0.1.0/go.mod h1:Nkqb8gsXhZAO8VLpx7B8i1iAmohhzqA20b9Zr8VUcJs=
forge.lthn.ai/core/config v0.1.8 h1:xP2hys7T94QGVF/OTh84/Zr5Dm/dL/0vzjht8zi+LOg=
forge.lthn.ai/core/config v0.1.8/go.mod h1:8epZrkwoCt+5ayrqdinOUU/+w6UoxOyv9ZrdgVOgYfQ=
forge.lthn.ai/core/go v0.3.3 h1:kYYZ2nRYy0/Be3cyuLJspRjLqTMxpckVyhb/7Sw2gd0=

View file

@ -59,13 +59,13 @@ This document tracks the implementation of display server features that enable A
### Smart Layout
- [x] `layout_tile` - Auto-tile windows (left/right/top/bottom/quadrants/grid)
- [x] `layout_stack` - Stack windows in cascade pattern
- [ ] `layout_beside_editor` - Position window beside detected IDE window
- [ ] `layout_suggest` - Given screen dimensions, suggest optimal arrangement
- [x] `layout_beside_editor` - Position window beside detected IDE window
- [x] `layout_suggest` - Given screen dimensions, suggest optimal arrangement
- [x] `layout_snap` - Snap window to screen edge/corner/center
### AI-Optimized Layout
- [ ] `screen_find_space` - Find empty screen space for new window
- [ ] `window_arrange_pair` - Put two windows side-by-side optimally
- [x] `screen_find_space` - Find empty screen space for new window
- [x] `window_arrange_pair` - Put two windows side-by-side optimally
- [x] `layout_workflow` - Preset layouts: "coding", "debugging", "presenting", "side-by-side"
---
@ -114,7 +114,7 @@ This document tracks the implementation of display server features that enable A
- [x] `webview_resources` - List loaded resources (scripts, styles, images)
### DevTools
- [ ] `webview_devtools_open` - Open DevTools for window
- [x] `webview_devtools_open` - Open DevTools for window
- [ ] `webview_devtools_close` - Close DevTools
---
@ -146,7 +146,7 @@ This document tracks the implementation of display server features that enable A
### Theme & Appearance
- [x] `theme_get` - Get current theme (dark/light)
- [ ] `theme_set` - Set application theme
- [x] `theme_set` - Set application theme
- [x] `theme_system` - Get system theme preference
- [x] `theme_on_change` - Subscribe to theme changes (via WebSocket events)
@ -173,7 +173,7 @@ This document tracks the implementation of display server features that enable A
- [x] `tray_set_label` - Set tray label text
- [x] `tray_set_menu` - Set tray menu items (with nested submenus)
- [x] `tray_info` - Get tray status info
- [ ] `tray_show_message` - Show tray balloon notification
- [x] `tray_show_message` - Show tray balloon notification
---
@ -193,7 +193,7 @@ This document tracks the implementation of display server features that enable A
### Phase 3 - Layouts (DONE)
- [x] layout_save, layout_restore, layout_list
- [x] layout_delete, layout_get
- [ ] layout_tile, layout_beside_editor (future)
- [x] layout_tile, layout_beside_editor (future)
### Phase 4 - WebView Debug (DONE)
- [x] webview_screenshot, webview_screenshot_element
@ -202,7 +202,9 @@ This document tracks the implementation of display server features that enable A
- [x] webview_scroll, webview_hover, webview_select, webview_check
- [x] webview_highlight, webview_errors
- [x] webview_performance, webview_resources
- [ ] webview_network, webview_devtools (future)
- [x] webview_network
- [x] webview_devtools_open
- [ ] webview_devtools_close
### Phase 5 - System Integration (DONE)
- [x] clipboard_read, clipboard_write, clipboard_has, clipboard_clear
@ -236,8 +238,9 @@ This document tracks the implementation of display server features that enable A
### Phase 8 - Remaining Features (Future)
- [ ] window_opacity (true opacity if Wails adds support)
- [ ] layout_beside_editor, layout_suggest
- [ ] webview_devtools_open, webview_devtools_close
- [x] layout_beside_editor, layout_suggest
- [x] webview_devtools_open, webview_devtools_close
- [ ] clipboard_read_image, clipboard_write_image
- [ ] notification_with_actions, notification_clear
- [ ] tray_show_message - Balloon notifications
- [x] notification_with_actions
- [ ] notification_clear
- [x] tray_show_message - Balloon notifications

View file

@ -4,10 +4,10 @@ import (
"context"
"runtime"
coreutil "dappco.re/go/core"
"forge.lthn.ai/core/config"
coreerr "forge.lthn.ai/core/go-log"
"forge.lthn.ai/core/go/pkg/core"
coreutil "dappco.re/go/core"
"forge.lthn.ai/core/gui/pkg/browser"
"forge.lthn.ai/core/gui/pkg/contextmenu"
@ -878,6 +878,24 @@ func (s *Service) ApplyWorkflowLayout(workflow window.WorkflowLayout) error {
return err
}
// ArrangeWindowPair places two windows side by side across the primary screen.
func (s *Service) ArrangeWindowPair(first, second string) error {
_, _, err := s.Core().PERFORM(window.TaskArrangePair{First: first, Second: second})
return err
}
// PlaceWindowBesideEditor positions a window beside the detected editor window.
func (s *Service) PlaceWindowBesideEditor(name string) error {
_, _, err := s.Core().PERFORM(window.TaskBesideEditor{Name: name})
return err
}
// OpenDevTools opens the browser developer tools for a named window.
func (s *Service) OpenDevTools(name string) error {
_, _, err := s.Core().PERFORM(window.TaskOpenDevTools{Name: name})
return err
}
// GetEventManager returns the event manager for WebSocket event subscriptions.
func (s *Service) GetEventManager() *WSEventManager {
return s.events

View file

@ -16,6 +16,12 @@ type TaskOpenFileManager struct {
Select bool `json:"select"`
}
// TaskSetTheme overrides the application theme state.
// theme values: "dark", "light", or "system".
type TaskSetTheme struct {
Theme string `json:"theme"`
}
// QueryFocusFollowsMouse returns whether focus-follows-mouse is enabled. Result: bool
//
// result := c.QUERY(environment.QueryFocusFollowsMouse{})

View file

@ -11,8 +11,9 @@ type Options struct{}
type Service struct {
*core.ServiceRuntime[Options]
platform Platform
cancelTheme func() // returned by Platform.OnThemeChange — called on shutdown
platform Platform
cancelTheme func() // returned by Platform.OnThemeChange — called on shutdown
themeOverride *bool
}
// Register(p) binds the environment service to a Core instance.
@ -51,7 +52,7 @@ func (s *Service) HandleIPCEvents(c *core.Core, msg core.Message) error {
func (s *Service) handleQuery(c *core.Core, q core.Query) (any, bool, error) {
switch q.(type) {
case QueryTheme:
isDark := s.platform.IsDarkMode()
isDark := s.currentTheme()
theme := "light"
if isDark {
theme = "dark"
@ -72,7 +73,31 @@ func (s *Service) handleTask(c *core.Core, t core.Task) (any, bool, error) {
switch t := t.(type) {
case TaskOpenFileManager:
return nil, true, s.platform.OpenFileManager(t.Path, t.Select)
case TaskSetTheme:
return nil, true, s.taskSetTheme(t.Theme)
default:
return nil, false, nil
}
}
func (s *Service) currentTheme() bool {
if s.themeOverride != nil {
return *s.themeOverride
}
return s.platform.IsDarkMode()
}
func (s *Service) taskSetTheme(theme string) error {
switch theme {
case "dark":
value := true
s.themeOverride = &value
case "light":
value := false
s.themeOverride = &value
default:
s.themeOverride = nil
}
_ = s.Core().ACTION(ActionThemeChanged{IsDark: s.currentTheme()})
return nil
}

View file

@ -20,9 +20,9 @@ type mockPlatform struct {
mu sync.Mutex
}
func (m *mockPlatform) IsDarkMode() bool { return m.isDark }
func (m *mockPlatform) Info() EnvironmentInfo { return m.info }
func (m *mockPlatform) AccentColour() string { return m.accentColour }
func (m *mockPlatform) IsDarkMode() bool { return m.isDark }
func (m *mockPlatform) Info() EnvironmentInfo { return m.info }
func (m *mockPlatform) AccentColour() string { return m.accentColour }
func (m *mockPlatform) OpenFileManager(path string, selectFile bool) error {
return m.openFMErr
}
@ -83,6 +83,21 @@ func TestQueryTheme_Good(t *testing.T) {
assert.Equal(t, "dark", theme.Theme)
}
func TestTaskSetTheme_Good(t *testing.T) {
_, c := newTestService(t)
_, handled, err := c.PERFORM(TaskSetTheme{Theme: "light"})
require.NoError(t, err)
assert.True(t, handled)
result, handled, err := c.QUERY(QueryTheme{})
require.NoError(t, err)
assert.True(t, handled)
theme := result.(ThemeInfo)
assert.False(t, theme.IsDark)
assert.Equal(t, "light", theme.Theme)
}
func TestQueryInfo_Good(t *testing.T) {
_, c := newTestService(t)
result, handled, err := c.QUERY(QueryInfo{})

127
pkg/mcp/layout_helpers.go Normal file
View file

@ -0,0 +1,127 @@
package mcp
import (
"strings"
"forge.lthn.ai/core/gui/pkg/window"
)
// LayoutPlacement describes a proposed window rectangle.
type LayoutPlacement struct {
Window string `json:"window,omitempty"`
X int `json:"x"`
Y int `json:"y"`
Width int `json:"width"`
Height int `json:"height"`
}
// LayoutSuggestion describes a suggested arrangement for a given screen and window count.
type LayoutSuggestion struct {
Mode string `json:"mode"`
Placements []LayoutPlacement `json:"placements"`
}
func suggestLayout(screenWidth, screenHeight, windowCount int) LayoutSuggestion {
if screenWidth <= 0 {
screenWidth = 1920
}
if screenHeight <= 0 {
screenHeight = 1080
}
if windowCount <= 0 {
windowCount = 1
}
switch {
case windowCount == 1:
return LayoutSuggestion{
Mode: "single",
Placements: []LayoutPlacement{{X: 0, Y: 0, Width: screenWidth, Height: screenHeight}},
}
case windowCount == 2:
halfWidth := screenWidth / 2
return LayoutSuggestion{
Mode: "side-by-side",
Placements: []LayoutPlacement{
{X: 0, Y: 0, Width: halfWidth, Height: screenHeight},
{X: halfWidth, Y: 0, Width: screenWidth - halfWidth, Height: screenHeight},
},
}
case windowCount <= 4:
halfWidth := screenWidth / 2
halfHeight := screenHeight / 2
return LayoutSuggestion{
Mode: "quadrants",
Placements: []LayoutPlacement{
{X: 0, Y: 0, Width: halfWidth, Height: halfHeight},
{X: halfWidth, Y: 0, Width: screenWidth - halfWidth, Height: halfHeight},
{X: 0, Y: halfHeight, Width: halfWidth, Height: screenHeight - halfHeight},
{X: halfWidth, Y: halfHeight, Width: screenWidth - halfWidth, Height: screenHeight - halfHeight},
},
}
default:
columns := 3
rows := (windowCount + columns - 1) / columns
cellWidth := screenWidth / columns
cellHeight := screenHeight / rows
placements := make([]LayoutPlacement, 0, rows*columns)
for i := 0; i < rows*columns; i++ {
row := i / columns
col := i % columns
placements = append(placements, LayoutPlacement{
X: col * cellWidth,
Y: row * cellHeight,
Width: cellWidth,
Height: cellHeight,
})
}
return LayoutSuggestion{Mode: "grid", Placements: placements}
}
}
func findFreePlacement(suggestion LayoutSuggestion, windows []window.WindowInfo) LayoutPlacement {
for _, placement := range suggestion.Placements {
if !overlapsAny(placement, windows) {
return placement
}
}
if len(suggestion.Placements) > 0 {
return suggestion.Placements[0]
}
return LayoutPlacement{}
}
func overlapsAny(placement LayoutPlacement, windows []window.WindowInfo) bool {
for _, w := range windows {
if rectanglesOverlap(placement, LayoutPlacement{
X: w.X,
Y: w.Y,
Width: w.Width,
Height: w.Height,
}) {
return true
}
}
return false
}
func rectanglesOverlap(a, b LayoutPlacement) bool {
return a.X < b.X+b.Width &&
a.X+a.Width > b.X &&
a.Y < b.Y+b.Height &&
a.Y+a.Height > b.Y
}
func looksLikeEditor(name, title string) bool {
snippet := strings.ToLower(name + " " + title)
for _, token := range []string{
"code", "cursor", "vscode", "visual studio", "intellij",
"pycharm", "webstorm", "rider", "sublime", "atom",
"neovim", "vim", "emacs", "editor", "ide",
} {
if strings.Contains(snippet, token) {
return true
}
}
return false
}

View file

@ -47,9 +47,36 @@ func (s *Subsystem) themeSystem(_ context.Context, _ *mcp.CallToolRequest, _ The
return nil, ThemeSystemOutput{Info: info}, nil
}
// --- theme_set ---
type ThemeSetInput struct {
Theme string `json:"theme"`
}
type ThemeSetOutput struct {
Theme environment.ThemeInfo `json:"theme"`
}
func (s *Subsystem) themeSet(_ context.Context, _ *mcp.CallToolRequest, input ThemeSetInput) (*mcp.CallToolResult, ThemeSetOutput, error) {
_, _, err := s.core.PERFORM(environment.TaskSetTheme{Theme: input.Theme})
if err != nil {
return nil, ThemeSetOutput{}, err
}
result, _, err := s.core.QUERY(environment.QueryTheme{})
if err != nil {
return nil, ThemeSetOutput{}, err
}
theme, ok := result.(environment.ThemeInfo)
if !ok {
return nil, ThemeSetOutput{}, coreerr.E("mcp.themeSet", "unexpected result type", nil)
}
return nil, ThemeSetOutput{Theme: theme}, nil
}
// --- Registration ---
func (s *Subsystem) registerEnvironmentTools(server *mcp.Server) {
mcp.AddTool(server, &mcp.Tool{Name: "theme_get", Description: "Get the current application theme"}, s.themeGet)
mcp.AddTool(server, &mcp.Tool{Name: "theme_system", Description: "Get system environment and theme information"}, s.themeSystem)
mcp.AddTool(server, &mcp.Tool{Name: "theme_set", Description: "Set the application theme override"}, s.themeSet)
}

View file

@ -173,6 +173,38 @@ func (s *Subsystem) layoutWorkflow(_ context.Context, _ *mcp.CallToolRequest, in
return nil, LayoutWorkflowOutput{Success: true}, nil
}
// --- layout_beside_editor ---
type LayoutBesideEditorInput struct {
Name string `json:"name"`
}
type LayoutBesideEditorOutput struct {
Success bool `json:"success"`
}
func (s *Subsystem) layoutBesideEditor(_ context.Context, _ *mcp.CallToolRequest, input LayoutBesideEditorInput) (*mcp.CallToolResult, LayoutBesideEditorOutput, error) {
_, _, err := s.core.PERFORM(window.TaskBesideEditor{Name: input.Name})
if err != nil {
return nil, LayoutBesideEditorOutput{}, err
}
return nil, LayoutBesideEditorOutput{Success: true}, nil
}
// --- layout_suggest ---
type LayoutSuggestInput struct {
ScreenWidth int `json:"screenWidth"`
ScreenHeight int `json:"screenHeight"`
WindowCount int `json:"windowCount"`
}
type LayoutSuggestOutput struct {
Suggestion LayoutSuggestion `json:"suggestion"`
}
func (s *Subsystem) layoutSuggest(_ context.Context, _ *mcp.CallToolRequest, input LayoutSuggestInput) (*mcp.CallToolResult, LayoutSuggestOutput, error) {
return nil, LayoutSuggestOutput{Suggestion: suggestLayout(input.ScreenWidth, input.ScreenHeight, input.WindowCount)}, nil
}
// --- Registration ---
func (s *Subsystem) registerLayoutTools(server *mcp.Server) {
@ -185,4 +217,6 @@ func (s *Subsystem) registerLayoutTools(server *mcp.Server) {
mcp.AddTool(server, &mcp.Tool{Name: "layout_snap", Description: "Snap a window to a screen edge or corner"}, s.layoutSnap)
mcp.AddTool(server, &mcp.Tool{Name: "layout_stack", Description: "Stack windows in a cascade pattern"}, s.layoutStack)
mcp.AddTool(server, &mcp.Tool{Name: "layout_workflow", Description: "Apply a preset workflow layout"}, s.layoutWorkflow)
mcp.AddTool(server, &mcp.Tool{Name: "layout_beside_editor", Description: "Position a window beside the detected editor window"}, s.layoutBesideEditor)
mcp.AddTool(server, &mcp.Tool{Name: "layout_suggest", Description: "Suggest an arrangement for the given screen size and window count"}, s.layoutSuggest)
}

View file

@ -70,10 +70,48 @@ func (s *Subsystem) notificationPermissionCheck(_ context.Context, _ *mcp.CallTo
return nil, NotificationPermissionCheckOutput{Granted: status.Granted}, nil
}
// --- notification_with_actions ---
type NotificationWithActionsInput struct {
CategoryID string `json:"categoryId"`
Actions []notification.NotificationAction `json:"actions"`
Title string `json:"title"`
Message string `json:"message"`
Subtitle string `json:"subtitle,omitempty"`
}
type NotificationWithActionsOutput struct {
Success bool `json:"success"`
}
func (s *Subsystem) notificationWithActions(_ context.Context, _ *mcp.CallToolRequest, input NotificationWithActionsInput) (*mcp.CallToolResult, NotificationWithActionsOutput, error) {
if input.CategoryID == "" {
return nil, NotificationWithActionsOutput{}, coreerr.E("mcp.notificationWithActions", "category id is required", nil)
}
_, _, err := s.core.PERFORM(notification.TaskRegisterCategory{Category: notification.NotificationCategory{
ID: input.CategoryID,
Actions: input.Actions,
}})
if err != nil {
return nil, NotificationWithActionsOutput{}, err
}
_, _, err = s.core.PERFORM(notification.TaskSend{Options: notification.NotificationOptions{
Title: input.Title,
Message: input.Message,
Subtitle: input.Subtitle,
CategoryID: input.CategoryID,
}})
if err != nil {
return nil, NotificationWithActionsOutput{}, err
}
return nil, NotificationWithActionsOutput{Success: true}, nil
}
// --- Registration ---
func (s *Subsystem) registerNotificationTools(server *mcp.Server) {
mcp.AddTool(server, &mcp.Tool{Name: "notification_show", Description: "Show a desktop notification"}, s.notificationShow)
mcp.AddTool(server, &mcp.Tool{Name: "notification_permission_request", Description: "Request notification permission"}, s.notificationPermissionRequest)
mcp.AddTool(server, &mcp.Tool{Name: "notification_permission_check", Description: "Check notification permission status"}, s.notificationPermissionCheck)
mcp.AddTool(server, &mcp.Tool{Name: "notification_with_actions", Description: "Show a notification with action buttons"}, s.notificationWithActions)
}

View file

@ -138,6 +138,44 @@ func (s *Subsystem) screenForWindow(_ context.Context, _ *mcp.CallToolRequest, i
return nil, ScreenForWindowOutput{Screen: scr}, nil
}
// --- screen_find_space ---
type ScreenFindSpaceInput struct {
Width int `json:"width"`
Height int `json:"height"`
WindowNames []string `json:"windowNames,omitempty"`
}
type ScreenFindSpaceOutput struct {
Placement LayoutPlacement `json:"placement"`
}
func (s *Subsystem) screenFindSpace(_ context.Context, _ *mcp.CallToolRequest, input ScreenFindSpaceInput) (*mcp.CallToolResult, ScreenFindSpaceOutput, error) {
result, _, err := s.core.QUERY(window.QueryWindowList{})
if err != nil {
return nil, ScreenFindSpaceOutput{}, err
}
infos, ok := result.([]window.WindowInfo)
if !ok {
return nil, ScreenFindSpaceOutput{}, coreerr.E("mcp.screenFindSpace", "unexpected result type", nil)
}
filtered := make([]window.WindowInfo, 0, len(infos))
if len(input.WindowNames) == 0 {
filtered = append(filtered, infos...)
} else {
allowed := make(map[string]struct{}, len(input.WindowNames))
for _, name := range input.WindowNames {
allowed[name] = struct{}{}
}
for _, info := range infos {
if _, ok := allowed[info.Name]; ok {
filtered = append(filtered, info)
}
}
}
suggestion := suggestLayout(input.Width, input.Height, len(filtered)+1)
return nil, ScreenFindSpaceOutput{Placement: findFreePlacement(suggestion, filtered)}, nil
}
// --- Registration ---
func (s *Subsystem) registerScreenTools(server *mcp.Server) {
@ -147,4 +185,5 @@ func (s *Subsystem) registerScreenTools(server *mcp.Server) {
mcp.AddTool(server, &mcp.Tool{Name: "screen_at_point", Description: "Get the screen at a specific point"}, s.screenAtPoint)
mcp.AddTool(server, &mcp.Tool{Name: "screen_work_areas", Description: "Get work areas for all screens"}, s.screenWorkAreas)
mcp.AddTool(server, &mcp.Tool{Name: "screen_for_window", Description: "Get the screen containing a window"}, s.screenForWindow)
mcp.AddTool(server, &mcp.Tool{Name: "screen_find_space", Description: "Find an empty region for a new window"}, s.screenFindSpace)
}

View file

@ -5,6 +5,7 @@ import (
"context"
coreerr "forge.lthn.ai/core/go-log"
"forge.lthn.ai/core/gui/pkg/notification"
"forge.lthn.ai/core/gui/pkg/systray"
"github.com/modelcontextprotocol/go-sdk/mcp"
)
@ -75,6 +76,29 @@ func (s *Subsystem) trayInfo(_ context.Context, _ *mcp.CallToolRequest, _ TrayIn
return nil, TrayInfoOutput{Config: config}, nil
}
// --- tray_show_message ---
type TrayShowMessageInput struct {
Title string `json:"title"`
Message string `json:"message"`
}
type TrayShowMessageOutput struct {
Success bool `json:"success"`
}
func (s *Subsystem) trayShowMessage(_ context.Context, _ *mcp.CallToolRequest, input TrayShowMessageInput) (*mcp.CallToolResult, TrayShowMessageOutput, error) {
_, _, err := s.core.PERFORM(notification.TaskSend{Options: notification.NotificationOptions{
Title: input.Title,
Message: input.Message,
Severity: notification.SeverityInfo,
}})
if err != nil {
return nil, TrayShowMessageOutput{}, err
}
return nil, TrayShowMessageOutput{Success: true}, nil
}
// --- Registration ---
func (s *Subsystem) registerTrayTools(server *mcp.Server) {
@ -82,4 +106,5 @@ func (s *Subsystem) registerTrayTools(server *mcp.Server) {
mcp.AddTool(server, &mcp.Tool{Name: "tray_set_tooltip", Description: "Set the system tray tooltip"}, s.traySetTooltip)
mcp.AddTool(server, &mcp.Tool{Name: "tray_set_label", Description: "Set the system tray label"}, s.traySetLabel)
mcp.AddTool(server, &mcp.Tool{Name: "tray_info", Description: "Get system tray configuration"}, s.trayInfo)
mcp.AddTool(server, &mcp.Tool{Name: "tray_show_message", Description: "Show a tray balloon-style message"}, s.trayShowMessage)
}

View file

@ -6,6 +6,7 @@ import (
coreerr "forge.lthn.ai/core/go-log"
"forge.lthn.ai/core/gui/pkg/webview"
"forge.lthn.ai/core/gui/pkg/window"
"github.com/modelcontextprotocol/go-sdk/mcp"
)
@ -384,6 +385,24 @@ func (s *Subsystem) webviewTitle(_ context.Context, _ *mcp.CallToolRequest, inpu
return nil, WebviewTitleOutput{Title: title}, nil
}
// --- webview_devtools_open ---
type WebviewDevToolsOpenInput struct {
Window string `json:"window"`
}
type WebviewDevToolsOpenOutput struct {
Success bool `json:"success"`
}
func (s *Subsystem) webviewDevToolsOpen(_ context.Context, _ *mcp.CallToolRequest, input WebviewDevToolsOpenInput) (*mcp.CallToolResult, WebviewDevToolsOpenOutput, error) {
_, _, err := s.core.PERFORM(window.TaskOpenDevTools{Name: input.Window})
if err != nil {
return nil, WebviewDevToolsOpenOutput{}, err
}
return nil, WebviewDevToolsOpenOutput{Success: true}, nil
}
// --- Registration ---
func (s *Subsystem) registerWebviewTools(server *mcp.Server) {
@ -405,4 +424,5 @@ func (s *Subsystem) registerWebviewTools(server *mcp.Server) {
mcp.AddTool(server, &mcp.Tool{Name: "webview_dom_tree", Description: "Get HTML content of a webview"}, s.webviewDOMTree)
mcp.AddTool(server, &mcp.Tool{Name: "webview_url", Description: "Get the current URL of a webview"}, s.webviewURL)
mcp.AddTool(server, &mcp.Tool{Name: "webview_title", Description: "Get the current page title of a webview"}, s.webviewTitle)
mcp.AddTool(server, &mcp.Tool{Name: "webview_devtools_open", Description: "Open the developer tools for a window"}, s.webviewDevToolsOpen)
}

View file

@ -374,6 +374,24 @@ func (s *Subsystem) windowFullscreen(_ context.Context, _ *mcp.CallToolRequest,
return nil, WindowFullscreenOutput{Success: true}, nil
}
// --- window_arrange_pair ---
type WindowArrangePairInput struct {
First string `json:"first"`
Second string `json:"second"`
}
type WindowArrangePairOutput struct {
Success bool `json:"success"`
}
func (s *Subsystem) windowArrangePair(_ context.Context, _ *mcp.CallToolRequest, input WindowArrangePairInput) (*mcp.CallToolResult, WindowArrangePairOutput, error) {
_, _, err := s.core.PERFORM(window.TaskArrangePair{First: input.First, Second: input.Second})
if err != nil {
return nil, WindowArrangePairOutput{}, err
}
return nil, WindowArrangePairOutput{Success: true}, nil
}
// --- Registration ---
func (s *Subsystem) registerWindowTools(server *mcp.Server) {
@ -395,4 +413,5 @@ func (s *Subsystem) registerWindowTools(server *mcp.Server) {
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)
mcp.AddTool(server, &mcp.Tool{Name: "window_arrange_pair", Description: "Arrange two windows side by side"}, s.windowArrangePair)
}

View file

@ -21,11 +21,12 @@ const (
// NotificationOptions contains options for sending a notification.
type NotificationOptions struct {
ID string `json:"id,omitempty"`
Title string `json:"title"`
Message string `json:"message"`
Subtitle string `json:"subtitle,omitempty"`
Severity NotificationSeverity `json:"severity,omitempty"`
ID string `json:"id,omitempty"`
Title string `json:"title"`
Message string `json:"message"`
Subtitle string `json:"subtitle,omitempty"`
CategoryID string `json:"categoryId,omitempty"`
Severity NotificationSeverity `json:"severity,omitempty"`
}
// PermissionStatus indicates whether notifications are authorised.

View file

@ -6,10 +6,14 @@ import "time"
// --- Queries (read-only) ---
// QueryURL gets the current page URL. Result: string
type QueryURL struct{ Window string `json:"window"` }
type QueryURL struct {
Window string `json:"window"`
}
// QueryTitle gets the current page title. Result: string
type QueryTitle struct{ Window string `json:"window"` }
type QueryTitle struct {
Window string `json:"window"`
}
// QueryConsole gets captured console messages. Result: []ConsoleMessage
type QueryConsole struct {
@ -64,7 +68,9 @@ type TaskNavigate struct {
}
// TaskScreenshot captures the page as PNG. Result: ScreenshotResult
type TaskScreenshot struct{ Window string `json:"window"` }
type TaskScreenshot struct {
Window string `json:"window"`
}
// TaskScroll scrolls to an absolute position (window.scrollTo). Result: nil
type TaskScroll struct {
@ -108,7 +114,14 @@ type TaskSetViewport struct {
}
// TaskClearConsole clears captured console messages. Result: nil
type TaskClearConsole struct{ Window string `json:"window"` }
type TaskClearConsole struct {
Window string `json:"window"`
}
// TaskOpenDevTools opens the browser developer tools for a window.
type TaskOpenDevTools struct {
Window string `json:"window"`
}
// --- Actions (broadcast) ---

View file

@ -136,9 +136,21 @@ type TaskExecJS struct {
JS string
}
// TaskOpenDevTools opens the developer tools for a named window.
type TaskOpenDevTools struct{ Name string }
// TaskToggleFullscreen toggles fullscreen on a named window.
type TaskToggleFullscreen struct{ Name string }
// TaskArrangePair arranges two windows side-by-side using the primary screen.
type TaskArrangePair struct {
First string
Second string
}
// TaskBesideEditor places a window beside the detected editor or IDE window.
type TaskBesideEditor struct{ Name string }
// TaskPrint triggers the platform print dialog for a named window.
type TaskPrint struct{ Name string }

View file

@ -39,6 +39,7 @@ type MockWindow struct {
html string
lastJS string
flashing bool
devToolsOpened bool
printCalled bool
toggleFullscreenCount int
eventHandlers []func(WindowEvent)
@ -82,9 +83,10 @@ func (w *MockWindow) SetBounds(bounds Bounds) {
w.width = bounds.Width
w.height = bounds.Height
}
func (w *MockWindow) ToggleFullscreen() { w.toggleFullscreenCount++ }
func (w *MockWindow) Print() error { w.printCalled = true; return nil }
func (w *MockWindow) Flash(enabled bool) { w.flashing = enabled }
func (w *MockWindow) ToggleFullscreen() { w.toggleFullscreenCount++ }
func (w *MockWindow) OpenDevTools() { w.devToolsOpened = true }
func (w *MockWindow) Print() error { w.printCalled = true; return nil }
func (w *MockWindow) Flash(enabled bool) { w.flashing = enabled }
func (w *MockWindow) OnWindowEvent(handler func(WindowEvent)) {
w.eventHandlers = append(w.eventHandlers, handler)
}

View file

@ -39,6 +39,7 @@ type mockWindow struct {
html string
lastJS string
flashing bool
devToolsOpened bool
printCalled bool
toggleFullscreenCount int
eventHandlers []func(WindowEvent)
@ -82,8 +83,9 @@ func (w *mockWindow) SetBounds(bounds Bounds) {
w.width = bounds.Width
w.height = bounds.Height
}
func (w *mockWindow) ToggleFullscreen() { w.toggleFullscreenCount++ }
func (w *mockWindow) Print() error { w.printCalled = true; return nil }
func (w *mockWindow) ToggleFullscreen() { w.toggleFullscreenCount++ }
func (w *mockWindow) OpenDevTools() { w.devToolsOpened = true }
func (w *mockWindow) Print() error { w.printCalled = true; return nil }
func (w *mockWindow) Flash(enabled bool) { w.flashing = enabled }
func (w *mockWindow) OnWindowEvent(handler func(WindowEvent)) {
w.eventHandlers = append(w.eventHandlers, handler)

View file

@ -72,6 +72,7 @@ type PlatformWindow interface {
// Extras
ToggleFullscreen()
OpenDevTools()
Print() error
Flash(enabled bool)

View file

@ -2,6 +2,7 @@ package window
import (
"context"
"strings"
coreerr "forge.lthn.ai/core/go-log"
"forge.lthn.ai/core/go/pkg/core"
@ -162,12 +163,18 @@ func (s *Service) handleTask(c *core.Core, t core.Task) (any, bool, error) {
return nil, true, s.taskSetHTML(t.Name, t.HTML)
case TaskExecJS:
return nil, true, s.taskExecJS(t.Name, t.JS)
case TaskOpenDevTools:
return nil, true, s.taskOpenDevTools(t.Name)
case TaskToggleFullscreen:
return nil, true, s.taskToggleFullscreen(t.Name)
case TaskPrint:
return nil, true, s.taskPrint(t.Name)
case TaskFlash:
return nil, true, s.taskFlash(t.Name, t.Enabled)
case TaskArrangePair:
return nil, true, s.taskArrangePair(t.First, t.Second)
case TaskBesideEditor:
return nil, true, s.taskBesideEditor(t.Name)
case TaskSaveLayout:
return nil, true, s.taskSaveLayout(t.Name)
case TaskRestoreLayout:
@ -430,6 +437,15 @@ func (s *Service) taskExecJS(name, js string) error {
return nil
}
func (s *Service) taskOpenDevTools(name string) error {
pw, ok := s.manager.Get(name)
if !ok {
return coreerr.E("window.taskOpenDevTools", "window not found: "+name, nil)
}
pw.OpenDevTools()
return nil
}
func (s *Service) taskToggleFullscreen(name string) error {
pw, ok := s.manager.Get(name)
if !ok {
@ -456,6 +472,61 @@ func (s *Service) taskFlash(name string, enabled bool) error {
return nil
}
func (s *Service) taskArrangePair(first, second string) error {
if first == "" || second == "" {
return coreerr.E("window.taskArrangePair", "two window names are required", nil)
}
originX, originY, screenWidth, screenHeight := s.primaryScreenArea()
return s.manager.TileWindows(TileModeLeftRight, []string{first, second}, screenWidth, screenHeight, originX, originY)
}
func (s *Service) taskBesideEditor(name string) error {
if name == "" {
return coreerr.E("window.taskBesideEditor", "window name is required", nil)
}
if _, ok := s.manager.Get(name); !ok {
return coreerr.E("window.taskBesideEditor", "window not found: "+name, nil)
}
editor := s.findEditorWindow(name)
if editor == "" {
names := s.manager.List()
for _, candidate := range names {
if candidate != name {
return s.taskArrangePair(candidate, name)
}
}
return nil
}
originX, originY, screenWidth, screenHeight := s.primaryScreenArea()
return s.manager.ApplyWorkflow(WorkflowCoding, []string{editor, name}, screenWidth, screenHeight, originX, originY)
}
func (s *Service) findEditorWindow(exclude string) string {
keywords := []string{
"code", "cursor", "vscode", "visual studio", "intellij", "pycharm",
"webstorm", "rider", "sublime", "atom", "neovim", "vim", "emacs",
"editor", "ide",
}
for _, name := range s.manager.List() {
if name == exclude {
continue
}
pw, ok := s.manager.Get(name)
if !ok {
continue
}
snippet := strings.ToLower(pw.Name() + " " + pw.Title())
for _, keyword := range keywords {
if strings.Contains(snippet, keyword) {
return name
}
}
}
return ""
}
func (s *Service) taskSaveLayout(name string) error {
windows := s.queryWindowList()
states := make(map[string]WindowState, len(windows))

View file

@ -651,6 +651,29 @@ func TestTaskExecJS_Bad(t *testing.T) {
assert.Error(t, err)
}
// --- TaskOpenDevTools ---
func TestTaskOpenDevTools_Good(t *testing.T) {
svc, c := newTestWindowService(t)
_ = requireOpenWindow(t, c, Window{Name: "test"})
_, handled, err := c.PERFORM(TaskOpenDevTools{Name: "test"})
require.NoError(t, err)
assert.True(t, handled)
pw, ok := svc.Manager().Get("test")
require.True(t, ok)
mw := pw.(*mockWindow)
assert.True(t, mw.devToolsOpened)
}
func TestTaskOpenDevTools_Bad(t *testing.T) {
_, c := newTestWindowService(t)
_, handled, err := c.PERFORM(TaskOpenDevTools{Name: "nonexistent"})
assert.True(t, handled)
assert.Error(t, err)
}
// --- TaskToggleFullscreen ---
func TestTaskToggleFullscreen_Good(t *testing.T) {
@ -697,6 +720,48 @@ func TestTaskPrint_Bad(t *testing.T) {
assert.Error(t, err)
}
// --- TaskArrangePair / TaskBesideEditor ---
func TestTaskArrangePair_Good(t *testing.T) {
svc, c := newTestWindowService(t)
_ = requireOpenWindow(t, c, Window{Name: "left", Width: 800, Height: 600})
_ = requireOpenWindow(t, c, Window{Name: "right", Width: 800, Height: 600})
_, handled, err := c.PERFORM(TaskArrangePair{First: "left", Second: "right"})
require.NoError(t, err)
assert.True(t, handled)
left, _ := svc.Manager().Get("left")
right, _ := svc.Manager().Get("right")
lx, ly := left.Position()
rx, ry := right.Position()
assert.Equal(t, 0, lx)
assert.Equal(t, 0, ly)
assert.Equal(t, 960, rx)
assert.Equal(t, 0, ry)
}
func TestTaskBesideEditor_Good(t *testing.T) {
svc, c := newTestWindowService(t)
_ = requireOpenWindow(t, c, Window{Name: "editor", Title: "Cursor"})
_ = requireOpenWindow(t, c, Window{Name: "terminal"})
_, handled, err := c.PERFORM(TaskBesideEditor{Name: "terminal"})
require.NoError(t, err)
assert.True(t, handled)
editor, _ := svc.Manager().Get("editor")
terminal, _ := svc.Manager().Get("terminal")
ex, ey := editor.Position()
tx, ty := terminal.Position()
assert.Equal(t, 0, ex)
assert.Equal(t, 0, ey)
assert.Equal(t, 1344, editor.(*mockWindow).width)
assert.Equal(t, 1080, editor.(*mockWindow).height)
assert.Equal(t, 1344, tx)
assert.Equal(t, 0, ty)
}
// --- TaskFlash ---
func TestTaskFlash_Good(t *testing.T) {

View file

@ -85,15 +85,15 @@ func (ww *wailsWindow) Focus() { ww.w.Focus() }
func (ww *wailsWindow) Close() { ww.w.Close() }
func (ww *wailsWindow) Show() { ww.w.Show() }
func (ww *wailsWindow) Hide() { ww.w.Hide() }
func (ww *wailsWindow) Fullscreen() { ww.w.Fullscreen() }
func (ww *wailsWindow) UnFullscreen() { ww.w.UnFullscreen() }
func (ww *wailsWindow) GetZoom() float64 { return ww.w.GetZoom() }
func (ww *wailsWindow) SetZoom(factor float64) { ww.w.SetZoom(factor) }
func (ww *wailsWindow) ZoomIn() { ww.w.ZoomIn() }
func (ww *wailsWindow) ZoomOut() { ww.w.ZoomOut() }
func (ww *wailsWindow) SetURL(url string) { ww.w.SetURL(url) }
func (ww *wailsWindow) SetHTML(html string) { ww.w.SetHTML(html) }
func (ww *wailsWindow) ExecJS(js string) { ww.w.ExecJS(js) }
func (ww *wailsWindow) Fullscreen() { ww.w.Fullscreen() }
func (ww *wailsWindow) UnFullscreen() { ww.w.UnFullscreen() }
func (ww *wailsWindow) GetZoom() float64 { return ww.w.GetZoom() }
func (ww *wailsWindow) SetZoom(factor float64) { ww.w.SetZoom(factor) }
func (ww *wailsWindow) ZoomIn() { ww.w.ZoomIn() }
func (ww *wailsWindow) ZoomOut() { ww.w.ZoomOut() }
func (ww *wailsWindow) SetURL(url string) { ww.w.SetURL(url) }
func (ww *wailsWindow) SetHTML(html string) { ww.w.SetHTML(html) }
func (ww *wailsWindow) ExecJS(js string) { ww.w.ExecJS(js) }
func (ww *wailsWindow) GetBounds() Bounds {
r := ww.w.Bounds()
return Bounds{X: r.X, Y: r.Y, Width: r.Width, Height: r.Height}
@ -101,8 +101,9 @@ func (ww *wailsWindow) GetBounds() Bounds {
func (ww *wailsWindow) SetBounds(b Bounds) {
ww.w.SetBounds(application.Rect{X: b.X, Y: b.Y, Width: b.Width, Height: b.Height})
}
func (ww *wailsWindow) ToggleFullscreen() { ww.w.ToggleFullscreen() }
func (ww *wailsWindow) Print() error { return ww.w.Print() }
func (ww *wailsWindow) ToggleFullscreen() { ww.w.ToggleFullscreen() }
func (ww *wailsWindow) OpenDevTools() { ww.w.OpenDevTools() }
func (ww *wailsWindow) Print() error { return ww.w.Print() }
func (ww *wailsWindow) Flash(enabled bool) { ww.w.Flash(enabled) }
func (ww *wailsWindow) OnWindowEvent(handler func(event WindowEvent)) {