Expose layout stack and workflow actions
This commit is contained in:
parent
3c5c109c3a
commit
3413b64f6c
7 changed files with 193 additions and 0 deletions
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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 }
|
||||
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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")}})
|
||||
|
|
|
|||
|
|
@ -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))
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue