diff --git a/go.mod b/go.mod index 8362937..69c2627 100644 --- a/go.mod +++ b/go.mod @@ -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 diff --git a/go.sum b/go.sum index cf4254d..0618491 100644 --- a/go.sum +++ b/go.sum @@ -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= diff --git a/pkg/display/FEATURES.md b/pkg/display/FEATURES.md index f336a61..716aaab 100644 --- a/pkg/display/FEATURES.md +++ b/pkg/display/FEATURES.md @@ -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 diff --git a/pkg/display/display.go b/pkg/display/display.go index 3ea8329..a5c3580 100644 --- a/pkg/display/display.go +++ b/pkg/display/display.go @@ -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 diff --git a/pkg/environment/messages.go b/pkg/environment/messages.go index 2beaedd..01fe610 100644 --- a/pkg/environment/messages.go +++ b/pkg/environment/messages.go @@ -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{}) diff --git a/pkg/environment/service.go b/pkg/environment/service.go index 6cc4192..5c9f084 100644 --- a/pkg/environment/service.go +++ b/pkg/environment/service.go @@ -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 +} diff --git a/pkg/environment/service_test.go b/pkg/environment/service_test.go index d5dae91..71e4d93 100644 --- a/pkg/environment/service_test.go +++ b/pkg/environment/service_test.go @@ -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{}) diff --git a/pkg/mcp/layout_helpers.go b/pkg/mcp/layout_helpers.go new file mode 100644 index 0000000..3e5e6ea --- /dev/null +++ b/pkg/mcp/layout_helpers.go @@ -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 +} diff --git a/pkg/mcp/tools_environment.go b/pkg/mcp/tools_environment.go index c8fc831..14e36e0 100644 --- a/pkg/mcp/tools_environment.go +++ b/pkg/mcp/tools_environment.go @@ -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) } diff --git a/pkg/mcp/tools_layout.go b/pkg/mcp/tools_layout.go index 1719ce5..d72506f 100644 --- a/pkg/mcp/tools_layout.go +++ b/pkg/mcp/tools_layout.go @@ -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) } diff --git a/pkg/mcp/tools_notification.go b/pkg/mcp/tools_notification.go index 0b965d3..fa8959b 100644 --- a/pkg/mcp/tools_notification.go +++ b/pkg/mcp/tools_notification.go @@ -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) } diff --git a/pkg/mcp/tools_screen.go b/pkg/mcp/tools_screen.go index 7f86e7e..d351f8a 100644 --- a/pkg/mcp/tools_screen.go +++ b/pkg/mcp/tools_screen.go @@ -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) } diff --git a/pkg/mcp/tools_tray.go b/pkg/mcp/tools_tray.go index d5efb45..f1dfa49 100644 --- a/pkg/mcp/tools_tray.go +++ b/pkg/mcp/tools_tray.go @@ -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) } diff --git a/pkg/mcp/tools_webview.go b/pkg/mcp/tools_webview.go index 923fe36..467d7f1 100644 --- a/pkg/mcp/tools_webview.go +++ b/pkg/mcp/tools_webview.go @@ -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) } diff --git a/pkg/mcp/tools_window.go b/pkg/mcp/tools_window.go index 677c2ac..88179f1 100644 --- a/pkg/mcp/tools_window.go +++ b/pkg/mcp/tools_window.go @@ -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) } diff --git a/pkg/notification/platform.go b/pkg/notification/platform.go index 57639ce..a5d0f73 100644 --- a/pkg/notification/platform.go +++ b/pkg/notification/platform.go @@ -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. diff --git a/pkg/webview/messages.go b/pkg/webview/messages.go index 4681e28..d90183b 100644 --- a/pkg/webview/messages.go +++ b/pkg/webview/messages.go @@ -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) --- diff --git a/pkg/window/messages.go b/pkg/window/messages.go index 92ce6b0..697b5a5 100644 --- a/pkg/window/messages.go +++ b/pkg/window/messages.go @@ -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 } diff --git a/pkg/window/mock_platform.go b/pkg/window/mock_platform.go index 941adf1..f27f9aa 100644 --- a/pkg/window/mock_platform.go +++ b/pkg/window/mock_platform.go @@ -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) } diff --git a/pkg/window/mock_test.go b/pkg/window/mock_test.go index 046338f..3d118cc 100644 --- a/pkg/window/mock_test.go +++ b/pkg/window/mock_test.go @@ -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) diff --git a/pkg/window/platform.go b/pkg/window/platform.go index 24daee0..b7cfa38 100644 --- a/pkg/window/platform.go +++ b/pkg/window/platform.go @@ -72,6 +72,7 @@ type PlatformWindow interface { // Extras ToggleFullscreen() + OpenDevTools() Print() error Flash(enabled bool) diff --git a/pkg/window/service.go b/pkg/window/service.go index 9006141..12cdfb1 100644 --- a/pkg/window/service.go +++ b/pkg/window/service.go @@ -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)) diff --git a/pkg/window/service_test.go b/pkg/window/service_test.go index 76d58c6..a4d6394 100644 --- a/pkg/window/service_test.go +++ b/pkg/window/service_test.go @@ -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) { diff --git a/pkg/window/wails.go b/pkg/window/wails.go index aa4f06f..2dd0de3 100644 --- a/pkg/window/wails.go +++ b/pkg/window/wails.go @@ -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)) {