diff --git a/pkg/display/display.go b/pkg/display/display.go index 46c4826..4785725 100644 --- a/pkg/display/display.go +++ b/pkg/display/display.go @@ -485,6 +485,43 @@ func (s *Service) handleWSMessage(msg WSMessage) (any, bool, error) { Editor: editor, Window: windowName, }) + case "layout:stack": + offsetX, _ := msg.Data["offsetX"].(float64) + offsetY, _ := msg.Data["offsetY"].(float64) + var names []string + if raw, ok := msg.Data["windows"].([]any); ok { + for _, v := range raw { + if name, ok := v.(string); ok && name != "" { + names = append(names, name) + } + } + } + result, handled, err = s.Core().PERFORM(window.TaskStackWindows{ + Windows: names, + OffsetX: int(offsetX), + OffsetY: int(offsetY), + }) + case "layout:workflow": + workflowName, e := wsRequire(msg.Data, "workflow") + if e != nil { + return nil, false, e + } + workflow, ok := window.ParseWorkflowLayout(workflowName) + if !ok { + return nil, false, fmt.Errorf("ws: unknown workflow %q", workflowName) + } + var names []string + if raw, ok := msg.Data["windows"].([]any); ok { + for _, v := range raw { + if name, ok := v.(string); ok && name != "" { + names = append(names, name) + } + } + } + result, handled, err = s.Core().PERFORM(window.TaskApplyWorkflow{ + Workflow: workflow, + Windows: names, + }) case "layout:suggest": windowCount := 0 if count, ok := msg.Data["windowCount"].(float64); ok { diff --git a/pkg/display/display_test.go b/pkg/display/display_test.go index a99f280..42bad09 100644 --- a/pkg/display/display_test.go +++ b/pkg/display/display_test.go @@ -695,6 +695,31 @@ func TestHandleWSMessage_Extended_Good(t *testing.T) { assert.Equal(t, "side-by-side", suggestion.Mode) }) + t.Run("layout stack", func(t *testing.T) { + _, handled, err := svc.handleWSMessage(WSMessage{ + Action: "layout:stack", + Data: map[string]any{ + "windows": []any{"editor", "assistant"}, + "offsetX": 25, + "offsetY": 30, + }, + }) + require.NoError(t, err) + assert.True(t, handled) + }) + + t.Run("layout workflow", func(t *testing.T) { + _, handled, err := svc.handleWSMessage(WSMessage{ + Action: "layout:workflow", + Data: map[string]any{ + "workflow": "coding", + "windows": []any{"editor", "assistant"}, + }, + }) + require.NoError(t, err) + assert.True(t, handled) + }) + t.Run("clipboard image read", func(t *testing.T) { result, handled, err := svc.handleWSMessage(WSMessage{Action: "clipboard:read-image"}) require.NoError(t, err) diff --git a/pkg/mcp/tools_layout.go b/pkg/mcp/tools_layout.go index 2f65937..0ae9fc2 100644 --- a/pkg/mcp/tools_layout.go +++ b/pkg/mcp/tools_layout.go @@ -246,6 +246,54 @@ func (s *Subsystem) windowArrangePair(_ context.Context, _ *mcp.CallToolRequest, return nil, WindowArrangePairOutput{Success: true}, nil } +// --- layout_stack --- + +type LayoutStackInput struct { + Windows []string `json:"windows,omitempty"` + OffsetX int `json:"offsetX,omitempty"` + OffsetY int `json:"offsetY,omitempty"` +} +type LayoutStackOutput struct { + Success bool `json:"success"` +} + +func (s *Subsystem) layoutStack(_ context.Context, _ *mcp.CallToolRequest, input LayoutStackInput) (*mcp.CallToolResult, LayoutStackOutput, error) { + _, _, err := s.core.PERFORM(window.TaskStackWindows{ + Windows: input.Windows, + OffsetX: input.OffsetX, + OffsetY: input.OffsetY, + }) + if err != nil { + return nil, LayoutStackOutput{}, err + } + return nil, LayoutStackOutput{Success: true}, nil +} + +// --- layout_workflow --- + +type LayoutWorkflowInput struct { + Workflow string `json:"workflow"` + Windows []string `json:"windows,omitempty"` +} +type LayoutWorkflowOutput struct { + Success bool `json:"success"` +} + +func (s *Subsystem) layoutWorkflow(_ context.Context, _ *mcp.CallToolRequest, input LayoutWorkflowInput) (*mcp.CallToolResult, LayoutWorkflowOutput, error) { + workflow, ok := window.ParseWorkflowLayout(input.Workflow) + if !ok { + return nil, LayoutWorkflowOutput{}, fmt.Errorf("unknown workflow: %s", input.Workflow) + } + _, _, err := s.core.PERFORM(window.TaskApplyWorkflow{ + Workflow: workflow, + Windows: input.Windows, + }) + if err != nil { + return nil, LayoutWorkflowOutput{}, err + } + return nil, LayoutWorkflowOutput{Success: true}, nil +} + // --- Registration --- func (s *Subsystem) registerLayoutTools(server *mcp.Server) { @@ -260,6 +308,8 @@ func (s *Subsystem) registerLayoutTools(server *mcp.Server) { mcp.AddTool(server, &mcp.Tool{Name: "layout_suggest", Description: "Suggest an optimal layout for the current screen"}, s.layoutSuggest) mcp.AddTool(server, &mcp.Tool{Name: "screen_find_space", Description: "Find an empty area for a new window"}, s.screenFindSpace) mcp.AddTool(server, &mcp.Tool{Name: "window_arrange_pair", Description: "Arrange two windows side-by-side"}, s.windowArrangePair) + mcp.AddTool(server, &mcp.Tool{Name: "layout_stack", Description: "Cascade windows with an offset"}, s.layoutStack) + mcp.AddTool(server, &mcp.Tool{Name: "layout_workflow", Description: "Apply a predefined workflow layout"}, s.layoutWorkflow) } func primaryScreenSize(c *core.Core) (int, int) { diff --git a/pkg/window/messages.go b/pkg/window/messages.go index dea640c..814250e 100644 --- a/pkg/window/messages.go +++ b/pkg/window/messages.go @@ -130,6 +130,19 @@ type TaskBesideEditor struct { Window string } +// TaskStackWindows cascades windows with a shared offset. +type TaskStackWindows struct { + Windows []string + OffsetX int + OffsetY int +} + +// TaskApplyWorkflow applies a predefined workflow layout to windows. +type TaskApplyWorkflow struct { + Workflow WorkflowLayout + Windows []string +} + // TaskSaveConfig persists this service's config section via the display orchestrator. type TaskSaveConfig struct{ Value map[string]any } diff --git a/pkg/window/service.go b/pkg/window/service.go index a1701d5..fa5f97c 100644 --- a/pkg/window/service.go +++ b/pkg/window/service.go @@ -161,6 +161,10 @@ func (s *Service) handleTask(c *core.Core, t core.Task) (any, bool, error) { return nil, true, s.taskArrangePair(t.First, t.Second) case TaskBesideEditor: return nil, true, s.taskBesideEditor(t.Editor, t.Window) + case TaskStackWindows: + return nil, true, s.taskStackWindows(t.Windows, t.OffsetX, t.OffsetY) + case TaskApplyWorkflow: + return nil, true, s.taskApplyWorkflow(t.Workflow, t.Windows) default: return nil, false, nil } @@ -408,6 +412,21 @@ func (s *Service) taskBesideEditor(editorName, windowName string) error { return s.manager.BesideEditor(editorName, windowName, screenW, screenH) } +func (s *Service) taskStackWindows(names []string, offsetX, offsetY int) error { + if len(names) == 0 { + names = s.manager.List() + } + return s.manager.StackWindows(names, offsetX, offsetY) +} + +func (s *Service) taskApplyWorkflow(workflow WorkflowLayout, names []string) error { + screenW, screenH := s.primaryScreenSize() + if len(names) == 0 { + names = s.manager.List() + } + return s.manager.ApplyWorkflow(workflow, names, screenW, screenH) +} + func (s *Service) detectEditorWindow() string { for _, info := range s.queryWindowList() { if looksLikeEditor(info.Name, info.Title) { diff --git a/pkg/window/service_test.go b/pkg/window/service_test.go index 0752c0d..6b41a3f 100644 --- a/pkg/window/service_test.go +++ b/pkg/window/service_test.go @@ -141,6 +141,45 @@ func TestTaskSetSize_Good(t *testing.T) { assert.Equal(t, 600, info.Height) } +func TestTaskStackWindows_Good(t *testing.T) { + _, c := newTestWindowService(t) + _, _, _ = c.PERFORM(TaskOpenWindow{Opts: []WindowOption{WithName("one")}}) + _, _, _ = c.PERFORM(TaskOpenWindow{Opts: []WindowOption{WithName("two")}}) + + _, handled, err := c.PERFORM(TaskStackWindows{ + Windows: []string{"one", "two"}, + OffsetX: 20, + OffsetY: 30, + }) + require.NoError(t, err) + assert.True(t, handled) + + result, _, _ := c.QUERY(QueryWindowByName{Name: "two"}) + info := result.(*WindowInfo) + assert.Equal(t, 20, info.X) + assert.Equal(t, 30, info.Y) +} + +func TestTaskApplyWorkflow_Good(t *testing.T) { + _, c := newTestWindowService(t) + _, _, _ = c.PERFORM(TaskOpenWindow{Opts: []WindowOption{WithName("editor")}}) + _, _, _ = c.PERFORM(TaskOpenWindow{Opts: []WindowOption{WithName("assistant")}}) + + _, handled, err := c.PERFORM(TaskApplyWorkflow{ + Workflow: WorkflowCoding, + Windows: []string{"editor", "assistant"}, + }) + require.NoError(t, err) + assert.True(t, handled) + + editorResult, _, _ := c.QUERY(QueryWindowByName{Name: "editor"}) + assistantResult, _, _ := c.QUERY(QueryWindowByName{Name: "assistant"}) + editor := editorResult.(*WindowInfo) + assistant := assistantResult.(*WindowInfo) + assert.Greater(t, editor.Width, assistant.Width) + assert.Equal(t, editor.Width, assistant.X) +} + func TestTaskMaximise_Good(t *testing.T) { _, c := newTestWindowService(t) _, _, _ = c.PERFORM(TaskOpenWindow{Opts: []WindowOption{WithName("test")}}) diff --git a/pkg/window/tiling.go b/pkg/window/tiling.go index 40669fe..ff1c6fe 100644 --- a/pkg/window/tiling.go +++ b/pkg/window/tiling.go @@ -61,6 +61,16 @@ var workflowNames = map[WorkflowLayout]string{ func (w WorkflowLayout) String() string { return workflowNames[w] } +// ParseWorkflowLayout converts a workflow name into its enum value. +func ParseWorkflowLayout(name string) (WorkflowLayout, bool) { + for workflow, workflowName := range workflowNames { + if workflowName == name { + return workflow, true + } + } + return WorkflowCoding, false +} + // TileWindows arranges the named windows in the given mode across the screen area. func (m *Manager) TileWindows(mode TileMode, names []string, screenW, screenH int) error { windows := make([]PlatformWindow, 0, len(names))