Expose layout stack and workflow actions

This commit is contained in:
Virgil 2026-04-02 13:23:17 +00:00
parent 3c5c109c3a
commit 3413b64f6c
7 changed files with 193 additions and 0 deletions

View file

@ -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 {

View file

@ -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)

View file

@ -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) {

View file

@ -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 }

View file

@ -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) {

View file

@ -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")}})

View file

@ -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))