diff --git a/docs/superpowers/plans/2026-03-13-display-package-split.md b/docs/superpowers/plans/2026-03-13-display-package-split.md deleted file mode 100644 index fe7d1dd..0000000 --- a/docs/superpowers/plans/2026-03-13-display-package-split.md +++ /dev/null @@ -1,3150 +0,0 @@ -# CoreGUI Display Package Split — Implementation Plan - -> **For agentic workers:** REQUIRED: Use superpowers:subagent-driven-development (if subagents available) or superpowers:executing-plans to implement this plan. Steps use checkbox (`- [ ]`) syntax for tracking. - -**Goal:** Split `pkg/display/` monolith (3,910 LOC, 15 files) into 4 focused packages: `pkg/window`, `pkg/systray`, `pkg/menu`, and a slimmed `pkg/display` orchestrator. - -**Architecture:** Each sub-package defines a `Platform` interface insulating Wails v3. The orchestrator (`pkg/display`) composes `window.Manager`, `systray.Manager`, `menu.Manager` and owns the WebSocket event bridge, dialogs, clipboard, notifications, and theme. No circular dependencies — sub-packages are peers. - -**Tech Stack:** Go 1.26, Wails v3 alpha-74, gorilla/websocket, testify, core/go DI - -**Spec:** `docs/superpowers/specs/2026-03-13-display-package-split-design.md` - ---- - -## File Map - -### New files to create - -| Package | File | Responsibility | -|---------|------|---------------| -| `pkg/window` | `platform.go` | `Platform`, `PlatformWindow`, `PlatformWindowOptions`, `WindowEvent` interfaces | -| `pkg/window` | `window.go` | `Window` struct (own type), `Manager` struct, CRUD | -| `pkg/window` | `options.go` | `WindowOption` functional options against `Window` | -| `pkg/window` | `state.go` | `WindowStateManager` — JSON persistence | -| `pkg/window` | `layout.go` | `LayoutManager` — named arrangements | -| `pkg/window` | `tiling.go` | `TileMode`, `SnapPosition`, tiling/snapping/stacking/workflows | -| `pkg/window` | `wails.go` | Wails adapter implementing `Platform` + `PlatformWindow` | -| `pkg/window` | `mock_test.go` | `mockPlatform` + `mockWindow` | -| `pkg/window` | `window_test.go` | All window tests | -| `pkg/systray` | `platform.go` | `Platform`, `PlatformTray`, `PlatformMenu` interfaces | -| `pkg/systray` | `types.go` | `TrayMenuItem` struct | -| `pkg/systray` | `tray.go` | `Manager` struct, lifecycle, icon, tooltip, label | -| `pkg/systray` | `menu.go` | Dynamic menu builder, callback registry | -| `pkg/systray` | `wails.go` | Wails adapter | -| `pkg/systray` | `mock_test.go` | `mockPlatform` + `mockTray` | -| `pkg/systray` | `tray_test.go` | All systray tests | -| `pkg/menu` | `platform.go` | `Platform`, `PlatformMenu`, `PlatformMenuItem` interfaces | -| `pkg/menu` | `menu.go` | `Manager` struct, `MenuItem`, builder (structure only) | -| `pkg/menu` | `wails.go` | Wails adapter | -| `pkg/menu` | `mock_test.go` | `mockPlatform` + `mockMenu` | -| `pkg/menu` | `menu_test.go` | All menu tests | - -### Files to modify - -| File | Change | -|------|--------| -| `pkg/display/display.go` | Remove window CRUD/tiling/snapping/workflows (~800 LOC). Compose sub-managers. | -| `pkg/display/interfaces.go` | Remove migrated interfaces. Keep `DialogManager`, `EnvManager`, `EventManager`, `Logger`. | -| `pkg/display/window.go` | DELETE — replaced by `pkg/window/options.go` | -| `pkg/display/window_state.go` | DELETE — replaced by `pkg/window/state.go` | -| `pkg/display/layout.go` | DELETE — replaced by `pkg/window/layout.go` | -| `pkg/display/tray.go` | DELETE — replaced by `pkg/systray/tray.go` | -| `pkg/display/menu.go` | DELETE — handlers stay in display.go, structure moves to `pkg/menu` | -| `pkg/display/actions.go` | Update `ActionOpenWindow` to use `window.Window` not Wails type | -| `pkg/display/events.go` | `AttachWindowListeners` accepts `window.PlatformWindow`, add `EventSource` interface | -| `pkg/display/display_test.go` | Update for new imports, split window tests to `pkg/window` | -| `pkg/display/mocks_test.go` | Remove migrated mocks, keep display-level mocks | - -### Files to move - -| From | To | -|------|-----| -| `pkg/display/ui/` (entire dir) | `ui/` (top-level) | -| `pkg/display/assets/apptray.png` | `pkg/systray/assets/apptray.png` | - -### New shared types file - -| File | Types | -|------|-------| -| `pkg/display/types.go` | `WindowHandle` interface, `ScreenInfo`, `WorkArea` structs | - ---- - -## Chunk 1: pkg/window - -### Task 1: Platform interfaces and mock - -**Files:** -- Create: `pkg/window/platform.go` -- Create: `pkg/window/mock_test.go` - -- [ ] **Step 1: Write platform.go** - -```go -// pkg/window/platform.go -package window - -// Platform abstracts the windowing backend (Wails v3). -type Platform interface { - CreateWindow(opts PlatformWindowOptions) PlatformWindow - GetWindows() []PlatformWindow -} - -// PlatformWindowOptions are the backend-specific options passed to CreateWindow. -type PlatformWindowOptions struct { - Name string - Title string - URL string - Width, Height int - X, Y int - MinWidth, MinHeight int - MaxWidth, MaxHeight int - Frameless bool - Hidden bool - AlwaysOnTop bool - BackgroundColour [4]uint8 // RGBA - DisableResize bool - EnableDragAndDrop bool - Centered bool -} - -// PlatformWindow is a live window handle from the backend. -type PlatformWindow interface { - // Identity - Name() string - - // Queries - Position() (int, int) - Size() (int, int) - IsMaximised() bool - IsFocused() bool - - // Mutations - SetTitle(title string) - SetPosition(x, y int) - SetSize(width, height int) - SetBackgroundColour(r, g, b, a uint8) - SetVisibility(visible bool) - SetAlwaysOnTop(alwaysOnTop bool) - - // Window state - Maximise() - Restore() - Minimise() - Focus() - Close() - Show() - Hide() - Fullscreen() - UnFullscreen() - - // Events - OnWindowEvent(handler func(event WindowEvent)) -} - -// WindowEvent is emitted by the backend for window state changes. -type WindowEvent struct { - Type string // "focus", "blur", "move", "resize", "close" - Name string // window name - Data map[string]any -} -``` - -- [ ] **Step 2: Write mock_test.go** - -```go -// pkg/window/mock_test.go -package window - -type mockPlatform struct { - windows []*mockWindow -} - -func newMockPlatform() *mockPlatform { - return &mockPlatform{} -} - -func (m *mockPlatform) CreateWindow(opts PlatformWindowOptions) PlatformWindow { - w := &mockWindow{ - name: opts.Name, title: opts.Title, url: opts.URL, - width: opts.Width, height: opts.Height, - x: opts.X, y: opts.Y, - } - m.windows = append(m.windows, w) - return w -} - -func (m *mockPlatform) GetWindows() []PlatformWindow { - out := make([]PlatformWindow, len(m.windows)) - for i, w := range m.windows { - out[i] = w - } - return out -} - -type mockWindow struct { - name, title, url string - width, height, x, y int - maximised, focused bool - visible, alwaysOnTop bool - closed bool - eventHandlers []func(WindowEvent) -} - -func (w *mockWindow) Name() string { return w.name } -func (w *mockWindow) Position() (int, int) { return w.x, w.y } -func (w *mockWindow) Size() (int, int) { return w.width, w.height } -func (w *mockWindow) IsMaximised() bool { return w.maximised } -func (w *mockWindow) IsFocused() bool { return w.focused } -func (w *mockWindow) SetTitle(title string) { w.title = title } -func (w *mockWindow) SetPosition(x, y int) { w.x = x; w.y = y } -func (w *mockWindow) SetSize(width, height int) { w.width = width; w.height = height } -func (w *mockWindow) SetBackgroundColour(r, g, b, a uint8) {} -func (w *mockWindow) SetVisibility(visible bool) { w.visible = visible } -func (w *mockWindow) SetAlwaysOnTop(alwaysOnTop bool) { w.alwaysOnTop = alwaysOnTop } -func (w *mockWindow) Maximise() { w.maximised = true } -func (w *mockWindow) Restore() { w.maximised = false } -func (w *mockWindow) Minimise() {} -func (w *mockWindow) Focus() { w.focused = true } -func (w *mockWindow) Close() { w.closed = true } -func (w *mockWindow) Show() { w.visible = true } -func (w *mockWindow) Hide() { w.visible = false } -func (w *mockWindow) Fullscreen() {} -func (w *mockWindow) UnFullscreen() {} -func (w *mockWindow) OnWindowEvent(handler func(WindowEvent)) { w.eventHandlers = append(w.eventHandlers, handler) } - -// emit fires a test event to all registered handlers. -func (w *mockWindow) emit(e WindowEvent) { - for _, h := range w.eventHandlers { - h(e) - } -} -``` - -- [ ] **Step 3: Verify compilation** - -Run: `cd /Users/snider/Code/core/gui && go build ./pkg/window/...` -Expected: SUCCESS (no test binary, just compile check) - -- [ ] **Step 4: Commit** - -```bash -git add pkg/window/platform.go pkg/window/mock_test.go -git commit -m "feat(window): add Platform and PlatformWindow interfaces" -``` - ---- - -### Task 2: Window struct, options, and Manager - -**Files:** -- Create: `pkg/window/window.go` -- Create: `pkg/window/options.go` -- Create: `pkg/window/window_test.go` - -- [ ] **Step 1: Write window_test.go — Window struct and option tests** - -```go -// pkg/window/window_test.go -package window - -import ( - "testing" - - "github.com/stretchr/testify/assert" - "github.com/stretchr/testify/require" -) - -func TestWindowDefaults(t *testing.T) { - w := &Window{} - assert.Equal(t, "", w.Name) - assert.Equal(t, 0, w.Width) -} - -func TestWindowOption_Name_Good(t *testing.T) { - w := &Window{} - err := WithName("main")(w) - require.NoError(t, err) - assert.Equal(t, "main", w.Name) -} - -func TestWindowOption_Title_Good(t *testing.T) { - w := &Window{} - err := WithTitle("My App")(w) - require.NoError(t, err) - assert.Equal(t, "My App", w.Title) -} - -func TestWindowOption_URL_Good(t *testing.T) { - w := &Window{} - err := WithURL("/dashboard")(w) - require.NoError(t, err) - assert.Equal(t, "/dashboard", w.URL) -} - -func TestWindowOption_Size_Good(t *testing.T) { - w := &Window{} - err := WithSize(1280, 720)(w) - require.NoError(t, err) - assert.Equal(t, 1280, w.Width) - assert.Equal(t, 720, w.Height) -} - -func TestWindowOption_Position_Good(t *testing.T) { - w := &Window{} - err := WithPosition(100, 200)(w) - require.NoError(t, err) - assert.Equal(t, 100, w.X) - assert.Equal(t, 200, w.Y) -} - -func TestApplyOptions_Good(t *testing.T) { - w, err := ApplyOptions( - WithName("test"), - WithTitle("Test Window"), - WithURL("/test"), - WithSize(800, 600), - ) - require.NoError(t, err) - assert.Equal(t, "test", w.Name) - assert.Equal(t, "Test Window", w.Title) - assert.Equal(t, "/test", w.URL) - assert.Equal(t, 800, w.Width) - assert.Equal(t, 600, w.Height) -} - -func TestApplyOptions_Bad(t *testing.T) { - _, err := ApplyOptions(func(w *Window) error { - return assert.AnError - }) - assert.Error(t, err) -} - -func TestApplyOptions_Empty_Good(t *testing.T) { - w, err := ApplyOptions() - require.NoError(t, err) - assert.NotNil(t, w) -} -``` - -- [ ] **Step 2: Run test to verify it fails** - -Run: `cd /Users/snider/Code/core/gui && go test ./pkg/window/... -v` -Expected: FAIL — `Window`, `WithName`, `ApplyOptions` etc. undefined - -- [ ] **Step 3: Write window.go — Window struct and Manager** - -```go -// pkg/window/window.go -package window - -import ( - "fmt" - "sync" -) - -// Window is CoreGUI's own window descriptor — NOT a Wails type alias. -type Window struct { - Name string - Title string - URL string - Width, Height int - X, Y int - MinWidth, MinHeight int - MaxWidth, MaxHeight int - Frameless bool - Hidden bool - AlwaysOnTop bool - BackgroundColour [4]uint8 - DisableResize bool - EnableDragAndDrop bool - Centered bool -} - -// ToPlatformOptions converts a Window to PlatformWindowOptions for the backend. -func (w *Window) ToPlatformOptions() PlatformWindowOptions { - return PlatformWindowOptions{ - Name: w.Name, Title: w.Title, URL: w.URL, - Width: w.Width, Height: w.Height, X: w.X, Y: w.Y, - MinWidth: w.MinWidth, MinHeight: w.MinHeight, - MaxWidth: w.MaxWidth, MaxHeight: w.MaxHeight, - Frameless: w.Frameless, Hidden: w.Hidden, - AlwaysOnTop: w.AlwaysOnTop, BackgroundColour: w.BackgroundColour, - DisableResize: w.DisableResize, EnableDragAndDrop: w.EnableDragAndDrop, - Centered: w.Centered, - } -} - -// Manager manages window lifecycle through a Platform backend. -type Manager struct { - platform Platform - state *StateManager - layout *LayoutManager - windows map[string]PlatformWindow - mu sync.RWMutex -} - -// NewManager creates a window Manager with the given platform backend. -func NewManager(platform Platform) *Manager { - return &Manager{ - platform: platform, - state: NewStateManager(), - layout: NewLayoutManager(), - windows: make(map[string]PlatformWindow), - } -} - -// Open creates a window using functional options, applies saved state, and tracks it. -func (m *Manager) Open(opts ...WindowOption) (PlatformWindow, error) { - w, err := ApplyOptions(opts...) - if err != nil { - return nil, fmt.Errorf("window.Manager.Open: %w", err) - } - return m.Create(w) -} - -// Create creates a window from a Window descriptor. -func (m *Manager) Create(w *Window) (PlatformWindow, error) { - if w.Name == "" { - w.Name = "main" - } - if w.Title == "" { - w.Title = "Core" - } - if w.Width == 0 { - w.Width = 1280 - } - if w.Height == 0 { - w.Height = 800 - } - if w.URL == "" { - w.URL = "/" - } - - // Apply saved state if available - m.state.ApplyState(w) - - pw := m.platform.CreateWindow(w.ToPlatformOptions()) - - m.mu.Lock() - m.windows[w.Name] = pw - m.mu.Unlock() - - return pw, nil -} - -// Get returns a tracked window by name. -func (m *Manager) Get(name string) (PlatformWindow, bool) { - m.mu.RLock() - defer m.mu.RUnlock() - pw, ok := m.windows[name] - return pw, ok -} - -// List returns all tracked window names. -func (m *Manager) List() []string { - m.mu.RLock() - defer m.mu.RUnlock() - names := make([]string, 0, len(m.windows)) - for name := range m.windows { - names = append(names, name) - } - return names -} - -// Remove stops tracking a window by name. -func (m *Manager) Remove(name string) { - m.mu.Lock() - delete(m.windows, name) - m.mu.Unlock() -} - -// Platform returns the underlying platform for direct access. -func (m *Manager) Platform() Platform { - return m.platform -} - -// State returns the state manager for window persistence. -func (m *Manager) State() *StateManager { - return m.state -} - -// Layout returns the layout manager. -func (m *Manager) Layout() *LayoutManager { - return m.layout -} -``` - -- [ ] **Step 4: Write options.go — WindowOption functional options** - -```go -// pkg/window/options.go -package window - -// WindowOption is a functional option applied to a Window descriptor. -type WindowOption func(*Window) error - -// ApplyOptions creates a Window and applies all options in order. -func ApplyOptions(opts ...WindowOption) (*Window, error) { - w := &Window{} - for _, opt := range opts { - if opt == nil { - continue - } - if err := opt(w); err != nil { - return nil, err - } - } - return w, nil -} - -func WithName(name string) WindowOption { - return func(w *Window) error { w.Name = name; return nil } -} - -func WithTitle(title string) WindowOption { - return func(w *Window) error { w.Title = title; return nil } -} - -func WithURL(url string) WindowOption { - return func(w *Window) error { w.URL = url; return nil } -} - -func WithSize(width, height int) WindowOption { - return func(w *Window) error { w.Width = width; w.Height = height; return nil } -} - -func WithPosition(x, y int) WindowOption { - return func(w *Window) error { w.X = x; w.Y = y; return nil } -} - -func WithMinSize(width, height int) WindowOption { - return func(w *Window) error { w.MinWidth = width; w.MinHeight = height; return nil } -} - -func WithMaxSize(width, height int) WindowOption { - return func(w *Window) error { w.MaxWidth = width; w.MaxHeight = height; return nil } -} - -func WithFrameless(frameless bool) WindowOption { - return func(w *Window) error { w.Frameless = frameless; return nil } -} - -func WithHidden(hidden bool) WindowOption { - return func(w *Window) error { w.Hidden = hidden; return nil } -} - -func WithAlwaysOnTop(alwaysOnTop bool) WindowOption { - return func(w *Window) error { w.AlwaysOnTop = alwaysOnTop; return nil } -} - -func WithBackgroundColour(r, g, b, a uint8) WindowOption { - return func(w *Window) error { w.BackgroundColour = [4]uint8{r, g, b, a}; return nil } -} - -func WithCentered(centered bool) WindowOption { - return func(w *Window) error { w.Centered = centered; return nil } -} -``` - -- [ ] **Step 5: Run tests** - -Run: `cd /Users/snider/Code/core/gui && go test ./pkg/window/... -v` -Expected: PASS (some tests will fail on StateManager — that's Task 3) - -Note: `NewStateManager` and `NewLayoutManager` are referenced but not yet created. Add stubs: - -```go -// Temporary stubs in window.go (remove after Task 3) -// func NewStateManager() *StateManager { return &StateManager{} } -// func NewLayoutManager() *LayoutManager { return &LayoutManager{} } -``` - -Actually — write minimal stubs in state.go and layout.go so tests pass: - -- [ ] **Step 5a: Write minimal state.go stub** - -```go -// pkg/window/state.go -package window - -// StateManager persists window positions to disk. -// Full implementation in Task 3. -type StateManager struct{} - -func NewStateManager() *StateManager { return &StateManager{} } - -// ApplyState restores saved position/size to a Window descriptor. -func (sm *StateManager) ApplyState(w *Window) {} -``` - -- [ ] **Step 5b: Write minimal layout.go stub** - -```go -// pkg/window/layout.go -package window - -// LayoutManager persists named window arrangements. -// Full implementation in Task 3. -type LayoutManager struct{} - -func NewLayoutManager() *LayoutManager { return &LayoutManager{} } -``` - -- [ ] **Step 6: Add Manager tests to window_test.go** - -Append to `pkg/window/window_test.go`: - -```go -// newTestManager creates a Manager with a mock platform for testing. -func newTestManager() (*Manager, *mockPlatform) { - p := newMockPlatform() - return NewManager(p), p -} - -func TestManager_Open_Good(t *testing.T) { - m, p := newTestManager() - pw, err := m.Open(WithName("test"), WithTitle("Test"), WithURL("/test"), WithSize(800, 600)) - require.NoError(t, err) - assert.NotNil(t, pw) - assert.Equal(t, "test", pw.Name()) - assert.Len(t, p.windows, 1) -} - -func TestManager_Open_Defaults_Good(t *testing.T) { - m, _ := newTestManager() - pw, err := m.Open() - require.NoError(t, err) - assert.Equal(t, "main", pw.Name()) - w, h := pw.Size() - assert.Equal(t, 1280, w) - assert.Equal(t, 800, h) -} - -func TestManager_Open_Bad(t *testing.T) { - m, _ := newTestManager() - _, err := m.Open(func(w *Window) error { return assert.AnError }) - assert.Error(t, err) -} - -func TestManager_Get_Good(t *testing.T) { - m, _ := newTestManager() - _, _ = m.Open(WithName("findme")) - pw, ok := m.Get("findme") - assert.True(t, ok) - assert.Equal(t, "findme", pw.Name()) -} - -func TestManager_Get_Bad(t *testing.T) { - m, _ := newTestManager() - _, ok := m.Get("nonexistent") - assert.False(t, ok) -} - -func TestManager_List_Good(t *testing.T) { - m, _ := newTestManager() - _, _ = m.Open(WithName("a")) - _, _ = m.Open(WithName("b")) - names := m.List() - assert.Len(t, names, 2) - assert.Contains(t, names, "a") - assert.Contains(t, names, "b") -} - -func TestManager_Remove_Good(t *testing.T) { - m, _ := newTestManager() - _, _ = m.Open(WithName("temp")) - m.Remove("temp") - _, ok := m.Get("temp") - assert.False(t, ok) -} -``` - -- [ ] **Step 7: Run tests** - -Run: `cd /Users/snider/Code/core/gui && go test ./pkg/window/... -v -count=1` -Expected: ALL PASS - -- [ ] **Step 8: Commit** - -```bash -git add pkg/window/ -git commit -m "feat(window): add Window struct, options, and Manager with CRUD" -``` - ---- - -### Task 3: State persistence - -**Files:** -- Modify: `pkg/window/state.go` (replace stub) -- Test: `pkg/window/window_test.go` (append state tests) - -- [ ] **Step 1: Write state tests** - -Append to `pkg/window/window_test.go`: - -```go -func TestStateManager_SetGet_Good(t *testing.T) { - sm := NewStateManager() - sm.configDir = t.TempDir() - state := WindowState{X: 100, Y: 200, Width: 800, Height: 600, Maximized: false} - sm.SetState("main", state) - got, ok := sm.GetState("main") - assert.True(t, ok) - assert.Equal(t, 100, got.X) - assert.Equal(t, 800, got.Width) -} - -func TestStateManager_SetGet_Bad(t *testing.T) { - sm := NewStateManager() - sm.configDir = t.TempDir() - _, ok := sm.GetState("nonexistent") - assert.False(t, ok) -} - -func TestStateManager_CaptureState_Good(t *testing.T) { - sm := NewStateManager() - sm.configDir = t.TempDir() - w := &mockWindow{name: "cap", x: 50, y: 60, width: 1024, height: 768, maximised: true} - sm.CaptureState(w) - got, ok := sm.GetState("cap") - assert.True(t, ok) - assert.Equal(t, 50, got.X) - assert.Equal(t, 1024, got.Width) - assert.True(t, got.Maximized) -} - -func TestStateManager_ApplyState_Good(t *testing.T) { - sm := NewStateManager() - sm.configDir = t.TempDir() - sm.SetState("win", WindowState{X: 10, Y: 20, Width: 640, Height: 480}) - w := &Window{Name: "win", Width: 1280, Height: 800} - sm.ApplyState(w) - assert.Equal(t, 10, w.X) - assert.Equal(t, 20, w.Y) - assert.Equal(t, 640, w.Width) - assert.Equal(t, 480, w.Height) -} - -func TestStateManager_ListStates_Good(t *testing.T) { - sm := NewStateManager() - sm.configDir = t.TempDir() - sm.SetState("a", WindowState{Width: 100}) - sm.SetState("b", WindowState{Width: 200}) - names := sm.ListStates() - assert.Len(t, names, 2) -} - -func TestStateManager_Clear_Good(t *testing.T) { - sm := NewStateManager() - sm.configDir = t.TempDir() - sm.SetState("a", WindowState{Width: 100}) - sm.Clear() - names := sm.ListStates() - assert.Empty(t, names) -} - -func TestStateManager_Persistence_Good(t *testing.T) { - dir := t.TempDir() - sm1 := NewStateManager() - sm1.configDir = dir - sm1.SetState("persist", WindowState{X: 42, Y: 84, Width: 500, Height: 300}) - sm1.ForceSync() - - sm2 := NewStateManager() - sm2.configDir = dir - sm2.load() - got, ok := sm2.GetState("persist") - assert.True(t, ok) - assert.Equal(t, 42, got.X) - assert.Equal(t, 500, got.Width) -} -``` - -- [ ] **Step 2: Run test to verify it fails** - -Run: `cd /Users/snider/Code/core/gui && go test ./pkg/window/... -run TestStateManager -v` -Expected: FAIL — stub has no real implementation - -- [ ] **Step 3: Replace state.go with full implementation** - -Migrate from `pkg/display/window_state.go` (262 LOC), changing: -- Accept `PlatformWindow` in `CaptureState` (not `*application.WebviewWindow`) -- Return/modify `*Window` in `ApplyState` (not `*application.WebviewWindowOptions`) -- Export `configDir` field for test injection - -```go -// pkg/window/state.go -package window - -import ( - "encoding/json" - "os" - "path/filepath" - "sync" - "time" -) - -// WindowState holds the persisted position/size of a window. -// JSON tags match existing window_state.json format for backward compat. -type WindowState struct { - X int `json:"x,omitempty"` - Y int `json:"y,omitempty"` - Width int `json:"width,omitempty"` - Height int `json:"height,omitempty"` - Maximized bool `json:"maximized,omitempty"` - Screen string `json:"screen,omitempty"` - URL string `json:"url,omitempty"` - UpdatedAt int64 `json:"updatedAt,omitempty"` -} - -// StateManager persists window positions to ~/.config/Core/window_state.json. -type StateManager struct { - configDir string - states map[string]WindowState - mu sync.RWMutex - saveTimer *time.Timer -} - -// NewStateManager creates a StateManager loading from the default config directory. -func NewStateManager() *StateManager { - sm := &StateManager{ - states: make(map[string]WindowState), - } - configDir, err := os.UserConfigDir() - if err == nil { - sm.configDir = filepath.Join(configDir, "Core") - } - sm.load() - return sm -} - -func (sm *StateManager) filePath() string { - return filepath.Join(sm.configDir, "window_state.json") -} - -func (sm *StateManager) load() { - if sm.configDir == "" { - return - } - data, err := os.ReadFile(sm.filePath()) - if err != nil { - return - } - sm.mu.Lock() - defer sm.mu.Unlock() - _ = json.Unmarshal(data, &sm.states) -} - -func (sm *StateManager) save() { - if sm.configDir == "" { - return - } - sm.mu.RLock() - data, err := json.MarshalIndent(sm.states, "", " ") - sm.mu.RUnlock() - if err != nil { - return - } - _ = os.MkdirAll(sm.configDir, 0o755) - _ = os.WriteFile(sm.filePath(), data, 0o644) -} - -func (sm *StateManager) scheduleSave() { - if sm.saveTimer != nil { - sm.saveTimer.Stop() - } - sm.saveTimer = time.AfterFunc(500*time.Millisecond, sm.save) -} - -// GetState returns the saved state for a window name. -func (sm *StateManager) GetState(name string) (WindowState, bool) { - sm.mu.RLock() - defer sm.mu.RUnlock() - s, ok := sm.states[name] - return s, ok -} - -// SetState saves state for a window name (debounced disk write). -func (sm *StateManager) SetState(name string, state WindowState) { - state.UpdatedAt = time.Now().UnixMilli() - sm.mu.Lock() - sm.states[name] = state - sm.mu.Unlock() - sm.scheduleSave() -} - -// UpdatePosition updates only the position fields. -func (sm *StateManager) UpdatePosition(name string, x, y int) { - sm.mu.Lock() - s := sm.states[name] - s.X = x - s.Y = y - s.UpdatedAt = time.Now().UnixMilli() - sm.states[name] = s - sm.mu.Unlock() - sm.scheduleSave() -} - -// UpdateSize updates only the size fields. -func (sm *StateManager) UpdateSize(name string, width, height int) { - sm.mu.Lock() - s := sm.states[name] - s.Width = width - s.Height = height - s.UpdatedAt = time.Now().UnixMilli() - sm.states[name] = s - sm.mu.Unlock() - sm.scheduleSave() -} - -// UpdateMaximized updates the maximized flag. -func (sm *StateManager) UpdateMaximized(name string, maximized bool) { - sm.mu.Lock() - s := sm.states[name] - s.Maximized = maximized - s.UpdatedAt = time.Now().UnixMilli() - sm.states[name] = s - sm.mu.Unlock() - sm.scheduleSave() -} - -// CaptureState snapshots the current state from a PlatformWindow. -func (sm *StateManager) CaptureState(pw PlatformWindow) { - x, y := pw.Position() - w, h := pw.Size() - sm.SetState(pw.Name(), WindowState{ - X: x, Y: y, Width: w, Height: h, - Maximized: pw.IsMaximised(), - }) -} - -// ApplyState restores saved position/size to a Window descriptor. -func (sm *StateManager) ApplyState(w *Window) { - s, ok := sm.GetState(w.Name) - if !ok { - return - } - if s.Width > 0 { - w.Width = s.Width - } - if s.Height > 0 { - w.Height = s.Height - } - w.X = s.X - w.Y = s.Y -} - -// ListStates returns all stored window names. -func (sm *StateManager) ListStates() []string { - sm.mu.RLock() - defer sm.mu.RUnlock() - names := make([]string, 0, len(sm.states)) - for name := range sm.states { - names = append(names, name) - } - return names -} - -// Clear removes all stored states. -func (sm *StateManager) Clear() { - sm.mu.Lock() - sm.states = make(map[string]WindowState) - sm.mu.Unlock() - sm.scheduleSave() -} - -// ForceSync writes state to disk immediately. -func (sm *StateManager) ForceSync() { - if sm.saveTimer != nil { - sm.saveTimer.Stop() - } - sm.save() -} -``` - -- [ ] **Step 4: Run tests** - -Run: `cd /Users/snider/Code/core/gui && go test ./pkg/window/... -run TestStateManager -v` -Expected: ALL PASS - -- [ ] **Step 5: Commit** - -```bash -git add pkg/window/state.go pkg/window/window_test.go -git commit -m "feat(window): add StateManager with JSON persistence" -``` - ---- - -### Task 4: Layout management - -**Files:** -- Modify: `pkg/window/layout.go` (replace stub) -- Test: `pkg/window/window_test.go` (append layout tests) - -- [ ] **Step 1: Write layout tests** - -Append to `pkg/window/window_test.go`: - -```go -func TestLayoutManager_SaveGet_Good(t *testing.T) { - lm := NewLayoutManager() - lm.configDir = t.TempDir() - states := map[string]WindowState{ - "editor": {X: 0, Y: 0, Width: 960, Height: 1080}, - "terminal": {X: 960, Y: 0, Width: 960, Height: 1080}, - } - err := lm.SaveLayout("coding", states) - require.NoError(t, err) - - layout, ok := lm.GetLayout("coding") - assert.True(t, ok) - assert.Equal(t, "coding", layout.Name) - assert.Len(t, layout.Windows, 2) -} - -func TestLayoutManager_GetLayout_Bad(t *testing.T) { - lm := NewLayoutManager() - lm.configDir = t.TempDir() - _, ok := lm.GetLayout("nonexistent") - assert.False(t, ok) -} - -func TestLayoutManager_ListLayouts_Good(t *testing.T) { - lm := NewLayoutManager() - lm.configDir = t.TempDir() - _ = lm.SaveLayout("a", map[string]WindowState{}) - _ = lm.SaveLayout("b", map[string]WindowState{}) - layouts := lm.ListLayouts() - assert.Len(t, layouts, 2) -} - -func TestLayoutManager_DeleteLayout_Good(t *testing.T) { - lm := NewLayoutManager() - lm.configDir = t.TempDir() - _ = lm.SaveLayout("temp", map[string]WindowState{}) - lm.DeleteLayout("temp") - _, ok := lm.GetLayout("temp") - assert.False(t, ok) -} -``` - -- [ ] **Step 2: Run test to verify it fails** - -Run: `cd /Users/snider/Code/core/gui && go test ./pkg/window/... -run TestLayoutManager -v` -Expected: FAIL - -- [ ] **Step 3: Replace layout.go with full implementation** - -Migrate from `pkg/display/layout.go` (150 LOC): - -```go -// pkg/window/layout.go -package window - -import ( - "encoding/json" - "fmt" - "os" - "path/filepath" - "sync" - "time" -) - -// Layout is a named window arrangement. -type Layout struct { - Name string `json:"name"` - Windows map[string]WindowState `json:"windows"` - CreatedAt int64 `json:"createdAt"` - UpdatedAt int64 `json:"updatedAt"` -} - -// LayoutInfo is a summary of a layout. -type LayoutInfo struct { - Name string `json:"name"` - WindowCount int `json:"windowCount"` - CreatedAt int64 `json:"createdAt"` - UpdatedAt int64 `json:"updatedAt"` -} - -// LayoutManager persists named window arrangements to ~/.config/Core/layouts.json. -type LayoutManager struct { - configDir string - layouts map[string]Layout - mu sync.RWMutex -} - -// NewLayoutManager creates a LayoutManager loading from the default config directory. -func NewLayoutManager() *LayoutManager { - lm := &LayoutManager{ - layouts: make(map[string]Layout), - } - configDir, err := os.UserConfigDir() - if err == nil { - lm.configDir = filepath.Join(configDir, "Core") - } - lm.load() - return lm -} - -func (lm *LayoutManager) filePath() string { - return filepath.Join(lm.configDir, "layouts.json") -} - -func (lm *LayoutManager) load() { - if lm.configDir == "" { - return - } - data, err := os.ReadFile(lm.filePath()) - if err != nil { - return - } - lm.mu.Lock() - defer lm.mu.Unlock() - _ = json.Unmarshal(data, &lm.layouts) -} - -func (lm *LayoutManager) save() { - if lm.configDir == "" { - return - } - lm.mu.RLock() - data, err := json.MarshalIndent(lm.layouts, "", " ") - lm.mu.RUnlock() - if err != nil { - return - } - _ = os.MkdirAll(lm.configDir, 0o755) - _ = os.WriteFile(lm.filePath(), data, 0o644) -} - -// SaveLayout creates or updates a named layout. -func (lm *LayoutManager) SaveLayout(name string, windowStates map[string]WindowState) error { - if name == "" { - return fmt.Errorf("layout name cannot be empty") - } - now := time.Now().UnixMilli() - lm.mu.Lock() - existing, exists := lm.layouts[name] - layout := Layout{ - Name: name, - Windows: windowStates, - UpdatedAt: now, - } - if exists { - layout.CreatedAt = existing.CreatedAt - } else { - layout.CreatedAt = now - } - lm.layouts[name] = layout - lm.mu.Unlock() - lm.save() - return nil -} - -// GetLayout returns a layout by name. -func (lm *LayoutManager) GetLayout(name string) (Layout, bool) { - lm.mu.RLock() - defer lm.mu.RUnlock() - l, ok := lm.layouts[name] - return l, ok -} - -// ListLayouts returns info summaries for all layouts. -func (lm *LayoutManager) ListLayouts() []LayoutInfo { - lm.mu.RLock() - defer lm.mu.RUnlock() - infos := make([]LayoutInfo, 0, len(lm.layouts)) - for _, l := range lm.layouts { - infos = append(infos, LayoutInfo{ - Name: l.Name, WindowCount: len(l.Windows), - CreatedAt: l.CreatedAt, UpdatedAt: l.UpdatedAt, - }) - } - return infos -} - -// DeleteLayout removes a layout by name. -func (lm *LayoutManager) DeleteLayout(name string) { - lm.mu.Lock() - delete(lm.layouts, name) - lm.mu.Unlock() - lm.save() -} -``` - -- [ ] **Step 4: Run tests** - -Run: `cd /Users/snider/Code/core/gui && go test ./pkg/window/... -run TestLayoutManager -v` -Expected: ALL PASS - -- [ ] **Step 5: Commit** - -```bash -git add pkg/window/layout.go pkg/window/window_test.go -git commit -m "feat(window): add LayoutManager with JSON persistence" -``` - ---- - -### Task 5: Tiling, snapping, and workflows - -**Files:** -- Create: `pkg/window/tiling.go` -- Test: `pkg/window/window_test.go` (append tiling tests) - -- [ ] **Step 1: Write tiling tests** - -Append to `pkg/window/window_test.go`: - -```go -func TestTileMode_String_Good(t *testing.T) { - assert.Equal(t, "left-half", TileModeLeftHalf.String()) - assert.Equal(t, "grid", TileModeGrid.String()) -} - -func TestManager_TileWindows_Good(t *testing.T) { - m, _ := newTestManager() - _, _ = m.Open(WithName("a"), WithSize(800, 600)) - _, _ = m.Open(WithName("b"), WithSize(800, 600)) - err := m.TileWindows(TileModeLeftRight, []string{"a", "b"}, 1920, 1080) - require.NoError(t, err) - a, _ := m.Get("a") - b, _ := m.Get("b") - aw, _ := a.Size() - bw, _ := b.Size() - assert.Equal(t, 960, aw) - assert.Equal(t, 960, bw) -} - -func TestManager_TileWindows_Bad(t *testing.T) { - m, _ := newTestManager() - err := m.TileWindows(TileModeLeftRight, []string{"nonexistent"}, 1920, 1080) - assert.Error(t, err) -} - -func TestManager_SnapWindow_Good(t *testing.T) { - m, _ := newTestManager() - _, _ = m.Open(WithName("snap"), WithSize(800, 600)) - err := m.SnapWindow("snap", SnapLeft, 1920, 1080) - require.NoError(t, err) - w, _ := m.Get("snap") - x, _ := w.Position() - assert.Equal(t, 0, x) - sw, _ := w.Size() - assert.Equal(t, 960, sw) -} - -func TestManager_StackWindows_Good(t *testing.T) { - m, _ := newTestManager() - _, _ = m.Open(WithName("s1"), WithSize(800, 600)) - _, _ = m.Open(WithName("s2"), WithSize(800, 600)) - err := m.StackWindows([]string{"s1", "s2"}, 30, 30) - require.NoError(t, err) - s2, _ := m.Get("s2") - x, y := s2.Position() - assert.Equal(t, 30, x) - assert.Equal(t, 30, y) -} - -func TestWorkflowLayout_Good(t *testing.T) { - assert.Equal(t, "coding", WorkflowCoding.String()) - assert.Equal(t, "debugging", WorkflowDebugging.String()) -} -``` - -- [ ] **Step 2: Run test to verify it fails** - -Run: `cd /Users/snider/Code/core/gui && go test ./pkg/window/... -run "TestTile|TestSnap|TestStack|TestWorkflow" -v` -Expected: FAIL - -- [ ] **Step 3: Write tiling.go** - -Migrate tiling/snapping/stacking/workflow code from `pkg/display/display.go` lines 859-1293: - -```go -// pkg/window/tiling.go -package window - -import "fmt" - -// TileMode defines how windows are arranged. -type TileMode int - -const ( - TileModeLeftHalf TileMode = iota - TileModeRightHalf - TileModeTopHalf - TileModeBottomHalf - TileModeTopLeft - TileModeTopRight - TileModeBottomLeft - TileModeBottomRight - TileModeLeftRight - TileModeGrid -) - -var tileModeNames = map[TileMode]string{ - TileModeLeftHalf: "left-half", TileModeRightHalf: "right-half", - TileModeTopHalf: "top-half", TileModeBottomHalf: "bottom-half", - TileModeTopLeft: "top-left", TileModeTopRight: "top-right", - TileModeBottomLeft: "bottom-left", TileModeBottomRight: "bottom-right", - TileModeLeftRight: "left-right", TileModeGrid: "grid", -} - -func (m TileMode) String() string { return tileModeNames[m] } - -// SnapPosition defines where a window snaps to. -type SnapPosition int - -const ( - SnapLeft SnapPosition = iota - SnapRight - SnapTop - SnapBottom - SnapTopLeft - SnapTopRight - SnapBottomLeft - SnapBottomRight - SnapCenter -) - -// WorkflowLayout is a predefined arrangement for common tasks. -type WorkflowLayout int - -const ( - WorkflowCoding WorkflowLayout = iota // 70/30 split - WorkflowDebugging // 60/40 split - WorkflowPresenting // maximised - WorkflowSideBySide // 50/50 split -) - -var workflowNames = map[WorkflowLayout]string{ - WorkflowCoding: "coding", WorkflowDebugging: "debugging", - WorkflowPresenting: "presenting", WorkflowSideBySide: "side-by-side", -} - -func (w WorkflowLayout) String() string { return workflowNames[w] } - -// 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)) - for _, name := range names { - pw, ok := m.Get(name) - if !ok { - return fmt.Errorf("window %q not found", name) - } - windows = append(windows, pw) - } - if len(windows) == 0 { - return fmt.Errorf("no windows to tile") - } - - halfW, halfH := screenW/2, screenH/2 - - switch mode { - case TileModeLeftRight: - w := screenW / len(windows) - for i, pw := range windows { - pw.SetPosition(i*w, 0) - pw.SetSize(w, screenH) - } - case TileModeGrid: - cols := 2 - if len(windows) > 4 { - cols = 3 - } - cellW := screenW / cols - for i, pw := range windows { - row := i / cols - col := i % cols - rows := (len(windows) + cols - 1) / cols - cellH := screenH / rows - pw.SetPosition(col*cellW, row*cellH) - pw.SetSize(cellW, cellH) - } - case TileModeLeftHalf: - for _, pw := range windows { - pw.SetPosition(0, 0) - pw.SetSize(halfW, screenH) - } - case TileModeRightHalf: - for _, pw := range windows { - pw.SetPosition(halfW, 0) - pw.SetSize(halfW, screenH) - } - case TileModeTopHalf: - for _, pw := range windows { - pw.SetPosition(0, 0) - pw.SetSize(screenW, halfH) - } - case TileModeBottomHalf: - for _, pw := range windows { - pw.SetPosition(0, halfH) - pw.SetSize(screenW, halfH) - } - case TileModeTopLeft: - for _, pw := range windows { - pw.SetPosition(0, 0) - pw.SetSize(halfW, halfH) - } - case TileModeTopRight: - for _, pw := range windows { - pw.SetPosition(halfW, 0) - pw.SetSize(halfW, halfH) - } - case TileModeBottomLeft: - for _, pw := range windows { - pw.SetPosition(0, halfH) - pw.SetSize(halfW, halfH) - } - case TileModeBottomRight: - for _, pw := range windows { - pw.SetPosition(halfW, halfH) - pw.SetSize(halfW, halfH) - } - } - return nil -} - -// SnapWindow snaps a window to a screen edge/corner/centre. -func (m *Manager) SnapWindow(name string, pos SnapPosition, screenW, screenH int) error { - pw, ok := m.Get(name) - if !ok { - return fmt.Errorf("window %q not found", name) - } - - halfW, halfH := screenW/2, screenH/2 - - switch pos { - case SnapLeft: - pw.SetPosition(0, 0) - pw.SetSize(halfW, screenH) - case SnapRight: - pw.SetPosition(halfW, 0) - pw.SetSize(halfW, screenH) - case SnapTop: - pw.SetPosition(0, 0) - pw.SetSize(screenW, halfH) - case SnapBottom: - pw.SetPosition(0, halfH) - pw.SetSize(screenW, halfH) - case SnapTopLeft: - pw.SetPosition(0, 0) - pw.SetSize(halfW, halfH) - case SnapTopRight: - pw.SetPosition(halfW, 0) - pw.SetSize(halfW, halfH) - case SnapBottomLeft: - pw.SetPosition(0, halfH) - pw.SetSize(halfW, halfH) - case SnapBottomRight: - pw.SetPosition(halfW, halfH) - pw.SetSize(halfW, halfH) - case SnapCenter: - cw, ch := pw.Size() - pw.SetPosition((screenW-cw)/2, (screenH-ch)/2) - } - return nil -} - -// StackWindows cascades windows with an offset. -func (m *Manager) StackWindows(names []string, offsetX, offsetY int) error { - for i, name := range names { - pw, ok := m.Get(name) - if !ok { - return fmt.Errorf("window %q not found", name) - } - pw.SetPosition(i*offsetX, i*offsetY) - } - return nil -} - -// ApplyWorkflow arranges windows in a predefined workflow layout. -func (m *Manager) ApplyWorkflow(workflow WorkflowLayout, names []string, screenW, screenH int) error { - if len(names) == 0 { - return fmt.Errorf("no windows for workflow") - } - - switch workflow { - case WorkflowCoding: - // 70/30 split — main editor + terminal - mainW := screenW * 70 / 100 - if pw, ok := m.Get(names[0]); ok { - pw.SetPosition(0, 0) - pw.SetSize(mainW, screenH) - } - if len(names) > 1 { - if pw, ok := m.Get(names[1]); ok { - pw.SetPosition(mainW, 0) - pw.SetSize(screenW-mainW, screenH) - } - } - case WorkflowDebugging: - // 60/40 split - mainW := screenW * 60 / 100 - if pw, ok := m.Get(names[0]); ok { - pw.SetPosition(0, 0) - pw.SetSize(mainW, screenH) - } - if len(names) > 1 { - if pw, ok := m.Get(names[1]); ok { - pw.SetPosition(mainW, 0) - pw.SetSize(screenW-mainW, screenH) - } - } - case WorkflowPresenting: - // Maximise first window - if pw, ok := m.Get(names[0]); ok { - pw.SetPosition(0, 0) - pw.SetSize(screenW, screenH) - } - case WorkflowSideBySide: - return m.TileWindows(TileModeLeftRight, names, screenW, screenH) - } - return nil -} -``` - -- [ ] **Step 4: Run tests** - -Run: `cd /Users/snider/Code/core/gui && go test ./pkg/window/... -v -count=1` -Expected: ALL PASS - -- [ ] **Step 5: Commit** - -```bash -git add pkg/window/tiling.go pkg/window/window_test.go -git commit -m "feat(window): add tiling, snapping, stacking, and workflow layouts" -``` - ---- - -### Task 6: Wails adapter for pkg/window - -**Files:** -- Create: `pkg/window/wails.go` - -- [ ] **Step 1: Write wails.go** - -```go -// pkg/window/wails.go -package window - -import ( - "github.com/wailsapp/wails/v3/pkg/application" -) - -// WailsPlatform implements Platform using Wails v3. -type WailsPlatform struct { - app *application.App -} - -// NewWailsPlatform creates a Wails-backed Platform. -func NewWailsPlatform(app *application.App) *WailsPlatform { - return &WailsPlatform{app: app} -} - -func (wp *WailsPlatform) CreateWindow(opts PlatformWindowOptions) PlatformWindow { - wOpts := application.WebviewWindowOptions{ - Name: opts.Name, - Title: opts.Title, - URL: opts.URL, - Width: opts.Width, - Height: opts.Height, - X: opts.X, - Y: opts.Y, - MinWidth: opts.MinWidth, - MinHeight: opts.MinHeight, - MaxWidth: opts.MaxWidth, - MaxHeight: opts.MaxHeight, - Frameless: opts.Frameless, - Hidden: opts.Hidden, - AlwaysOnTop: opts.AlwaysOnTop, - DisableResize: opts.DisableResize, - EnableDragAndDrop: opts.EnableDragAndDrop, - Centered: opts.Centered, - BackgroundColour: application.NewRGBA(opts.BackgroundColour[0], opts.BackgroundColour[1], opts.BackgroundColour[2], opts.BackgroundColour[3]), - } - w := wp.app.NewWebviewWindowWithOptions(wOpts) - return &wailsWindow{w: w} -} - -func (wp *WailsPlatform) GetWindows() []PlatformWindow { - all := wp.app.GetWindowByName // Wails doesn't expose GetAll directly - // Use the app's internal window list — adapt based on Wails v3 API - return nil // TODO: implement once Wails v3 exposes window enumeration -} - -// wailsWindow wraps *application.WebviewWindow to implement PlatformWindow. -type wailsWindow struct { - w *application.WebviewWindow -} - -func (ww *wailsWindow) Name() string { return ww.w.Name() } -func (ww *wailsWindow) Position() (int, int) { return ww.w.Position() } -func (ww *wailsWindow) Size() (int, int) { return ww.w.Size() } -func (ww *wailsWindow) IsMaximised() bool { return ww.w.IsMaximised() } -func (ww *wailsWindow) IsFocused() bool { return ww.w.IsFocused() } -func (ww *wailsWindow) SetTitle(title string) { ww.w.SetTitle(title) } -func (ww *wailsWindow) SetPosition(x, y int) { ww.w.SetPosition(x, y) } -func (ww *wailsWindow) SetSize(width, height int) { ww.w.SetSize(width, height) } -func (ww *wailsWindow) SetBackgroundColour(r, g, b, a uint8) { ww.w.SetBackgroundColour(application.NewRGBA(r, g, b, a)) } -func (ww *wailsWindow) SetVisibility(visible bool) { if visible { ww.w.Show() } else { ww.w.Hide() } } -func (ww *wailsWindow) SetAlwaysOnTop(alwaysOnTop bool) { ww.w.SetAlwaysOnTop(alwaysOnTop) } -func (ww *wailsWindow) Maximise() { ww.w.Maximise() } -func (ww *wailsWindow) Restore() { ww.w.Restore() } -func (ww *wailsWindow) Minimise() { ww.w.Minimise() } -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) OnWindowEvent(handler func(event WindowEvent)) { - name := ww.w.Name() - ww.w.OnWindowEvent(func(e *application.WindowEvent) { - handler(WindowEvent{ - Type: e.EventType.String(), - Name: name, - }) - }) -} -``` - -Note: The `GetWindows()` and `OnWindowEvent` implementations may need adjusting based on exact Wails v3 API. The engineer should check `wails/v3/pkg/application` for the correct method signatures. The key contract is that the adapter wraps Wails and nothing outside this file touches Wails types. - -- [ ] **Step 2: Verify compilation** - -Run: `cd /Users/snider/Code/core/gui && go build ./pkg/window/...` -Expected: SUCCESS (may need minor API adjustments — Wails v3 alpha) - -- [ ] **Step 3: Commit** - -```bash -git add pkg/window/wails.go -git commit -m "feat(window): add Wails v3 adapter" -``` - ---- - -## Chunk 2: pkg/systray + pkg/menu - -### Task 7: pkg/systray — Platform, Manager, and menu builder - -**Files:** -- Create: `pkg/systray/platform.go` -- Create: `pkg/systray/types.go` -- Create: `pkg/systray/tray.go` -- Create: `pkg/systray/menu.go` -- Create: `pkg/systray/mock_test.go` -- Create: `pkg/systray/tray_test.go` -- Create: `pkg/systray/wails.go` -- Move: `pkg/display/assets/apptray.png` → `pkg/systray/assets/apptray.png` - -- [ ] **Step 1: Copy apptray.png asset FIRST (required for `//go:embed` in tray.go)** - -```bash -mkdir -p /Users/snider/Code/core/gui/pkg/systray/assets -cp /Users/snider/Code/core/gui/pkg/display/assets/apptray.png /Users/snider/Code/core/gui/pkg/systray/assets/apptray.png -``` - -- [ ] **Step 2: Write platform.go** - -```go -// pkg/systray/platform.go -package systray - -// Platform abstracts the system tray backend. -type Platform interface { - NewTray() PlatformTray - NewMenu() PlatformMenu // Menu factory for building tray menus -} - -// PlatformTray is a live tray handle from the backend. -type PlatformTray interface { - SetIcon(data []byte) - SetTemplateIcon(data []byte) - SetTooltip(text string) - SetLabel(text string) - SetMenu(menu PlatformMenu) - AttachWindow(w WindowHandle) -} - -// PlatformMenu is a tray menu built by the backend. -type PlatformMenu interface { - Add(label string) PlatformMenuItem - AddSeparator() -} - -// PlatformMenuItem is a single item in a tray menu. -type PlatformMenuItem interface { - SetTooltip(text string) - SetChecked(checked bool) - SetEnabled(enabled bool) - OnClick(fn func()) - AddSubmenu() PlatformMenu -} - -// WindowHandle is a cross-package interface for window operations. -// Defined locally to avoid circular imports (display imports systray). -// pkg/window.PlatformWindow satisfies this implicitly. -type WindowHandle interface { - Name() string - Show() - Hide() - SetPosition(x, y int) - SetSize(width, height int) -} -``` - -Note: `WindowHandle` is defined locally in `pkg/systray` — NOT imported from `pkg/display`. This avoids a circular dependency (`display` → `systray` → `display`). Go's implicit interface satisfaction means `window.PlatformWindow` satisfies this without any coupling. - -- [ ] **Step 3: Write types.go** - -```go -// pkg/systray/types.go -package systray - -// TrayMenuItem describes a menu item for dynamic tray menus. -type TrayMenuItem struct { - Label string `json:"label"` - Type string `json:"type"` // "normal", "separator", "checkbox", "radio" - Checked bool `json:"checked,omitempty"` - Disabled bool `json:"disabled,omitempty"` - Tooltip string `json:"tooltip,omitempty"` - Submenu []TrayMenuItem `json:"submenu,omitempty"` - ActionID string `json:"action_id,omitempty"` -} -``` - -- [ ] **Step 4: Write tray.go — Manager struct** - -```go -// pkg/systray/tray.go -package systray - -import ( - _ "embed" - "fmt" - "sync" -) - -//go:embed assets/apptray.png -var defaultIcon []byte - -// Manager manages the system tray lifecycle. -// State that was previously in package-level vars is now on the Manager. -type Manager struct { - platform Platform - tray PlatformTray - callbacks map[string]func() - mu sync.RWMutex -} - -// NewManager creates a systray Manager. -func NewManager(platform Platform) *Manager { - return &Manager{ - platform: platform, - callbacks: make(map[string]func()), - } -} - -// Setup creates the system tray with default icon and tooltip. -func (m *Manager) Setup(tooltip, label string) error { - m.tray = m.platform.NewTray() - if m.tray == nil { - return fmt.Errorf("platform returned nil tray") - } - m.tray.SetTemplateIcon(defaultIcon) - m.tray.SetTooltip(tooltip) - m.tray.SetLabel(label) - return nil -} - -// SetIcon sets the tray icon. -func (m *Manager) SetIcon(data []byte) error { - if m.tray == nil { - return fmt.Errorf("tray not initialised") - } - m.tray.SetIcon(data) - return nil -} - -// SetTemplateIcon sets the template icon (macOS). -func (m *Manager) SetTemplateIcon(data []byte) error { - if m.tray == nil { - return fmt.Errorf("tray not initialised") - } - m.tray.SetTemplateIcon(data) - return nil -} - -// SetTooltip sets the tray tooltip. -func (m *Manager) SetTooltip(text string) error { - if m.tray == nil { - return fmt.Errorf("tray not initialised") - } - m.tray.SetTooltip(text) - return nil -} - -// SetLabel sets the tray label. -func (m *Manager) SetLabel(text string) error { - if m.tray == nil { - return fmt.Errorf("tray not initialised") - } - m.tray.SetLabel(text) - return nil -} - -// AttachWindow attaches a panel window to the tray. -func (m *Manager) AttachWindow(w WindowHandle) error { - if m.tray == nil { - return fmt.Errorf("tray not initialised") - } - m.tray.AttachWindow(w) - return nil -} - -// Tray returns the underlying platform tray for direct access. -func (m *Manager) Tray() PlatformTray { - return m.tray -} - -// IsActive returns whether a tray has been created. -func (m *Manager) IsActive() bool { - return m.tray != nil -} -``` - -- [ ] **Step 5: Write menu.go — Dynamic menu builder and callback registry** - -```go -// pkg/systray/menu.go -package systray - -import "fmt" - -// SetMenu sets a dynamic menu on the tray from TrayMenuItem descriptors. -func (m *Manager) SetMenu(items []TrayMenuItem) error { - if m.tray == nil { - return fmt.Errorf("tray not initialised") - } - menu := m.buildMenu(items) - m.tray.SetMenu(menu) - return nil -} - -// buildMenu recursively builds a PlatformMenu from TrayMenuItem descriptors. -func (m *Manager) buildMenu(items []TrayMenuItem) PlatformMenu { - menu := m.platform.NewMenu() - for _, item := range items { - if item.Type == "separator" { - menu.AddSeparator() - continue - } - if len(item.Submenu) > 0 { - sub := m.buildMenu(item.Submenu) - mi := menu.Add(item.Label) - _ = mi.AddSubmenu() - _ = sub // TODO: wire sub into parent via platform - continue - } - mi := menu.Add(item.Label) - if item.Tooltip != "" { - mi.SetTooltip(item.Tooltip) - } - if item.Disabled { - mi.SetEnabled(false) - } - if item.Checked { - mi.SetChecked(true) - } - if item.ActionID != "" { - actionID := item.ActionID - mi.OnClick(func() { - if cb, ok := m.GetCallback(actionID); ok { - cb() - } - }) - } - } - return menu -} - -// RegisterCallback registers a callback for a menu action ID. -func (m *Manager) RegisterCallback(actionID string, callback func()) { - m.mu.Lock() - m.callbacks[actionID] = callback - m.mu.Unlock() -} - -// UnregisterCallback removes a callback. -func (m *Manager) UnregisterCallback(actionID string) { - m.mu.Lock() - delete(m.callbacks, actionID) - m.mu.Unlock() -} - -// GetCallback returns the callback for an action ID. -func (m *Manager) GetCallback(actionID string) (func(), bool) { - m.mu.RLock() - defer m.mu.RUnlock() - cb, ok := m.callbacks[actionID] - return cb, ok -} - -// GetInfo returns tray status information. -func (m *Manager) GetInfo() map[string]any { - return map[string]any{ - "active": m.IsActive(), - } -} -``` - -- [ ] **Step 6: Write mock_test.go** - -```go -// pkg/systray/mock_test.go -package systray - -type mockPlatform struct { - trays []*mockTray - menus []*mockTrayMenu -} - -func newMockPlatform() *mockPlatform { return &mockPlatform{} } - -func (p *mockPlatform) NewTray() PlatformTray { - t := &mockTray{} - p.trays = append(p.trays, t) - return t -} - -func (p *mockPlatform) NewMenu() PlatformMenu { - m := &mockTrayMenu{} - p.menus = append(p.menus, m) - return m -} - -type mockTrayMenu struct { - items []string -} - -func (m *mockTrayMenu) Add(label string) PlatformMenuItem { m.items = append(m.items, label); return &mockTrayMenuItem{} } -func (m *mockTrayMenu) AddSeparator() { m.items = append(m.items, "---") } - -type mockTrayMenuItem struct{} - -func (mi *mockTrayMenuItem) SetTooltip(text string) {} -func (mi *mockTrayMenuItem) SetChecked(checked bool) {} -func (mi *mockTrayMenuItem) SetEnabled(enabled bool) {} -func (mi *mockTrayMenuItem) OnClick(fn func()) {} -func (mi *mockTrayMenuItem) AddSubmenu() PlatformMenu { return &mockTrayMenu{} } - -type mockTray struct { - icon, templateIcon []byte - tooltip, label string - menu PlatformMenu - attachedWindow WindowHandle -} - -func (t *mockTray) SetIcon(data []byte) { t.icon = data } -func (t *mockTray) SetTemplateIcon(data []byte) { t.templateIcon = data } -func (t *mockTray) SetTooltip(text string) { t.tooltip = text } -func (t *mockTray) SetLabel(text string) { t.label = text } -func (t *mockTray) SetMenu(menu PlatformMenu) { t.menu = menu } -func (t *mockTray) AttachWindow(w WindowHandle) { t.attachedWindow = w } -``` - -- [ ] **Step 7: Write tray_test.go** - -```go -// pkg/systray/tray_test.go -package systray - -import ( - "testing" - - "github.com/stretchr/testify/assert" - "github.com/stretchr/testify/require" -) - -func newTestManager() (*Manager, *mockPlatform) { - p := newMockPlatform() - return NewManager(p), p -} - -func TestManager_Setup_Good(t *testing.T) { - m, p := newTestManager() - err := m.Setup("Core", "Core") - require.NoError(t, err) - assert.True(t, m.IsActive()) - assert.Len(t, p.trays, 1) - assert.Equal(t, "Core", p.trays[0].tooltip) - assert.Equal(t, "Core", p.trays[0].label) - assert.NotEmpty(t, p.trays[0].templateIcon) // default icon embedded -} - -func TestManager_SetIcon_Good(t *testing.T) { - m, p := newTestManager() - _ = m.Setup("Core", "Core") - err := m.SetIcon([]byte{1, 2, 3}) - require.NoError(t, err) - assert.Equal(t, []byte{1, 2, 3}, p.trays[0].icon) -} - -func TestManager_SetIcon_Bad(t *testing.T) { - m, _ := newTestManager() - err := m.SetIcon([]byte{1}) - assert.Error(t, err) // tray not initialised -} - -func TestManager_SetTooltip_Good(t *testing.T) { - m, p := newTestManager() - _ = m.Setup("Core", "Core") - _ = m.SetTooltip("New Tooltip") - assert.Equal(t, "New Tooltip", p.trays[0].tooltip) -} - -func TestManager_SetLabel_Good(t *testing.T) { - m, p := newTestManager() - _ = m.Setup("Core", "Core") - _ = m.SetLabel("New Label") - assert.Equal(t, "New Label", p.trays[0].label) -} - -func TestManager_RegisterCallback_Good(t *testing.T) { - m, _ := newTestManager() - called := false - m.RegisterCallback("test-action", func() { called = true }) - cb, ok := m.GetCallback("test-action") - assert.True(t, ok) - cb() - assert.True(t, called) -} - -func TestManager_RegisterCallback_Bad(t *testing.T) { - m, _ := newTestManager() - _, ok := m.GetCallback("nonexistent") - assert.False(t, ok) -} - -func TestManager_UnregisterCallback_Good(t *testing.T) { - m, _ := newTestManager() - m.RegisterCallback("remove-me", func() {}) - m.UnregisterCallback("remove-me") - _, ok := m.GetCallback("remove-me") - assert.False(t, ok) -} - -func TestManager_GetInfo_Good(t *testing.T) { - m, _ := newTestManager() - info := m.GetInfo() - assert.False(t, info["active"].(bool)) - _ = m.Setup("Core", "Core") - info = m.GetInfo() - assert.True(t, info["active"].(bool)) -} -``` - -- [ ] **Step 8: Write wails.go adapter** - -```go -// pkg/systray/wails.go -package systray - -import ( - "github.com/wailsapp/wails/v3/pkg/application" -) - -// WailsPlatform implements Platform using Wails v3. -type WailsPlatform struct { - app *application.App -} - -func NewWailsPlatform(app *application.App) *WailsPlatform { - return &WailsPlatform{app: app} -} - -func (wp *WailsPlatform) NewTray() PlatformTray { - return &wailsTray{tray: wp.app.NewSystemTray(), app: wp.app} -} - -type wailsTray struct { - tray *application.SystemTray - app *application.App -} - -func (wt *wailsTray) SetIcon(data []byte) { wt.tray.SetIcon(data) } -func (wt *wailsTray) SetTemplateIcon(data []byte) { wt.tray.SetTemplateIcon(data) } -func (wt *wailsTray) SetTooltip(text string) { wt.tray.SetTooltip(text) } -func (wt *wailsTray) SetLabel(text string) { wt.tray.SetLabel(text) } - -func (wt *wailsTray) SetMenu(menu PlatformMenu) { - // Menu constructed via Wails application.Menu — adapt as needed -} - -func (wt *wailsTray) AttachWindow(w WindowHandle) { - // Wails systray can attach a window — adapt based on v3 API -} -``` - -- [ ] **Step 9: Run tests** - -Run: `cd /Users/snider/Code/core/gui && go test ./pkg/systray/... -v -count=1` -Expected: ALL PASS (no `pkg/display` import needed — `WindowHandle` is local) - -- [ ] **Step 10: Commit** - -```bash -git add pkg/systray/ pkg/display/types.go -git commit -m "feat(systray): add Manager with platform abstraction and callback registry" -``` - ---- - -### Task 8: pkg/menu — Platform and builder - -**Files:** -- Create: `pkg/menu/platform.go` -- Create: `pkg/menu/menu.go` -- Create: `pkg/menu/mock_test.go` -- Create: `pkg/menu/menu_test.go` -- Create: `pkg/menu/wails.go` - -- [ ] **Step 1: Write platform.go** - -```go -// pkg/menu/platform.go -package menu - -// Platform abstracts the menu backend. -type Platform interface { - NewMenu() PlatformMenu - SetApplicationMenu(menu PlatformMenu) -} - -// PlatformMenu is a live menu handle. -type PlatformMenu interface { - Add(label string) PlatformMenuItem - AddSeparator() - AddSubmenu(label string) PlatformMenu - // Roles — macOS menu roles - AddRole(role MenuRole) -} - -// PlatformMenuItem is a single menu item. -type PlatformMenuItem interface { - SetAccelerator(accel string) PlatformMenuItem - SetTooltip(text string) PlatformMenuItem - SetChecked(checked bool) PlatformMenuItem - SetEnabled(enabled bool) PlatformMenuItem - OnClick(fn func()) PlatformMenuItem -} - -// MenuRole is a predefined platform menu role. -type MenuRole int - -const ( - RoleAppMenu MenuRole = iota - RoleFileMenu - RoleEditMenu - RoleViewMenu - RoleWindowMenu - RoleHelpMenu -) -``` - -- [ ] **Step 2: Write menu.go — Manager + MenuItem (structure only)** - -```go -// pkg/menu/menu.go -package menu - -// MenuItem describes a menu item for construction (structure only — no handlers). -type MenuItem struct { - Label string - Accelerator string - Type string // "normal", "separator", "checkbox", "radio", "submenu" - Checked bool - Disabled bool - Tooltip string - Children []MenuItem - Role *MenuRole - OnClick func() // Injected by orchestrator, not by menu package consumer -} - -// Manager builds application menus via a Platform backend. -type Manager struct { - platform Platform -} - -// NewManager creates a menu Manager. -func NewManager(platform Platform) *Manager { - return &Manager{platform: platform} -} - -// Build constructs a PlatformMenu from a tree of MenuItems. -func (m *Manager) Build(items []MenuItem) PlatformMenu { - menu := m.platform.NewMenu() - m.buildItems(menu, items) - return menu -} - -func (m *Manager) buildItems(menu PlatformMenu, items []MenuItem) { - for _, item := range items { - if item.Role != nil { - menu.AddRole(*item.Role) - continue - } - if item.Type == "separator" { - menu.AddSeparator() - continue - } - if len(item.Children) > 0 { - sub := menu.AddSubmenu(item.Label) - m.buildItems(sub, item.Children) - continue - } - mi := menu.Add(item.Label) - if item.Accelerator != "" { - mi.SetAccelerator(item.Accelerator) - } - if item.Tooltip != "" { - mi.SetTooltip(item.Tooltip) - } - if item.OnClick != nil { - mi.OnClick(item.OnClick) - } - } -} - -// SetApplicationMenu builds and sets the application menu. -func (m *Manager) SetApplicationMenu(items []MenuItem) { - menu := m.Build(items) - m.platform.SetApplicationMenu(menu) -} - -// Platform returns the underlying platform. -func (m *Manager) Platform() Platform { - return m.platform -} -``` - -- [ ] **Step 3: Write mock_test.go** - -```go -// pkg/menu/mock_test.go -package menu - -type mockPlatform struct { - menus []*mockMenu - appMenu PlatformMenu -} - -func newMockPlatform() *mockPlatform { return &mockPlatform{} } - -func (p *mockPlatform) NewMenu() PlatformMenu { - m := &mockMenu{} - p.menus = append(p.menus, m) - return m -} - -func (p *mockPlatform) SetApplicationMenu(menu PlatformMenu) { p.appMenu = menu } - -type mockMenu struct { - items []*mockMenuItem - subs []*mockMenu - roles []MenuRole -} - -func (m *mockMenu) Add(label string) PlatformMenuItem { - mi := &mockMenuItem{label: label} - m.items = append(m.items, mi) - return mi -} - -func (m *mockMenu) AddSeparator() { - m.items = append(m.items, &mockMenuItem{label: "---"}) -} - -func (m *mockMenu) AddSubmenu(label string) PlatformMenu { - sub := &mockMenu{} - m.subs = append(m.subs, sub) - m.items = append(m.items, &mockMenuItem{label: label, isSubmenu: true}) - return sub -} - -func (m *mockMenu) AddRole(role MenuRole) { m.roles = append(m.roles, role) } - -type mockMenuItem struct { - label, accel, tooltip string - checked, enabled bool - isSubmenu bool - onClick func() -} - -func (mi *mockMenuItem) SetAccelerator(accel string) PlatformMenuItem { mi.accel = accel; return mi } -func (mi *mockMenuItem) SetTooltip(text string) PlatformMenuItem { mi.tooltip = text; return mi } -func (mi *mockMenuItem) SetChecked(checked bool) PlatformMenuItem { mi.checked = checked; return mi } -func (mi *mockMenuItem) SetEnabled(enabled bool) PlatformMenuItem { mi.enabled = enabled; return mi } -func (mi *mockMenuItem) OnClick(fn func()) PlatformMenuItem { mi.onClick = fn; return mi } -``` - -- [ ] **Step 4: Write menu_test.go** - -```go -// pkg/menu/menu_test.go -package menu - -import ( - "testing" - - "github.com/stretchr/testify/assert" -) - -func newTestManager() (*Manager, *mockPlatform) { - p := newMockPlatform() - return NewManager(p), p -} - -func TestManager_Build_Good(t *testing.T) { - m, p := newTestManager() - items := []MenuItem{ - {Label: "File"}, - {Label: "Edit"}, - } - menu := m.Build(items) - assert.NotNil(t, menu) - assert.Len(t, p.menus, 1) - assert.Len(t, p.menus[0].items, 2) - assert.Equal(t, "File", p.menus[0].items[0].label) -} - -func TestManager_Build_Separator_Good(t *testing.T) { - m, p := newTestManager() - items := []MenuItem{ - {Label: "Above"}, - {Type: "separator"}, - {Label: "Below"}, - } - m.Build(items) - assert.Len(t, p.menus[0].items, 3) - assert.Equal(t, "---", p.menus[0].items[1].label) -} - -func TestManager_Build_Submenu_Good(t *testing.T) { - m, p := newTestManager() - items := []MenuItem{ - {Label: "Parent", Children: []MenuItem{ - {Label: "Child 1"}, - {Label: "Child 2"}, - }}, - } - m.Build(items) - assert.Len(t, p.menus[0].subs, 1) - assert.Len(t, p.menus[0].subs[0].items, 2) -} - -func TestManager_Build_Accelerator_Good(t *testing.T) { - m, p := newTestManager() - items := []MenuItem{ - {Label: "Save", Accelerator: "CmdOrCtrl+S"}, - } - m.Build(items) - assert.Equal(t, "CmdOrCtrl+S", p.menus[0].items[0].accel) -} - -func TestManager_Build_OnClick_Good(t *testing.T) { - m, p := newTestManager() - called := false - items := []MenuItem{ - {Label: "Action", OnClick: func() { called = true }}, - } - m.Build(items) - p.menus[0].items[0].onClick() - assert.True(t, called) -} - -func TestManager_Build_Role_Good(t *testing.T) { - m, p := newTestManager() - appMenu := RoleAppMenu - items := []MenuItem{ - {Role: &appMenu}, - } - m.Build(items) - assert.Contains(t, p.menus[0].roles, RoleAppMenu) -} - -func TestManager_SetApplicationMenu_Good(t *testing.T) { - m, p := newTestManager() - items := []MenuItem{{Label: "Test"}} - m.SetApplicationMenu(items) - assert.NotNil(t, p.appMenu) -} - -func TestManager_Build_Empty_Good(t *testing.T) { - m, _ := newTestManager() - menu := m.Build(nil) - assert.NotNil(t, menu) -} -``` - -- [ ] **Step 5: Write wails.go adapter** - -```go -// pkg/menu/wails.go -package menu - -import "github.com/wailsapp/wails/v3/pkg/application" - -// WailsPlatform implements Platform using Wails v3. -type WailsPlatform struct { - app *application.App -} - -func NewWailsPlatform(app *application.App) *WailsPlatform { - return &WailsPlatform{app: app} -} - -func (wp *WailsPlatform) NewMenu() PlatformMenu { - return &wailsMenu{menu: application.NewMenu()} -} - -func (wp *WailsPlatform) SetApplicationMenu(menu PlatformMenu) { - if wm, ok := menu.(*wailsMenu); ok { - wp.app.SetMenu(wm.menu) - } -} - -type wailsMenu struct { - menu *application.Menu -} - -func (wm *wailsMenu) Add(label string) PlatformMenuItem { - return &wailsMenuItem{item: wm.menu.Add(label)} -} - -func (wm *wailsMenu) AddSeparator() { - wm.menu.AddSeparator() -} - -func (wm *wailsMenu) AddSubmenu(label string) PlatformMenu { - sub := wm.menu.AddSubmenu(label) - return &wailsMenu{menu: sub} -} - -func (wm *wailsMenu) AddRole(role MenuRole) { - switch role { - case RoleAppMenu: - wm.menu.AddRole(application.AppMenu) - case RoleFileMenu: - wm.menu.AddRole(application.FileMenu) - case RoleEditMenu: - wm.menu.AddRole(application.EditMenu) - case RoleViewMenu: - wm.menu.AddRole(application.ViewMenu) - case RoleWindowMenu: - wm.menu.AddRole(application.WindowMenu) - case RoleHelpMenu: - wm.menu.AddRole(application.HelpMenu) - } -} - -type wailsMenuItem struct { - item *application.MenuItem -} - -func (mi *wailsMenuItem) SetAccelerator(accel string) PlatformMenuItem { - mi.item.SetAccelerator(accel) - return mi -} - -func (mi *wailsMenuItem) SetTooltip(text string) PlatformMenuItem { - mi.item.SetTooltip(text) - return mi -} - -func (mi *wailsMenuItem) SetChecked(checked bool) PlatformMenuItem { - mi.item.SetChecked(checked) - return mi -} - -func (mi *wailsMenuItem) SetEnabled(enabled bool) PlatformMenuItem { - if enabled { - mi.item.SetEnabled(true) - } else { - mi.item.SetEnabled(false) - } - return mi -} - -func (mi *wailsMenuItem) OnClick(fn func()) PlatformMenuItem { - mi.item.OnClick(func(*application.Context) { fn() }) - return mi -} -``` - -- [ ] **Step 6: Run tests** - -Run: `cd /Users/snider/Code/core/gui && go test ./pkg/menu/... -v -count=1` -Expected: ALL PASS - -- [ ] **Step 7: Commit** - -```bash -git add pkg/menu/ -git commit -m "feat(menu): add Manager with platform abstraction and builder" -``` - ---- - -## Chunk 3: pkg/display refactor - -### Task 9: Shared types in pkg/display - -**Files:** -- Modify: `pkg/display/types.go` (expand stub from Task 7) - -- [ ] **Step 1: Expand types.go** - -```go -// pkg/display/types.go -package display - -// WindowHandle provides a cross-package interface for window operations. -// Both pkg/window.PlatformWindow and pkg/systray use this — no peer imports. -type WindowHandle interface { - Name() string - Show() - Hide() - SetPosition(x, y int) - SetSize(width, height int) -} - -// ScreenInfo describes a display screen. -type ScreenInfo struct { - ID string `json:"id"` - Name string `json:"name"` - X int `json:"x"` - Y int `json:"y"` - Width int `json:"width"` - Height int `json:"height"` - IsPrimary bool `json:"isPrimary"` -} - -// WorkArea describes the usable area of a screen (excluding dock/menubar). -type WorkArea struct { - ScreenID string `json:"screenId"` - X int `json:"x"` - Y int `json:"y"` - Width int `json:"width"` - Height int `json:"height"` -} - -// EventSource abstracts the application event system (Wails insulation for WSEventManager). -// WSEventManager receives this instead of calling application.Get() directly. -type EventSource interface { - OnThemeChange(handler func(isDark bool)) func() - Emit(name string, data ...any) bool -} -``` - -- [ ] **Step 2: Commit** - -```bash -git add pkg/display/types.go -git commit -m "feat(display): add shared types (WindowHandle, ScreenInfo, WorkArea, EventSource)" -``` - ---- - -### Task 10: Refactor pkg/display — Orchestrator - -This is the largest task. The orchestrator composes the three sub-managers and delegates. - -**Files:** -- Modify: `pkg/display/display.go` — Remove ~800 LOC of window CRUD/tiling/snapping, replace with delegation -- Modify: `pkg/display/interfaces.go` — Remove migrated interfaces -- Modify: `pkg/display/events.go` — Accept `window.PlatformWindow` instead of Wails types -- Modify: `pkg/display/actions.go` — Use `window.Window` instead of Wails type -- Delete: `pkg/display/window.go` — Replaced by `pkg/window/options.go` -- Delete: `pkg/display/window_state.go` — Replaced by `pkg/window/state.go` -- Delete: `pkg/display/layout.go` — Replaced by `pkg/window/layout.go` -- Delete: `pkg/display/tray.go` — Replaced by `pkg/systray/tray.go` -- Delete: `pkg/display/menu.go` — Handlers stay in display.go, structure in `pkg/menu` -- Modify: `pkg/display/display_test.go` — Update for new imports -- Modify: `pkg/display/mocks_test.go` — Remove migrated mocks - -**Strategy:** This task is large but mechanical. The engineer should: -1. Delete the old files first -2. Update `display.go` to compose sub-managers -3. Update imports and types -4. Fix tests - -- [ ] **Step 1: Delete replaced files** - -```bash -cd /Users/snider/Code/core/gui -rm pkg/display/window.go -rm pkg/display/window_state.go -rm pkg/display/layout.go -rm pkg/display/tray.go -rm pkg/display/menu.go -``` - -- [ ] **Step 2: Update actions.go** - -Replace the `ActionOpenWindow` struct to use `window.Window`: - -```go -// pkg/display/actions.go -package display - -import "forge.lthn.ai/core/gui/pkg/window" - -// ActionOpenWindow is an IPC message type requesting a new window. -type ActionOpenWindow struct { - window.Window -} -``` - -- [ ] **Step 3: Update interfaces.go — Keep only display-level interfaces** - -Remove `WindowManager`, `MenuManager`, `SystemTrayManager` and their Wails adapters. Keep `DialogManager`, `EnvManager`, `EventManager`, `Logger`, and the `App` interface reduced to what display still needs directly: - -```go -// pkg/display/interfaces.go -package display - -import ( - "github.com/wailsapp/wails/v3/pkg/application" - "github.com/wailsapp/wails/v3/pkg/events" -) - -// App abstracts the Wails application for the orchestrator. -type App interface { - Dialog() DialogManager - Env() EnvManager - Event() EventManager - Logger() Logger - Quit() -} - -// DialogManager wraps Wails dialog operations. -type DialogManager interface { - Info() *application.MessageDialog - Warning() *application.MessageDialog - OpenFile() *application.OpenFileDialogStruct -} - -// EnvManager wraps Wails environment queries. -type EnvManager interface { - Info() application.EnvironmentInfo - IsDarkMode() bool -} - -// EventManager wraps Wails application events. -type EventManager interface { - OnApplicationEvent(eventType events.ApplicationEventType, handler func(*application.ApplicationEvent)) func() - Emit(name string, data ...any) bool -} - -// Logger wraps Wails logging. -type Logger interface { - Info(message string, args ...any) -} - -// wailsApp wraps *application.App for the App interface. -type wailsApp struct { - app *application.App -} - -func newWailsApp(app *application.App) *wailsApp { - return &wailsApp{app: app} -} - -func (w *wailsApp) Dialog() DialogManager { return &wailsDialogManager{app: w.app} } -func (w *wailsApp) Env() EnvManager { return &wailsEnvManager{app: w.app} } -func (w *wailsApp) Event() EventManager { return &wailsEventManager{app: w.app} } -func (w *wailsApp) Logger() Logger { return &wailsLogger{app: w.app} } -func (w *wailsApp) Quit() { w.app.Quit() } - -type wailsDialogManager struct{ app *application.App } -func (d *wailsDialogManager) Info() *application.MessageDialog { return d.app.InfoDialog() } -func (d *wailsDialogManager) Warning() *application.MessageDialog { return d.app.WarningDialog() } -func (d *wailsDialogManager) OpenFile() *application.OpenFileDialogStruct { return d.app.OpenFileDialogWithOptions() } - -type wailsEnvManager struct{ app *application.App } -func (e *wailsEnvManager) Info() application.EnvironmentInfo { return e.app.Info() } -func (e *wailsEnvManager) IsDarkMode() bool { return e.app.IsDarkMode() } - -type wailsEventManager struct{ app *application.App } -func (ev *wailsEventManager) OnApplicationEvent(eventType events.ApplicationEventType, handler func(*application.ApplicationEvent)) func() { - return ev.app.OnApplicationEvent(eventType, handler) -} -func (ev *wailsEventManager) Emit(name string, data ...any) bool { return ev.app.EmitEvent(name, data...) } - -type wailsLogger struct{ app *application.App } -func (l *wailsLogger) Info(message string, args ...any) { l.app.Logger.Info(message, args...) } -``` - -- [ ] **Step 4: Refactor display.go — Compose sub-managers** - -The `Service` struct changes from managing windows directly to delegating to `window.Manager`, `systray.Manager`, and `menu.Manager`. - -```go -// pkg/display/display.go — updated Service struct and key methods - -import ( - "forge.lthn.ai/core/go/pkg/core" - "forge.lthn.ai/core/gui/pkg/menu" - "forge.lthn.ai/core/gui/pkg/systray" - "forge.lthn.ai/core/gui/pkg/window" -) - -type Service struct { - *core.ServiceRuntime[Options] - app App - windows *window.Manager - tray *systray.Manager - menus *menu.Manager - events *WSEventManager - eventSource EventSource -} - -// New creates an unregistered Service. -func New() (*Service, error) { - return &Service{}, nil -} - -// Register creates a Service bound to Core DI. -func Register(c *core.Core) (any, error) { - s := &Service{} - s.ServiceRuntime = core.NewServiceRuntime[Options](c, Options{}) - return s, nil -} - -// ServiceStartup initialises sub-managers with the Wails app. -func (s *Service) ServiceStartup(app any) { - // Cast to *application.App, create platform adapters - // wailsApp := app.(*application.App) - // s.windows = window.NewManager(window.NewWailsPlatform(wailsApp)) - // s.tray = systray.NewManager(systray.NewWailsPlatform(wailsApp)) - // s.menus = menu.NewManager(menu.NewWailsPlatform(wailsApp)) - // s.app = newWailsApp(wailsApp) - // s.events = NewWSEventManager(s.eventSource) - // s.buildMenu() - // s.setupTray() -} - -// --- Public API delegates to window.Manager --- - -func (s *Service) OpenWindow(opts ...window.WindowOption) error { - pw, err := s.windows.Open(opts...) - if err != nil { - return err - } - s.trackWindow(pw) - return nil -} - -func (s *Service) SetWindowPosition(name string, x, y int) error { - pw, ok := s.windows.Get(name) - if !ok { - return fmt.Errorf("window %q not found", name) - } - pw.SetPosition(x, y) - s.windows.State().UpdatePosition(name, x, y) - return nil -} - -func (s *Service) SetWindowSize(name string, width, height int) error { - pw, ok := s.windows.Get(name) - if !ok { - return fmt.Errorf("window %q not found", name) - } - pw.SetSize(width, height) - s.windows.State().UpdateSize(name, width, height) - return nil -} - -func (s *Service) MaximizeWindow(name string) error { - pw, ok := s.windows.Get(name) - if !ok { - return fmt.Errorf("window %q not found", name) - } - pw.Maximise() - s.windows.State().UpdateMaximized(name, true) - return nil -} - -func (s *Service) FocusWindow(name string) error { - pw, ok := s.windows.Get(name) - if !ok { - return fmt.Errorf("window %q not found", name) - } - pw.Focus() - return nil -} - -func (s *Service) CloseWindow(name string) error { - pw, ok := s.windows.Get(name) - if !ok { - return fmt.Errorf("window %q not found", name) - } - s.windows.State().CaptureState(pw) - pw.Close() - s.windows.Remove(name) - return nil -} - -// --- Layout delegation --- - -func (s *Service) SaveLayout(name string) error { - states := make(map[string]window.WindowState) - for _, n := range s.windows.List() { - if pw, ok := s.windows.Get(n); ok { - x, y := pw.Position() - w, h := pw.Size() - states[n] = window.WindowState{X: x, Y: y, Width: w, Height: h, Maximized: pw.IsMaximised()} - } - } - return s.windows.Layout().SaveLayout(name, states) -} - -func (s *Service) RestoreLayout(name string) error { - layout, ok := s.windows.Layout().GetLayout(name) - if !ok { - return fmt.Errorf("layout %q not found", name) - } - for wName, state := range layout.Windows { - if pw, ok := s.windows.Get(wName); ok { - pw.SetPosition(state.X, state.Y) - pw.SetSize(state.Width, state.Height) - if state.Maximized { - pw.Maximise() - } - } - } - return nil -} - -// --- Tiling/snapping delegation --- - -func (s *Service) TileWindows(mode window.TileMode, names []string) error { - // Use primary screen dimensions — screen queries remain in display - return s.windows.TileWindows(mode, names, 1920, 1080) // TODO: use actual screen size -} - -func (s *Service) SnapWindow(name string, position window.SnapPosition) error { - return s.windows.SnapWindow(name, position, 1920, 1080) // TODO: use actual screen size -} - -// --- trackWindow attaches event listeners for state persistence --- - -func (s *Service) trackWindow(pw window.PlatformWindow) { - s.events.AttachWindowListeners(pw) - s.events.EmitWindowEvent(EventWindowCreate, pw.Name(), nil) -} - -// --- setupTray delegates to systray.Manager --- - -func (s *Service) setupTray() { - _ = s.tray.Setup("Core", "Core") - s.tray.RegisterCallback("open-desktop", func() { - for _, name := range s.windows.List() { - if pw, ok := s.windows.Get(name); ok { - pw.Show() - } - } - }) - s.tray.RegisterCallback("close-desktop", func() { - for _, name := range s.windows.List() { - if pw, ok := s.windows.Get(name); ok { - pw.Hide() - } - } - }) - s.tray.RegisterCallback("env-info", func() { s.ShowEnvironmentDialog() }) - s.tray.RegisterCallback("quit", func() { s.app.Quit() }) - _ = s.tray.SetMenu([]systray.TrayMenuItem{ - {Label: "Open Desktop", ActionID: "open-desktop"}, - {Label: "Close Desktop", ActionID: "close-desktop"}, - {Type: "separator"}, - {Label: "Environment Info", ActionID: "env-info"}, - {Type: "separator"}, - {Label: "Quit", ActionID: "quit"}, - }) -} - -// --- Handler methods (stay in display — use s.windows.Open) --- - -func (s *Service) handleNewWorkspace() { - _ = s.OpenWindow(window.WithName("workspace-new"), window.WithTitle("New Workspace"), - window.WithURL("/workspace/new"), window.WithSize(500, 400)) -} - -func (s *Service) handleNewFile() { - _ = s.OpenWindow(window.WithName("editor-new"), window.WithTitle("New File"), - window.WithURL("/developer/editor?new=true"), window.WithSize(1200, 800)) -} - -func (s *Service) handleOpenFile() { - // File dialog → open editor with file path - // Uses s.app.Dialog().OpenFile() which stays in display -} - -func (s *Service) handleSaveFile() { s.app.Event().Emit("ide:save") } -func (s *Service) handleOpenEditor() { - _ = s.OpenWindow(window.WithName("editor"), window.WithTitle("Editor"), - window.WithURL("/developer/editor"), window.WithSize(1200, 800)) -} -func (s *Service) handleOpenTerminal() { - _ = s.OpenWindow(window.WithName("terminal"), window.WithTitle("Terminal"), - window.WithURL("/developer/terminal"), window.WithSize(800, 500)) -} -func (s *Service) handleRun() { s.app.Event().Emit("ide:run") } -func (s *Service) handleBuild() { s.app.Event().Emit("ide:build") } - -func ptr[T any](v T) *T { return &v } -``` - -**Note on screen queries:** Methods like `GetScreens()`, `GetWorkAreas()`, `GetPrimaryScreen()` currently call `application.Get()` directly. These stay in `pkg/display` and will be insulated in a follow-up task via a `ScreenProvider` interface. For now they remain as direct Wails calls — the priority is splitting window/systray/menu cleanly. - -**Key pattern for menu handlers staying in display:** - -```go -func (s *Service) buildMenu() { - items := []menu.MenuItem{ - {Role: ptr(menu.RoleAppMenu)}, - {Role: ptr(menu.RoleFileMenu)}, - {Label: "Workspace", Children: []menu.MenuItem{ - {Label: "New...", OnClick: s.handleNewWorkspace}, - {Label: "List", OnClick: s.handleListWorkspaces}, - }}, - {Label: "Developer", Children: []menu.MenuItem{ - {Label: "New File", Accelerator: "CmdOrCtrl+N", OnClick: s.handleNewFile}, - {Label: "Open File...", Accelerator: "CmdOrCtrl+O", OnClick: s.handleOpenFile}, - {Label: "Save", Accelerator: "CmdOrCtrl+S", OnClick: s.handleSaveFile}, - {Type: "separator"}, - {Label: "Editor", OnClick: s.handleOpenEditor}, - {Label: "Terminal", OnClick: s.handleOpenTerminal}, - {Type: "separator"}, - {Label: "Run", Accelerator: "CmdOrCtrl+R", OnClick: s.handleRun}, - {Label: "Build", Accelerator: "CmdOrCtrl+B", OnClick: s.handleBuild}, - }}, - {Role: ptr(menu.RoleEditMenu)}, - {Role: ptr(menu.RoleViewMenu)}, - {Role: ptr(menu.RoleWindowMenu)}, - {Role: ptr(menu.RoleHelpMenu)}, - } - s.menus.SetApplicationMenu(items) -} - -func ptr[T any](v T) *T { return &v } -``` - -Handler methods (`handleNewWorkspace`, `handleOpenFile`, etc.) stay in `display.go` — they use `s.windows.Open(...)` to create windows and `s.app.Event().Emit(...)` for IDE events. - -- [ ] **Step 5: Update events.go — PlatformWindow + EventSource insulation** - -1. `AttachWindowListeners` accepts `window.PlatformWindow` instead of Wails concrete type -2. `SetupWindowEventListeners` uses `EventSource` instead of `application.Get()` directly -3. `NewWSEventManager` accepts `EventSource` - -```go -// events.go — key changes (keep existing WebSocket logic intact) - -import "forge.lthn.ai/core/gui/pkg/window" - -// NewWSEventManager now accepts an EventSource for theme change events. -func NewWSEventManager(es EventSource) *WSEventManager { - em := &WSEventManager{ - eventSource: es, - // ... existing fields ... - } - return em -} - -// AttachWindowListeners accepts PlatformWindow (not *application.WebviewWindow). -func (em *WSEventManager) AttachWindowListeners(pw window.PlatformWindow) { - pw.OnWindowEvent(func(e window.WindowEvent) { - em.EmitWindowEvent(EventType(e.Type), e.Name, e.Data) - }) -} - -// SetupWindowEventListeners uses EventSource (not application.Get()). -func (em *WSEventManager) SetupWindowEventListeners() { - if em.eventSource != nil { - em.eventSource.OnThemeChange(func(isDark bool) { - theme := "light" - if isDark { - theme = "dark" - } - em.EmitWindowEvent(EventThemeChange, "", map[string]any{"theme": theme}) - }) - } -} -``` - -- [ ] **Step 6: Update display_test.go and mocks_test.go** - -Remove tests that moved to sub-packages (window option tests, tray tests). Keep orchestrator-level tests. Update mocks to compose sub-package mocks: - -```go -// mocks_test.go — simplified -type mockApp struct { - dialogManager *mockDialogManager - envManager *mockEnvManager - eventManager *mockEventManager - logger *mockLogger - quitCalled bool -} -// ... only dialog/env/event/logger mocks remain -``` - -- [ ] **Step 7: Run all tests** - -Run: `cd /Users/snider/Code/core/gui && go test ./... -v -count=1` -Expected: ALL PASS across all 4 packages - -- [ ] **Step 8: Commit** - -```bash -git add pkg/display/display.go pkg/display/interfaces.go pkg/display/events.go pkg/display/actions.go pkg/display/types.go pkg/display/display_test.go pkg/display/mocks_test.go -git rm pkg/display/window.go pkg/display/window_state.go pkg/display/layout.go pkg/display/tray.go pkg/display/menu.go -git commit -m "refactor(display): compose window/systray/menu sub-packages into orchestrator" -``` - ---- - -## Chunk 4: ui/ move and final verification - -### Task 11: Move ui/ to top level - -**Files:** -- Move: `pkg/display/ui/` → `ui/` -- Modify: Any `go:embed` directives referencing `ui/` - -- [ ] **Step 1: Move ui/ directory** - -```bash -cd /Users/snider/Code/core/gui -mv pkg/display/ui ui -``` - -- [ ] **Step 2: Check for go:embed references** - -Search for any `go:embed` directives in pkg/display/ that reference `ui/`: - -```bash -grep -r "go:embed" pkg/display/ -``` - -If found, these likely embed the Angular build output. Update paths from `ui/dist` to `../../ui/dist` or move the embed directive to a top-level file. - -- [ ] **Step 3: Verify Angular project is intact** - -```bash -ls ui/package.json ui/angular.json ui/src/main.ts -``` - -- [ ] **Step 4: Commit** - -```bash -git add -A -git commit -m "refactor: move ui/ demo to top level" -``` - ---- - -### Task 12: Final verification - -- [ ] **Step 1: Build all packages** - -```bash -cd /Users/snider/Code/core/gui && go build ./... -``` - -- [ ] **Step 2: Run all tests** - -```bash -cd /Users/snider/Code/core/gui && go test ./... -v -count=1 -``` - -- [ ] **Step 3: Run go vet** - -```bash -cd /Users/snider/Code/core/gui && go vet ./... -``` - -- [ ] **Step 4: Verify no circular dependencies** - -```bash -cd /Users/snider/Code/core/gui && go list -f '{{.ImportPath}}: {{join .Imports "\n "}}' ./pkg/window/ ./pkg/systray/ ./pkg/menu/ ./pkg/display/ -``` - -Verify: -- `pkg/window` does NOT import `pkg/display`, `pkg/systray`, or `pkg/menu` -- `pkg/systray` imports `pkg/display` (for WindowHandle) but NOT `pkg/window` or `pkg/menu` -- `pkg/menu` does NOT import `pkg/display`, `pkg/window`, or `pkg/systray` -- `pkg/display` imports `pkg/window`, `pkg/systray`, `pkg/menu` - -- [ ] **Step 5: Verify workspace builds** - -```bash -cd /Users/snider/Code && go build ./... -``` - -- [ ] **Step 6: Commit and push** - -```bash -cd /Users/snider/Code/core/gui -git add -A -git commit -m "chore: final verification after display package split" -git push origin main -``` - ---- - -## Breaking API Changes - -This split changes the public API. Downstream consumers (LEM, Mining, IDE) will need updates: - -| Old (pkg/display) | New | Notes | -|---|---|---| -| `type Window = application.WebviewWindowOptions` | `window.Window` (own struct) | No longer a Wails alias | -| `WindowOption func(*application.WebviewWindowOptions) error` | `window.WindowOption func(*window.Window) error` | Rewritten against CoreGUI's Window | -| `WindowName("x")` | `window.WithName("x")` | Renamed to `With*` prefix | -| `display.TileMode` (string) | `window.TileMode` (int iota) | Type changed | -| `display.SnapPosition` (string) | `window.SnapPosition` (int iota) | Type changed | -| `SetTrayMenu(items)` | `systray.Manager.SetMenu(items)` | Now on Manager | -| `RegisterTrayMenuCallback(id, fn)` | `systray.Manager.RegisterCallback(id, fn)` | Now on Manager | - -Screen query methods (`GetScreens`, `GetWorkAreas`, etc.) remain on `pkg/display.Service` unchanged. Dialog, clipboard, notification, and theme APIs are unchanged. - -## Deferred Work - -- **Screen insulation:** `GetScreens()`, `GetWorkAreas()`, etc. still call `application.Get()` directly. A future `ScreenProvider` interface will complete the insulation. -- **Existing layouts.json / window_state.json:** JSON field naming is preserved (camelCase) for backward compatibility with existing persisted files. -- **Clipboard image/HTML:** Clipboard remains text-only; parsed types exist but aren't used. - -## Key References - -| File | Role | -|------|------| -| `docs/superpowers/specs/2026-03-13-display-package-split-design.md` | Approved design spec | -| `pkg/display/display.go` | Current monolith (1,294 LOC) | -| `pkg/display/interfaces.go` | Current Wails abstraction layer | -| `pkg/display/tray.go` | Current tray with package-level globals | -| `pkg/display/menu.go` | Current menu with embedded click handlers | -| `pkg/display/window_state.go` | Current state persistence | -| `pkg/display/display_test.go` | Existing 63 test cases | -| `pkg/display/mocks_test.go` | Existing mock infrastructure | diff --git a/docs/superpowers/plans/2026-03-13-gui-config-wiring.md b/docs/superpowers/plans/2026-03-13-gui-config-wiring.md deleted file mode 100644 index 61e424a..0000000 --- a/docs/superpowers/plans/2026-03-13-gui-config-wiring.md +++ /dev/null @@ -1,312 +0,0 @@ -# GUI Config Wiring — Implementation Plan - -> **For agentic workers:** REQUIRED: Use superpowers:subagent-driven-development (if subagents available) or superpowers:executing-plans to implement this plan. Steps use checkbox (`- [ ]`) syntax for tracking. - -**Goal:** Replace the in-memory `loadConfig()` stub with real `.core/gui/config.yaml` loading via go-config, and wire `handleConfigTask` to persist changes to disk. - -**Architecture:** Display orchestrator owns a `*config.Config` instance pointed at `~/.core/gui/config.yaml`. On startup, it loads file contents into the existing `configData` map. On `TaskSaveConfig`, it calls `cfg.Set()` + `cfg.Commit()` to persist. Sub-services remain unchanged — they already QUERY for their section and receive `map[string]any`. - -**Tech Stack:** Go 1.26, `forge.lthn.ai/core/go` v0.2.2 (DI/IPC), `forge.lthn.ai/core/go-config` (Viper-backed YAML config), testify (assert/require) - -**Spec:** `docs/superpowers/specs/2026-03-13-gui-config-wiring-design.md` - ---- - -## File Structure - -### Modified Files - -| File | Changes | -|------|---------| -| `go.mod` | Add `forge.lthn.ai/core/go-config` dependency | -| `pkg/display/display.go` | Add `cfg *config.Config` field, replace `loadConfig()` stub, update `handleConfigTask` to persist via `cfg.Set()` + `cfg.Commit()`, add `guiConfigPath()` helper | -| `pkg/display/display_test.go` | Add config loading + persistence tests | -| `pkg/window/service.go` | Flesh out `applyConfig()` stub: read `default_width`, `default_height`, `state_file` | -| `pkg/systray/service.go` | Flesh out `applyConfig()` stub: read `icon` field (tooltip already works) | -| `pkg/menu/service.go` | Flesh out `applyConfig()` stub: read `show_dev_tools`, expose via accessor | - ---- - -## Task 1: Wire go-config into Display Orchestrator - -**Files:** -- Modify: `go.mod` -- Modify: `pkg/display/display.go` -- Modify: `pkg/display/display_test.go` - -- [ ] **Step 1: Write failing test — config loads from file** - -Add to `pkg/display/display_test.go`: - -```go -func TestLoadConfig_Good(t *testing.T) { - // Create temp config file - dir := t.TempDir() - cfgPath := filepath.Join(dir, ".core", "gui", "config.yaml") - require.NoError(t, os.MkdirAll(filepath.Dir(cfgPath), 0o755)) - require.NoError(t, os.WriteFile(cfgPath, []byte(` -window: - default_width: 1280 - default_height: 720 -systray: - tooltip: "Test App" -menu: - show_dev_tools: false -`), 0o644)) - - s, _ := New() - s.loadConfigFrom(cfgPath) - - // Verify configData was populated from file - assert.Equal(t, 1280, s.configData["window"]["default_width"]) - assert.Equal(t, "Test App", s.configData["systray"]["tooltip"]) - assert.Equal(t, false, s.configData["menu"]["show_dev_tools"]) -} - -func TestLoadConfig_Bad_MissingFile(t *testing.T) { - s, _ := New() - s.loadConfigFrom(filepath.Join(t.TempDir(), "nonexistent.yaml")) - - // Should not panic, configData stays at empty defaults - assert.Empty(t, s.configData["window"]) - assert.Empty(t, s.configData["systray"]) - assert.Empty(t, s.configData["menu"]) -} -``` - -- [ ] **Step 2: Add go-config dependency** - -```bash -cd /path/to/core/gui -go get forge.lthn.ai/core/go-config -``` - -The Go workspace will resolve it locally from `~/Code/core/go-config`. - -- [ ] **Step 3: Implement `loadConfig()` and `loadConfigFrom()`** - -In `pkg/display/display.go`, add the `cfg` field and replace the stub: - -```go -import ( - "os" - "path/filepath" - - "forge.lthn.ai/core/go-config" -) - -type Service struct { - *core.ServiceRuntime[Options] - wailsApp *application.App - app App - config Options - configData map[string]map[string]any - cfg *config.Config // go-config instance for file persistence - notifier *notifications.NotificationService - events *WSEventManager -} - -func guiConfigPath() string { - home, err := os.UserHomeDir() - if err != nil { - return filepath.Join(".core", "gui", "config.yaml") - } - return filepath.Join(home, ".core", "gui", "config.yaml") -} - -func (s *Service) loadConfig() { - s.loadConfigFrom(guiConfigPath()) -} - -func (s *Service) loadConfigFrom(path string) { - cfg, err := config.New(config.WithPath(path)) - if err != nil { - // Non-critical — continue with empty configData - return - } - s.cfg = cfg - - for _, section := range []string{"window", "systray", "menu"} { - var data map[string]any - if err := cfg.Get(section, &data); err == nil && data != nil { - s.configData[section] = data - } - } -} -``` - -- [ ] **Step 4: Write failing test — config persists on TaskSaveConfig** - -```go -func TestHandleConfigTask_Persists_Good(t *testing.T) { - dir := t.TempDir() - cfgPath := filepath.Join(dir, "config.yaml") - - s, _ := New() - s.loadConfigFrom(cfgPath) // Creates empty config (file doesn't exist yet) - - // Simulate a TaskSaveConfig through the handler - c, _ := core.New( - core.WithService(func(c *core.Core) (any, error) { - s.ServiceRuntime = core.NewServiceRuntime[Options](c, Options{}) - return s, nil - }), - core.WithServiceLock(), - ) - c.ServiceStartup(context.Background(), nil) - - _, handled, err := c.PERFORM(window.TaskSaveConfig{ - Value: map[string]any{"default_width": 1920}, - }) - require.NoError(t, err) - assert.True(t, handled) - - // Verify file was written - data, err := os.ReadFile(cfgPath) - require.NoError(t, err) - assert.Contains(t, string(data), "default_width") -} -``` - -- [ ] **Step 5: Update `handleConfigTask` to persist** - -```go -func (s *Service) handleConfigTask(c *core.Core, t core.Task) (any, bool, error) { - switch t := t.(type) { - case window.TaskSaveConfig: - s.configData["window"] = t.Value - s.persistSection("window", t.Value) - return nil, true, nil - case systray.TaskSaveConfig: - s.configData["systray"] = t.Value - s.persistSection("systray", t.Value) - return nil, true, nil - case menu.TaskSaveConfig: - s.configData["menu"] = t.Value - s.persistSection("menu", t.Value) - return nil, true, nil - default: - return nil, false, nil - } -} - -func (s *Service) persistSection(key string, value map[string]any) { - if s.cfg == nil { - return - } - _ = s.cfg.Set(key, value) - _ = s.cfg.Commit() -} -``` - -- [ ] **Step 6: Run tests, verify green** - -```bash -core go test --run TestLoadConfig -core go test --run TestHandleConfigTask_Persists -``` - ---- - -## Task 2: Flesh Out Sub-Service `applyConfig()` Stubs - -**Files:** -- Modify: `pkg/window/service.go` -- Modify: `pkg/systray/service.go` -- Modify: `pkg/menu/service.go` - -- [ ] **Step 1: Window — apply default dimensions** - -In `pkg/window/service.go`, replace the stub: - -```go -func (s *Service) applyConfig(cfg map[string]any) { - if w, ok := cfg["default_width"]; ok { - if width, ok := w.(int); ok { - s.manager.SetDefaultWidth(width) - } - } - if h, ok := cfg["default_height"]; ok { - if height, ok := h.(int); ok { - s.manager.SetDefaultHeight(height) - } - } - if sf, ok := cfg["state_file"]; ok { - if stateFile, ok := sf.(string); ok { - s.manager.State().SetPath(stateFile) - } - } -} -``` - -> **Note:** The Manager's `SetDefaultWidth`, `SetDefaultHeight`, and `State().SetPath()` methods may need to be added if they don't exist yet. If not present, skip those calls and add a `// TODO:` — this task is about wiring config, not extending Manager's API. - -- [ ] **Step 2: Systray — add icon path handling** - -In `pkg/systray/service.go`, extend the existing `applyConfig`: - -```go -func (s *Service) applyConfig(cfg map[string]any) { - tooltip, _ := cfg["tooltip"].(string) - if tooltip == "" { - tooltip = "Core" - } - _ = s.manager.Setup(tooltip, tooltip) - - if iconPath, ok := cfg["icon"].(string); ok && iconPath != "" { - // Icon loading is deferred to when assets are available. - // Store the path for later use. - s.iconPath = iconPath - } -} -``` - -Add `iconPath string` field to `Service` struct. - -- [ ] **Step 3: Menu — read show_dev_tools flag** - -In `pkg/menu/service.go`, replace the stub: - -```go -func (s *Service) applyConfig(cfg map[string]any) { - if v, ok := cfg["show_dev_tools"]; ok { - if show, ok := v.(bool); ok { - s.showDevTools = show - } - } -} - -// ShowDevTools returns whether developer tools menu items should be shown. -func (s *Service) ShowDevTools() bool { - return s.showDevTools -} -``` - -Add `showDevTools bool` field to `Service` struct (default `false`). - -- [ ] **Step 4: Run full test suite** - -```bash -core go test -``` - ---- - -## Completion Criteria - -1. `loadConfig()` reads from `~/.core/gui/config.yaml` via go-config -2. `handleConfigTask` persists changes to disk via `cfg.Set()` + `cfg.Commit()` -3. Missing/malformed config file does not crash the GUI -4. Sub-service `applyConfig()` methods consume real config values -5. All existing tests continue to pass -6. New tests cover load, persist, and missing-file scenarios - -## Deferred Work - -- **Manifest/slots integration**: go-scm `Manifest.Layout`/`Manifest.Slots` could feed a `layout` config section for user slot preferences. Not needed yet. -- **Manager API extensions**: `SetDefaultWidth()`, `SetDefaultHeight()`, `State().SetPath()` — add when window Manager is extended. -- **Config file watching**: Viper supports `WatchConfig()` for live reload. Not needed for a desktop app where config changes come through IPC. - -## Licence - -EUPL-1.2 diff --git a/docs/superpowers/plans/2026-03-13-gui-extract-insulate.md b/docs/superpowers/plans/2026-03-13-gui-extract-insulate.md deleted file mode 100644 index 2fd2344..0000000 --- a/docs/superpowers/plans/2026-03-13-gui-extract-insulate.md +++ /dev/null @@ -1,1713 +0,0 @@ -# GUI Extract & Insulate Implementation Plan - -> **For agentic workers:** REQUIRED: Use superpowers:subagent-driven-development (if subagents available) or superpowers:executing-plans to implement this plan. Steps use checkbox (`- [ ]`) syntax for tracking. - -**Goal:** Extract clipboard, dialog, notification, environment, and screen from pkg/display into independent core.Service packages with Platform interface insulation and full IPC coverage. - -**Architecture:** Each package follows the three-layer pattern (IPC Bus → Service → Platform Interface) established by window/systray/menu. The display orchestrator sheds ~420 lines and gains HandleIPCEvents + WS bridge cases for the new Action types. - -**Tech Stack:** Go, core/go DI framework, Wails v3 (behind Platform interfaces), gorilla/websocket (WSEventManager) - -**Spec:** `docs/superpowers/specs/2026-03-13-gui-extract-insulate-design.md` - ---- - -## File Structure - -### New files (per package, 5 packages × 4 files = 20 files) - -| Package | File | Responsibility | -|---------|------|---------------| -| `pkg/clipboard/` | `platform.go` | Platform interface (2 methods) | -| | `messages.go` | IPC message types (QueryText, TaskSetText, TaskClear) | -| | `service.go` | Service struct, Register(), OnStartup(), handlers | -| | `service_test.go` | Tests with mock platform | -| `pkg/dialog/` | `platform.go` | Platform interface (4 methods) + option types | -| | `messages.go` | IPC message types (4 Tasks) | -| | `service.go` | Service struct, Register(), OnStartup(), handlers | -| | `service_test.go` | Tests with mock platform | -| `pkg/notification/` | `platform.go` | Platform interface (3 methods) + option types | -| | `messages.go` | IPC message types (QueryPermission, TaskSend, TaskRequestPermission) | -| | `service.go` | Service struct, Register(), OnStartup(), handlers, fallback logic | -| | `service_test.go` | Tests with mock platform + fallback test | -| `pkg/environment/` | `platform.go` | Platform interface (5 methods) + own types | -| | `messages.go` | IPC message types (3 Queries, 1 Task, 1 Action) | -| | `service.go` | Service struct, Register(), OnStartup(), theme callback, handlers | -| | `service_test.go` | Tests with mock platform + theme change test | -| `pkg/screen/` | `platform.go` | Platform interface (2 methods) + own types | -| | `messages.go` | IPC message types (4 Queries, 1 Action) | -| | `service.go` | Service struct, Register(), OnStartup(), computed queries | -| | `service_test.go` | Tests with mock platform + computed query tests | - -### Modified files - -| File | Change | -|------|--------| -| `pkg/display/display.go` | Remove screen methods (~120 lines), remove ShowEnvironmentDialog, remove ScreenInfo/WorkArea types | -| `pkg/display/events.go` | Change `NewWSEventManager` signature to drop `EventSource`, remove `SetupWindowEventListeners()` | -| `pkg/display/interfaces.go` | Remove `wailsEventSource`, `wailsDialogManager` (moved to new packages) | -| `pkg/display/types.go` | Remove `EventSource` interface | -| `pkg/display/clipboard.go` | DELETE (moved to pkg/clipboard) | -| `pkg/display/dialog.go` | DELETE (moved to pkg/dialog) | -| `pkg/display/notification.go` | DELETE (moved to pkg/notification) | -| `pkg/display/theme.go` | DELETE (moved to pkg/environment) | - ---- - -## Chunk 1: Clipboard + Dialog - -### Task 1: Create pkg/clipboard - -**Files:** -- Create: `pkg/clipboard/platform.go` -- Create: `pkg/clipboard/messages.go` -- Create: `pkg/clipboard/service.go` -- Create: `pkg/clipboard/service_test.go` -- Delete: `pkg/display/clipboard.go` (after Task 7) - -- [ ] **Step 1: Create platform.go** - -```go -// pkg/clipboard/platform.go -package clipboard - -// Platform abstracts the system clipboard backend. -type Platform interface { - Text() (string, bool) - SetText(text string) bool -} - -// ClipboardContent is the result of QueryText. -type ClipboardContent struct { - Text string `json:"text"` - HasContent bool `json:"hasContent"` -} -``` - -- [ ] **Step 2: Create messages.go** - -```go -// pkg/clipboard/messages.go -package clipboard - -// QueryText reads the clipboard. Result: ClipboardContent -type QueryText struct{} - -// TaskSetText writes text to the clipboard. Result: bool (success) -type TaskSetText struct{ Text string } - -// TaskClear clears the clipboard. Result: bool (success) -type TaskClear struct{} -``` - -- [ ] **Step 3: Write failing test** - -```go -// pkg/clipboard/service_test.go -package clipboard - -import ( - "context" - "testing" - - "forge.lthn.ai/core/go/pkg/core" - "github.com/stretchr/testify/assert" - "github.com/stretchr/testify/require" -) - -type mockPlatform struct { - text string - ok bool -} - -func (m *mockPlatform) Text() (string, bool) { return m.text, m.ok } -func (m *mockPlatform) SetText(text string) bool { - m.text = text - m.ok = text != "" - return true -} - -func newTestService(t *testing.T) (*Service, *core.Core) { - t.Helper() - c, err := core.New( - core.WithService(Register(&mockPlatform{text: "hello", ok: true})), - core.WithServiceLock(), - ) - require.NoError(t, err) - require.NoError(t, c.ServiceStartup(context.Background(), nil)) - svc := core.MustServiceFor[*Service](c, "clipboard") - return svc, c -} - -func TestRegister_Good(t *testing.T) { - svc, _ := newTestService(t) - assert.NotNil(t, svc) -} - -func TestQueryText_Good(t *testing.T) { - _, c := newTestService(t) - result, handled, err := c.QUERY(QueryText{}) - require.NoError(t, err) - assert.True(t, handled) - content := result.(ClipboardContent) - assert.Equal(t, "hello", content.Text) - assert.True(t, content.HasContent) -} - -func TestQueryText_Bad(t *testing.T) { - // No clipboard service registered - c, _ := core.New(core.WithServiceLock()) - _, handled, _ := c.QUERY(QueryText{}) - assert.False(t, handled) -} - -func TestTaskSetText_Good(t *testing.T) { - _, c := newTestService(t) - result, handled, err := c.PERFORM(TaskSetText{Text: "world"}) - require.NoError(t, err) - assert.True(t, handled) - assert.Equal(t, true, result) - - // Verify via query - r, _, _ := c.QUERY(QueryText{}) - assert.Equal(t, "world", r.(ClipboardContent).Text) -} - -func TestTaskClear_Good(t *testing.T) { - _, c := newTestService(t) - _, handled, err := c.PERFORM(TaskClear{}) - require.NoError(t, err) - assert.True(t, handled) - - // Verify empty - r, _, _ := c.QUERY(QueryText{}) - assert.Equal(t, "", r.(ClipboardContent).Text) - assert.False(t, r.(ClipboardContent).HasContent) -} -``` - -- [ ] **Step 4: Run test to verify it fails** - -Run: `cd /Users/snider/Code/core/gui && go test ./pkg/clipboard/ -v` -Expected: FAIL — `Service` type not defined - -- [ ] **Step 5: Create service.go** - -```go -// pkg/clipboard/service.go -package clipboard - -import ( - "context" - - "forge.lthn.ai/core/go/pkg/core" -) - -// Options holds configuration for the clipboard service. -type Options struct{} - -// Service is a core.Service managing clipboard operations via IPC. -type Service struct { - *core.ServiceRuntime[Options] - platform Platform -} - -// Register creates a factory closure that captures the Platform adapter. -func Register(p Platform) func(*core.Core) (any, error) { - return func(c *core.Core) (any, error) { - return &Service{ - ServiceRuntime: core.NewServiceRuntime[Options](c, Options{}), - platform: p, - }, nil - } -} - -// OnStartup registers IPC handlers. -func (s *Service) OnStartup(ctx context.Context) error { - s.Core().RegisterQuery(s.handleQuery) - s.Core().RegisterTask(s.handleTask) - return nil -} - -// HandleIPCEvents is auto-discovered by core.WithService. -func (s *Service) HandleIPCEvents(c *core.Core, msg core.Message) error { - return nil -} - -func (s *Service) handleQuery(c *core.Core, q core.Query) (any, bool, error) { - switch q.(type) { - case QueryText: - text, ok := s.platform.Text() - return ClipboardContent{Text: text, HasContent: ok && text != ""}, true, nil - default: - return nil, false, nil - } -} - -func (s *Service) handleTask(c *core.Core, t core.Task) (any, bool, error) { - switch t := t.(type) { - case TaskSetText: - return s.platform.SetText(t.Text), true, nil - case TaskClear: - return s.platform.SetText(""), true, nil - default: - return nil, false, nil - } -} -``` - -- [ ] **Step 6: Run tests to verify they pass** - -Run: `cd /Users/snider/Code/core/gui && go test ./pkg/clipboard/ -v` -Expected: PASS (5 tests) - -- [ ] **Step 7: Commit** - -```bash -cd /Users/snider/Code/core/gui -git add pkg/clipboard/ -git commit -m "feat(clipboard): add clipboard core.Service with Platform interface and IPC" -``` - ---- - -### Task 2: Create pkg/dialog - -**Files:** -- Create: `pkg/dialog/platform.go` -- Create: `pkg/dialog/messages.go` -- Create: `pkg/dialog/service.go` -- Create: `pkg/dialog/service_test.go` -- Delete: `pkg/display/dialog.go` (after Task 7) - -- [ ] **Step 1: Create platform.go with types** - -```go -// pkg/dialog/platform.go -package dialog - -// Platform abstracts the native dialog backend. -type Platform interface { - OpenFile(opts OpenFileOptions) ([]string, error) - SaveFile(opts SaveFileOptions) (string, error) - OpenDirectory(opts OpenDirectoryOptions) (string, error) - MessageDialog(opts MessageDialogOptions) (string, error) -} - -// DialogType represents the type of message dialog. -type DialogType int - -const ( - DialogInfo DialogType = iota - DialogWarning - DialogError - DialogQuestion -) - -// OpenFileOptions contains options for the open file dialog. -type OpenFileOptions struct { - Title string `json:"title,omitempty"` - Directory string `json:"directory,omitempty"` - Filename string `json:"filename,omitempty"` - Filters []FileFilter `json:"filters,omitempty"` - AllowMultiple bool `json:"allowMultiple,omitempty"` -} - -// SaveFileOptions contains options for the save file dialog. -type SaveFileOptions struct { - Title string `json:"title,omitempty"` - Directory string `json:"directory,omitempty"` - Filename string `json:"filename,omitempty"` - Filters []FileFilter `json:"filters,omitempty"` -} - -// OpenDirectoryOptions contains options for the directory picker. -type OpenDirectoryOptions struct { - Title string `json:"title,omitempty"` - Directory string `json:"directory,omitempty"` - AllowMultiple bool `json:"allowMultiple,omitempty"` -} - -// MessageDialogOptions contains options for a message dialog. -type MessageDialogOptions struct { - Type DialogType `json:"type"` - Title string `json:"title"` - Message string `json:"message"` - Buttons []string `json:"buttons,omitempty"` -} - -// FileFilter represents a file type filter for dialogs. -type FileFilter struct { - DisplayName string `json:"displayName"` - Pattern string `json:"pattern"` - Extensions []string `json:"extensions,omitempty"` -} -``` - -- [ ] **Step 2: Create messages.go** - -```go -// pkg/dialog/messages.go -package dialog - -// TaskOpenFile shows an open file dialog. Result: []string (paths) -type TaskOpenFile struct{ Opts OpenFileOptions } - -// TaskSaveFile shows a save file dialog. Result: string (path) -type TaskSaveFile struct{ Opts SaveFileOptions } - -// TaskOpenDirectory shows a directory picker. Result: string (path) -type TaskOpenDirectory struct{ Opts OpenDirectoryOptions } - -// TaskMessageDialog shows a message dialog. Result: string (button clicked) -type TaskMessageDialog struct{ Opts MessageDialogOptions } -``` - -- [ ] **Step 3: Write failing test** - -```go -// pkg/dialog/service_test.go -package dialog - -import ( - "context" - "testing" - - "forge.lthn.ai/core/go/pkg/core" - "github.com/stretchr/testify/assert" - "github.com/stretchr/testify/require" -) - -type mockPlatform struct { - openFilePaths []string - saveFilePath string - openDirPath string - messageButton string - openFileErr error - saveFileErr error - openDirErr error - messageErr error - lastOpenOpts OpenFileOptions - lastSaveOpts SaveFileOptions - lastDirOpts OpenDirectoryOptions - lastMsgOpts MessageDialogOptions -} - -func (m *mockPlatform) OpenFile(opts OpenFileOptions) ([]string, error) { - m.lastOpenOpts = opts - return m.openFilePaths, m.openFileErr -} -func (m *mockPlatform) SaveFile(opts SaveFileOptions) (string, error) { - m.lastSaveOpts = opts - return m.saveFilePath, m.saveFileErr -} -func (m *mockPlatform) OpenDirectory(opts OpenDirectoryOptions) (string, error) { - m.lastDirOpts = opts - return m.openDirPath, m.openDirErr -} -func (m *mockPlatform) MessageDialog(opts MessageDialogOptions) (string, error) { - m.lastMsgOpts = opts - return m.messageButton, m.messageErr -} - -func newTestService(t *testing.T) (*mockPlatform, *core.Core) { - t.Helper() - mock := &mockPlatform{ - openFilePaths: []string{"/tmp/file.txt"}, - saveFilePath: "/tmp/save.txt", - openDirPath: "/tmp/dir", - messageButton: "OK", - } - c, err := core.New( - core.WithService(Register(mock)), - core.WithServiceLock(), - ) - require.NoError(t, err) - require.NoError(t, c.ServiceStartup(context.Background(), nil)) - return mock, c -} - -func TestRegister_Good(t *testing.T) { - _, c := newTestService(t) - svc := core.MustServiceFor[*Service](c, "dialog") - assert.NotNil(t, svc) -} - -func TestTaskOpenFile_Good(t *testing.T) { - mock, c := newTestService(t) - mock.openFilePaths = []string{"/a.txt", "/b.txt"} - - result, handled, err := c.PERFORM(TaskOpenFile{ - Opts: OpenFileOptions{Title: "Pick", AllowMultiple: true}, - }) - require.NoError(t, err) - assert.True(t, handled) - paths := result.([]string) - assert.Equal(t, []string{"/a.txt", "/b.txt"}, paths) - assert.Equal(t, "Pick", mock.lastOpenOpts.Title) - assert.True(t, mock.lastOpenOpts.AllowMultiple) -} - -func TestTaskSaveFile_Good(t *testing.T) { - _, c := newTestService(t) - result, handled, err := c.PERFORM(TaskSaveFile{ - Opts: SaveFileOptions{Filename: "out.txt"}, - }) - require.NoError(t, err) - assert.True(t, handled) - assert.Equal(t, "/tmp/save.txt", result) -} - -func TestTaskOpenDirectory_Good(t *testing.T) { - _, c := newTestService(t) - result, handled, err := c.PERFORM(TaskOpenDirectory{ - Opts: OpenDirectoryOptions{Title: "Pick Dir"}, - }) - require.NoError(t, err) - assert.True(t, handled) - assert.Equal(t, "/tmp/dir", result) -} - -func TestTaskMessageDialog_Good(t *testing.T) { - mock, c := newTestService(t) - mock.messageButton = "Yes" - - result, handled, err := c.PERFORM(TaskMessageDialog{ - Opts: MessageDialogOptions{ - Type: DialogQuestion, Title: "Confirm", - Message: "Sure?", Buttons: []string{"Yes", "No"}, - }, - }) - require.NoError(t, err) - assert.True(t, handled) - assert.Equal(t, "Yes", result) - assert.Equal(t, DialogQuestion, mock.lastMsgOpts.Type) -} - -func TestTaskOpenFile_Bad(t *testing.T) { - c, _ := core.New(core.WithServiceLock()) - _, handled, _ := c.PERFORM(TaskOpenFile{}) - assert.False(t, handled) -} -``` - -- [ ] **Step 4: Run test to verify it fails** - -Run: `cd /Users/snider/Code/core/gui && go test ./pkg/dialog/ -v` -Expected: FAIL — `Service` type not defined - -- [ ] **Step 5: Create service.go** - -```go -// pkg/dialog/service.go -package dialog - -import ( - "context" - - "forge.lthn.ai/core/go/pkg/core" -) - -// Options holds configuration for the dialog service. -type Options struct{} - -// Service is a core.Service managing native dialogs via IPC. -type Service struct { - *core.ServiceRuntime[Options] - platform Platform -} - -// Register creates a factory closure that captures the Platform adapter. -func Register(p Platform) func(*core.Core) (any, error) { - return func(c *core.Core) (any, error) { - return &Service{ - ServiceRuntime: core.NewServiceRuntime[Options](c, Options{}), - platform: p, - }, nil - } -} - -// OnStartup registers IPC handlers. -func (s *Service) OnStartup(ctx context.Context) error { - s.Core().RegisterTask(s.handleTask) - return nil -} - -// HandleIPCEvents is auto-discovered by core.WithService. -func (s *Service) HandleIPCEvents(c *core.Core, msg core.Message) error { - return nil -} - -func (s *Service) handleTask(c *core.Core, t core.Task) (any, bool, error) { - switch t := t.(type) { - case TaskOpenFile: - paths, err := s.platform.OpenFile(t.Opts) - return paths, true, err - case TaskSaveFile: - path, err := s.platform.SaveFile(t.Opts) - return path, true, err - case TaskOpenDirectory: - path, err := s.platform.OpenDirectory(t.Opts) - return path, true, err - case TaskMessageDialog: - button, err := s.platform.MessageDialog(t.Opts) - return button, true, err - default: - return nil, false, nil - } -} -``` - -- [ ] **Step 6: Run tests to verify they pass** - -Run: `cd /Users/snider/Code/core/gui && go test ./pkg/dialog/ -v` -Expected: PASS (6 tests) - -- [ ] **Step 7: Commit** - -```bash -cd /Users/snider/Code/core/gui -git add pkg/dialog/ -git commit -m "feat(dialog): add dialog core.Service with Platform interface and IPC" -``` - ---- - -## Chunk 2: Notification + Environment - -### Task 3: Create pkg/notification - -**Files:** -- Create: `pkg/notification/platform.go` -- Create: `pkg/notification/messages.go` -- Create: `pkg/notification/service.go` -- Create: `pkg/notification/service_test.go` -- Delete: `pkg/display/notification.go` (after Task 7) - -- [ ] **Step 1: Create platform.go with types** - -```go -// pkg/notification/platform.go -package notification - -// Platform abstracts the native notification backend. -type Platform interface { - Send(opts NotificationOptions) error - RequestPermission() (bool, error) - CheckPermission() (bool, error) -} - -// NotificationSeverity indicates the severity for dialog fallback. -type NotificationSeverity int - -const ( - SeverityInfo NotificationSeverity = iota - SeverityWarning - SeverityError -) - -// 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"` -} - -// PermissionStatus indicates whether notifications are authorised. -type PermissionStatus struct { - Granted bool `json:"granted"` -} -``` - -- [ ] **Step 2: Create messages.go** - -```go -// pkg/notification/messages.go -package notification - -// QueryPermission checks notification authorisation. Result: PermissionStatus -type QueryPermission struct{} - -// TaskSend sends a notification. Falls back to dialog if platform fails. -type TaskSend struct{ Opts NotificationOptions } - -// TaskRequestPermission requests notification authorisation. Result: bool (granted) -type TaskRequestPermission struct{} - -// ActionNotificationClicked is broadcast when a notification is clicked (future). -type ActionNotificationClicked struct{ ID string } -``` - -- [ ] **Step 3: Write failing test** - -```go -// pkg/notification/service_test.go -package notification - -import ( - "context" - "errors" - "testing" - - "forge.lthn.ai/core/go/pkg/core" - "forge.lthn.ai/core/gui/pkg/dialog" - "github.com/stretchr/testify/assert" - "github.com/stretchr/testify/require" -) - -type mockPlatform struct { - sendErr error - permGranted bool - permErr error - lastOpts NotificationOptions - sendCalled bool -} - -func (m *mockPlatform) Send(opts NotificationOptions) error { - m.sendCalled = true - m.lastOpts = opts - return m.sendErr -} -func (m *mockPlatform) RequestPermission() (bool, error) { return m.permGranted, m.permErr } -func (m *mockPlatform) CheckPermission() (bool, error) { return m.permGranted, m.permErr } - -// mockDialogPlatform tracks whether MessageDialog was called (for fallback test). -type mockDialogPlatform struct { - messageCalled bool - lastMsgOpts dialog.MessageDialogOptions -} - -func (m *mockDialogPlatform) OpenFile(opts dialog.OpenFileOptions) ([]string, error) { return nil, nil } -func (m *mockDialogPlatform) SaveFile(opts dialog.SaveFileOptions) (string, error) { return "", nil } -func (m *mockDialogPlatform) OpenDirectory(opts dialog.OpenDirectoryOptions) (string, error) { - return "", nil -} -func (m *mockDialogPlatform) MessageDialog(opts dialog.MessageDialogOptions) (string, error) { - m.messageCalled = true - m.lastMsgOpts = opts - return "OK", nil -} - -func newTestService(t *testing.T) (*mockPlatform, *core.Core) { - t.Helper() - mock := &mockPlatform{permGranted: true} - c, err := core.New( - core.WithService(Register(mock)), - core.WithServiceLock(), - ) - require.NoError(t, err) - require.NoError(t, c.ServiceStartup(context.Background(), nil)) - return mock, c -} - -func TestRegister_Good(t *testing.T) { - _, c := newTestService(t) - svc := core.MustServiceFor[*Service](c, "notification") - assert.NotNil(t, svc) -} - -func TestTaskSend_Good(t *testing.T) { - mock, c := newTestService(t) - _, handled, err := c.PERFORM(TaskSend{ - Opts: NotificationOptions{Title: "Test", Message: "Hello"}, - }) - require.NoError(t, err) - assert.True(t, handled) - assert.True(t, mock.sendCalled) - assert.Equal(t, "Test", mock.lastOpts.Title) -} - -func TestTaskSend_Fallback_Good(t *testing.T) { - // Platform fails → falls back to dialog via IPC - mockNotify := &mockPlatform{sendErr: errors.New("no permission")} - mockDlg := &mockDialogPlatform{} - c, err := core.New( - core.WithService(dialog.Register(mockDlg)), - core.WithService(Register(mockNotify)), - core.WithServiceLock(), - ) - require.NoError(t, err) - require.NoError(t, c.ServiceStartup(context.Background(), nil)) - - _, handled, err := c.PERFORM(TaskSend{ - Opts: NotificationOptions{Title: "Warn", Message: "Oops", Severity: SeverityWarning}, - }) - assert.True(t, handled) - assert.NoError(t, err) // fallback succeeds even though platform failed - assert.True(t, mockDlg.messageCalled) - assert.Equal(t, dialog.DialogWarning, mockDlg.lastMsgOpts.Type) -} - -func TestQueryPermission_Good(t *testing.T) { - _, c := newTestService(t) - result, handled, err := c.QUERY(QueryPermission{}) - require.NoError(t, err) - assert.True(t, handled) - status := result.(PermissionStatus) - assert.True(t, status.Granted) -} - -func TestTaskRequestPermission_Good(t *testing.T) { - _, c := newTestService(t) - result, handled, err := c.PERFORM(TaskRequestPermission{}) - require.NoError(t, err) - assert.True(t, handled) - assert.Equal(t, true, result) -} - -func TestTaskSend_Bad(t *testing.T) { - c, _ := core.New(core.WithServiceLock()) - _, handled, _ := c.PERFORM(TaskSend{}) - assert.False(t, handled) -} -``` - -- [ ] **Step 4: Run test to verify it fails** - -Run: `cd /Users/snider/Code/core/gui && go test ./pkg/notification/ -v` -Expected: FAIL — `Service` type not defined - -- [ ] **Step 5: Create service.go** - -```go -// pkg/notification/service.go -package notification - -import ( - "context" - "fmt" - "time" - - "forge.lthn.ai/core/go/pkg/core" - "forge.lthn.ai/core/gui/pkg/dialog" -) - -// Options holds configuration for the notification service. -type Options struct{} - -// Service is a core.Service managing notifications via IPC. -type Service struct { - *core.ServiceRuntime[Options] - platform Platform -} - -// Register creates a factory closure that captures the Platform adapter. -func Register(p Platform) func(*core.Core) (any, error) { - return func(c *core.Core) (any, error) { - return &Service{ - ServiceRuntime: core.NewServiceRuntime[Options](c, Options{}), - platform: p, - }, nil - } -} - -// OnStartup registers IPC handlers. -func (s *Service) OnStartup(ctx context.Context) error { - s.Core().RegisterQuery(s.handleQuery) - s.Core().RegisterTask(s.handleTask) - return nil -} - -// HandleIPCEvents is auto-discovered by core.WithService. -func (s *Service) HandleIPCEvents(c *core.Core, msg core.Message) error { - return nil -} - -func (s *Service) handleQuery(c *core.Core, q core.Query) (any, bool, error) { - switch q.(type) { - case QueryPermission: - granted, err := s.platform.CheckPermission() - return PermissionStatus{Granted: granted}, true, err - default: - return nil, false, nil - } -} - -func (s *Service) handleTask(c *core.Core, t core.Task) (any, bool, error) { - switch t := t.(type) { - case TaskSend: - return nil, true, s.send(t.Opts) - case TaskRequestPermission: - granted, err := s.platform.RequestPermission() - return granted, true, err - default: - return nil, false, nil - } -} - -// send attempts native notification, falls back to dialog via IPC. -func (s *Service) send(opts NotificationOptions) error { - // Generate ID if not provided - if opts.ID == "" { - opts.ID = fmt.Sprintf("core-%d", time.Now().UnixNano()) - } - - if err := s.platform.Send(opts); err != nil { - // Fallback: show as dialog via IPC - return s.fallbackDialog(opts) - } - return nil -} - -// fallbackDialog shows a dialog via IPC when native notifications fail. -func (s *Service) fallbackDialog(opts NotificationOptions) error { - // Map severity to dialog type - var dt dialog.DialogType - switch opts.Severity { - case SeverityWarning: - dt = dialog.DialogWarning - case SeverityError: - dt = dialog.DialogError - default: - dt = dialog.DialogInfo - } - - msg := opts.Message - if opts.Subtitle != "" { - msg = opts.Subtitle + "\n\n" + msg - } - - _, _, err := s.Core().PERFORM(dialog.TaskMessageDialog{ - Opts: dialog.MessageDialogOptions{ - Type: dt, - Title: opts.Title, - Message: msg, - Buttons: []string{"OK"}, - }, - }) - return err -} -``` - -- [ ] **Step 6: Run tests to verify they pass** - -Run: `cd /Users/snider/Code/core/gui && go test ./pkg/notification/ -v` -Expected: PASS (6 tests) - -- [ ] **Step 7: Commit** - -```bash -cd /Users/snider/Code/core/gui -git add pkg/notification/ -git commit -m "feat(notification): add notification core.Service with fallback to dialog via IPC" -``` - ---- - -### Task 4: Create pkg/environment - -**Files:** -- Create: `pkg/environment/platform.go` -- Create: `pkg/environment/messages.go` -- Create: `pkg/environment/service.go` -- Create: `pkg/environment/service_test.go` -- Delete: `pkg/display/theme.go` (after Task 7) - -- [ ] **Step 1: Create platform.go with types** - -```go -// pkg/environment/platform.go -package environment - -// Platform abstracts environment and theme backend queries. -type Platform interface { - IsDarkMode() bool - Info() EnvironmentInfo - AccentColour() string - OpenFileManager(path string, selectFile bool) error - OnThemeChange(handler func(isDark bool)) func() // returns cancel func -} - -// EnvironmentInfo contains system environment details. -type EnvironmentInfo struct { - OS string `json:"os"` - Arch string `json:"arch"` - Debug bool `json:"debug"` - Platform PlatformInfo `json:"platform"` -} - -// PlatformInfo contains platform-specific details. -type PlatformInfo struct { - Name string `json:"name"` - Version string `json:"version"` -} - -// ThemeInfo contains the current theme state. -type ThemeInfo struct { - IsDark bool `json:"isDark"` - Theme string `json:"theme"` // "dark" or "light" -} -``` - -- [ ] **Step 2: Create messages.go** - -```go -// pkg/environment/messages.go -package environment - -// QueryTheme returns the current theme. Result: ThemeInfo -type QueryTheme struct{} - -// QueryInfo returns environment information. Result: EnvironmentInfo -type QueryInfo struct{} - -// QueryAccentColour returns the system accent colour. Result: string -type QueryAccentColour struct{} - -// TaskOpenFileManager opens the system file manager. Result: error only -type TaskOpenFileManager struct { - Path string `json:"path"` - Select bool `json:"select"` -} - -// ActionThemeChanged is broadcast when the system theme changes. -type ActionThemeChanged struct { - IsDark bool `json:"isDark"` -} -``` - -- [ ] **Step 3: Write failing test** - -```go -// pkg/environment/service_test.go -package environment - -import ( - "context" - "sync" - "testing" - - "forge.lthn.ai/core/go/pkg/core" - "github.com/stretchr/testify/assert" - "github.com/stretchr/testify/require" -) - -type mockPlatform struct { - isDark bool - info EnvironmentInfo - accentColour string - openFMErr error - themeHandler func(isDark bool) - 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) OpenFileManager(path string, selectFile bool) error { - return m.openFMErr -} -func (m *mockPlatform) OnThemeChange(handler func(isDark bool)) func() { - m.mu.Lock() - m.themeHandler = handler - m.mu.Unlock() - return func() { - m.mu.Lock() - m.themeHandler = nil - m.mu.Unlock() - } -} - -// simulateThemeChange triggers the stored handler (test helper). -func (m *mockPlatform) simulateThemeChange(isDark bool) { - m.mu.Lock() - h := m.themeHandler - m.mu.Unlock() - if h != nil { - h(isDark) - } -} - -func newTestService(t *testing.T) (*mockPlatform, *core.Core) { - t.Helper() - mock := &mockPlatform{ - isDark: true, - accentColour: "rgb(0,122,255)", - info: EnvironmentInfo{ - OS: "darwin", Arch: "arm64", - Platform: PlatformInfo{Name: "macOS", Version: "14.0"}, - }, - } - c, err := core.New( - core.WithService(Register(mock)), - core.WithServiceLock(), - ) - require.NoError(t, err) - require.NoError(t, c.ServiceStartup(context.Background(), nil)) - return mock, c -} - -func TestRegister_Good(t *testing.T) { - _, c := newTestService(t) - svc := core.MustServiceFor[*Service](c, "environment") - assert.NotNil(t, svc) -} - -func TestQueryTheme_Good(t *testing.T) { - _, c := newTestService(t) - result, handled, err := c.QUERY(QueryTheme{}) - require.NoError(t, err) - assert.True(t, handled) - theme := result.(ThemeInfo) - assert.True(t, theme.IsDark) - assert.Equal(t, "dark", theme.Theme) -} - -func TestQueryInfo_Good(t *testing.T) { - _, c := newTestService(t) - result, handled, err := c.QUERY(QueryInfo{}) - require.NoError(t, err) - assert.True(t, handled) - info := result.(EnvironmentInfo) - assert.Equal(t, "darwin", info.OS) - assert.Equal(t, "arm64", info.Arch) -} - -func TestQueryAccentColour_Good(t *testing.T) { - _, c := newTestService(t) - result, handled, err := c.QUERY(QueryAccentColour{}) - require.NoError(t, err) - assert.True(t, handled) - assert.Equal(t, "rgb(0,122,255)", result) -} - -func TestTaskOpenFileManager_Good(t *testing.T) { - _, c := newTestService(t) - _, handled, err := c.PERFORM(TaskOpenFileManager{Path: "/tmp", Select: true}) - require.NoError(t, err) - assert.True(t, handled) -} - -func TestThemeChange_ActionBroadcast_Good(t *testing.T) { - mock, c := newTestService(t) - - // Register a listener that captures the action - var received *ActionThemeChanged - var mu sync.Mutex - c.RegisterAction(func(_ *core.Core, msg core.Message) error { - if a, ok := msg.(ActionThemeChanged); ok { - mu.Lock() - received = &a - mu.Unlock() - } - return nil - }) - - // Simulate theme change - mock.simulateThemeChange(false) - - mu.Lock() - r := received - mu.Unlock() - require.NotNil(t, r) - assert.False(t, r.IsDark) -} -``` - -- [ ] **Step 4: Run test to verify it fails** - -Run: `cd /Users/snider/Code/core/gui && go test ./pkg/environment/ -v` -Expected: FAIL — `Service` type not defined - -- [ ] **Step 5: Create service.go** - -```go -// pkg/environment/service.go -package environment - -import ( - "context" - - "forge.lthn.ai/core/go/pkg/core" -) - -// Options holds configuration for the environment service. -type Options struct{} - -// Service is a core.Service providing environment queries and theme change events via IPC. -type Service struct { - *core.ServiceRuntime[Options] - platform Platform - cancelTheme func() // cancel function for theme change listener -} - -// Register creates a factory closure that captures the Platform adapter. -func Register(p Platform) func(*core.Core) (any, error) { - return func(c *core.Core) (any, error) { - return &Service{ - ServiceRuntime: core.NewServiceRuntime[Options](c, Options{}), - platform: p, - }, nil - } -} - -// OnStartup registers IPC handlers and the theme change listener. -func (s *Service) OnStartup(ctx context.Context) error { - s.Core().RegisterQuery(s.handleQuery) - s.Core().RegisterTask(s.handleTask) - - // Register theme change callback — broadcasts ActionThemeChanged via IPC - s.cancelTheme = s.platform.OnThemeChange(func(isDark bool) { - _ = s.Core().ACTION(ActionThemeChanged{IsDark: isDark}) - }) - return nil -} - -// OnShutdown cancels the theme change listener. -func (s *Service) OnShutdown(ctx context.Context) error { - if s.cancelTheme != nil { - s.cancelTheme() - } - return nil -} - -// HandleIPCEvents is auto-discovered by core.WithService. -func (s *Service) HandleIPCEvents(c *core.Core, msg core.Message) error { - return nil -} - -func (s *Service) handleQuery(c *core.Core, q core.Query) (any, bool, error) { - switch q.(type) { - case QueryTheme: - isDark := s.platform.IsDarkMode() - theme := "light" - if isDark { - theme = "dark" - } - return ThemeInfo{IsDark: isDark, Theme: theme}, true, nil - case QueryInfo: - return s.platform.Info(), true, nil - case QueryAccentColour: - return s.platform.AccentColour(), true, nil - default: - return nil, false, nil - } -} - -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) - default: - return nil, false, nil - } -} -``` - -- [ ] **Step 6: Run tests to verify they pass** - -Run: `cd /Users/snider/Code/core/gui && go test ./pkg/environment/ -v` -Expected: PASS (6 tests) - -- [ ] **Step 7: Commit** - -```bash -cd /Users/snider/Code/core/gui -git add pkg/environment/ -git commit -m "feat(environment): add environment core.Service with theme change broadcasts" -``` - ---- - -## Chunk 3: Screen + Display Orchestrator Update - -### Task 5: Create pkg/screen - -**Files:** -- Create: `pkg/screen/platform.go` -- Create: `pkg/screen/messages.go` -- Create: `pkg/screen/service.go` -- Create: `pkg/screen/service_test.go` - -- [ ] **Step 1: Create platform.go with types** - -```go -// pkg/screen/platform.go -package screen - -// Platform abstracts the screen/display backend. -type Platform interface { - GetAll() []Screen - GetPrimary() *Screen -} - -// Screen describes a display/monitor. -type Screen struct { - ID string `json:"id"` - Name string `json:"name"` - ScaleFactor float64 `json:"scaleFactor"` - Size Size `json:"size"` - Bounds Rect `json:"bounds"` - WorkArea Rect `json:"workArea"` - IsPrimary bool `json:"isPrimary"` - Rotation float64 `json:"rotation"` -} - -// Rect represents a rectangle with position and dimensions. -type Rect struct { - X int `json:"x"` - Y int `json:"y"` - Width int `json:"width"` - Height int `json:"height"` -} - -// Size represents dimensions. -type Size struct { - Width int `json:"width"` - Height int `json:"height"` -} -``` - -- [ ] **Step 2: Create messages.go** - -```go -// pkg/screen/messages.go -package screen - -// QueryAll returns all screens. Result: []Screen -type QueryAll struct{} - -// QueryPrimary returns the primary screen. Result: *Screen (nil if not found) -type QueryPrimary struct{} - -// QueryByID returns a screen by ID. Result: *Screen (nil if not found) -type QueryByID struct{ ID string } - -// QueryAtPoint returns the screen containing a point. Result: *Screen (nil if none) -type QueryAtPoint struct{ X, Y int } - -// QueryWorkAreas returns work areas for all screens. Result: []Rect -type QueryWorkAreas struct{} - -// ActionScreensChanged is broadcast when displays change (future). -type ActionScreensChanged struct{ Screens []Screen } -``` - -- [ ] **Step 3: Write failing test** - -```go -// pkg/screen/service_test.go -package screen - -import ( - "context" - "testing" - - "forge.lthn.ai/core/go/pkg/core" - "github.com/stretchr/testify/assert" - "github.com/stretchr/testify/require" -) - -type mockPlatform struct { - screens []Screen -} - -func (m *mockPlatform) GetAll() []Screen { return m.screens } -func (m *mockPlatform) GetPrimary() *Screen { - for i := range m.screens { - if m.screens[i].IsPrimary { - return &m.screens[i] - } - } - return nil -} - -func newTestService(t *testing.T) (*mockPlatform, *core.Core) { - t.Helper() - mock := &mockPlatform{ - screens: []Screen{ - { - ID: "1", Name: "Built-in", IsPrimary: true, - Bounds: Rect{X: 0, Y: 0, Width: 2560, Height: 1600}, - WorkArea: Rect{X: 0, Y: 38, Width: 2560, Height: 1562}, - Size: Size{Width: 2560, Height: 1600}, - }, - { - ID: "2", Name: "External", - Bounds: Rect{X: 2560, Y: 0, Width: 1920, Height: 1080}, - WorkArea: Rect{X: 2560, Y: 0, Width: 1920, Height: 1080}, - Size: Size{Width: 1920, Height: 1080}, - }, - }, - } - c, err := core.New( - core.WithService(Register(mock)), - core.WithServiceLock(), - ) - require.NoError(t, err) - require.NoError(t, c.ServiceStartup(context.Background(), nil)) - return mock, c -} - -func TestRegister_Good(t *testing.T) { - _, c := newTestService(t) - svc := core.MustServiceFor[*Service](c, "screen") - assert.NotNil(t, svc) -} - -func TestQueryAll_Good(t *testing.T) { - _, c := newTestService(t) - result, handled, err := c.QUERY(QueryAll{}) - require.NoError(t, err) - assert.True(t, handled) - screens := result.([]Screen) - assert.Len(t, screens, 2) -} - -func TestQueryPrimary_Good(t *testing.T) { - _, c := newTestService(t) - result, handled, err := c.QUERY(QueryPrimary{}) - require.NoError(t, err) - assert.True(t, handled) - scr := result.(*Screen) - require.NotNil(t, scr) - assert.Equal(t, "Built-in", scr.Name) - assert.True(t, scr.IsPrimary) -} - -func TestQueryByID_Good(t *testing.T) { - _, c := newTestService(t) - result, handled, err := c.QUERY(QueryByID{ID: "2"}) - require.NoError(t, err) - assert.True(t, handled) - scr := result.(*Screen) - require.NotNil(t, scr) - assert.Equal(t, "External", scr.Name) -} - -func TestQueryByID_Bad(t *testing.T) { - _, c := newTestService(t) - result, handled, err := c.QUERY(QueryByID{ID: "99"}) - require.NoError(t, err) - assert.True(t, handled) - assert.Nil(t, result) -} - -func TestQueryAtPoint_Good(t *testing.T) { - _, c := newTestService(t) - - // Point on primary screen - result, handled, err := c.QUERY(QueryAtPoint{X: 100, Y: 100}) - require.NoError(t, err) - assert.True(t, handled) - scr := result.(*Screen) - require.NotNil(t, scr) - assert.Equal(t, "Built-in", scr.Name) - - // Point on external screen - result, _, _ = c.QUERY(QueryAtPoint{X: 3000, Y: 500}) - scr = result.(*Screen) - require.NotNil(t, scr) - assert.Equal(t, "External", scr.Name) -} - -func TestQueryAtPoint_Bad(t *testing.T) { - _, c := newTestService(t) - result, handled, err := c.QUERY(QueryAtPoint{X: -1000, Y: -1000}) - require.NoError(t, err) - assert.True(t, handled) - assert.Nil(t, result) -} - -func TestQueryWorkAreas_Good(t *testing.T) { - _, c := newTestService(t) - result, handled, err := c.QUERY(QueryWorkAreas{}) - require.NoError(t, err) - assert.True(t, handled) - areas := result.([]Rect) - assert.Len(t, areas, 2) - assert.Equal(t, 38, areas[0].Y) // primary has menu bar offset -} -``` - -- [ ] **Step 4: Run test to verify it fails** - -Run: `cd /Users/snider/Code/core/gui && go test ./pkg/screen/ -v` -Expected: FAIL — `Service` type not defined - -- [ ] **Step 5: Create service.go** - -```go -// pkg/screen/service.go -package screen - -import ( - "context" - - "forge.lthn.ai/core/go/pkg/core" -) - -// Options holds configuration for the screen service. -type Options struct{} - -// Service is a core.Service providing screen/display queries via IPC. -type Service struct { - *core.ServiceRuntime[Options] - platform Platform -} - -// Register creates a factory closure that captures the Platform adapter. -func Register(p Platform) func(*core.Core) (any, error) { - return func(c *core.Core) (any, error) { - return &Service{ - ServiceRuntime: core.NewServiceRuntime[Options](c, Options{}), - platform: p, - }, nil - } -} - -// OnStartup registers IPC handlers. -func (s *Service) OnStartup(ctx context.Context) error { - s.Core().RegisterQuery(s.handleQuery) - return nil -} - -// HandleIPCEvents is auto-discovered by core.WithService. -func (s *Service) HandleIPCEvents(c *core.Core, msg core.Message) error { - return nil -} - -func (s *Service) handleQuery(c *core.Core, q core.Query) (any, bool, error) { - switch q := q.(type) { - case QueryAll: - return s.platform.GetAll(), true, nil - case QueryPrimary: - return s.platform.GetPrimary(), true, nil - case QueryByID: - return s.queryByID(q.ID), true, nil - case QueryAtPoint: - return s.queryAtPoint(q.X, q.Y), true, nil - case QueryWorkAreas: - return s.queryWorkAreas(), true, nil - default: - return nil, false, nil - } -} - -func (s *Service) queryByID(id string) *Screen { - for _, scr := range s.platform.GetAll() { - if scr.ID == id { - return &scr - } - } - return nil -} - -func (s *Service) queryAtPoint(x, y int) *Screen { - for _, scr := range s.platform.GetAll() { - b := scr.Bounds - if x >= b.X && x < b.X+b.Width && y >= b.Y && y < b.Y+b.Height { - return &scr - } - } - return nil -} - -func (s *Service) queryWorkAreas() []Rect { - screens := s.platform.GetAll() - areas := make([]Rect, len(screens)) - for i, scr := range screens { - areas[i] = scr.WorkArea - } - return areas -} -``` - -- [ ] **Step 6: Run tests to verify they pass** - -Run: `cd /Users/snider/Code/core/gui && go test ./pkg/screen/ -v` -Expected: PASS (8 tests) - -- [ ] **Step 7: Commit** - -```bash -cd /Users/snider/Code/core/gui -git add pkg/screen/ -git commit -m "feat(screen): add screen core.Service with computed queries via IPC" -``` - ---- - -### Task 6: Update display orchestrator — add new IPC bridge cases - -**Files:** -- Modify: `pkg/display/display.go` — add import aliases, add HandleIPCEvents cases for new Actions -- Modify: `pkg/display/events.go` — add `EventNotificationClick` constant - -- [ ] **Step 1: Add imports for new packages to display.go** - -Add to the import block in `pkg/display/display.go`: - -```go -"forge.lthn.ai/core/gui/pkg/clipboard" -"forge.lthn.ai/core/gui/pkg/dialog" -"forge.lthn.ai/core/gui/pkg/environment" -"forge.lthn.ai/core/gui/pkg/notification" -"forge.lthn.ai/core/gui/pkg/screen" -``` - -- [ ] **Step 2: Add EventNotificationClick constant to events.go** - -Add after existing event constants in `pkg/display/events.go`: - -```go -EventNotificationClick EventType = "notification.click" -``` - -- [ ] **Step 3: Add HandleIPCEvents cases for new Action types** - -Add to the `HandleIPCEvents` switch in `pkg/display/display.go`, after the existing systray cases: - -```go -case environment.ActionThemeChanged: - if s.events != nil { - theme := "light" - if m.IsDark { - theme = "dark" - } - s.events.Emit(Event{Type: EventThemeChange, - Data: map[string]any{"isDark": m.IsDark, "theme": theme}}) - } -case notification.ActionNotificationClicked: - if s.events != nil { - s.events.Emit(Event{Type: EventNotificationClick, - Data: map[string]any{"id": m.ID}}) - } -case screen.ActionScreensChanged: - if s.events != nil { - s.events.Emit(Event{Type: EventScreenChange, - Data: map[string]any{"screens": m.Screens}}) - } -``` - -- [ ] **Step 4: Run all tests** - -Run: `cd /Users/snider/Code/core/gui && go test ./pkg/display/ ./pkg/clipboard/ ./pkg/dialog/ ./pkg/notification/ ./pkg/environment/ ./pkg/screen/ -v` -Expected: ALL PASS - -- [ ] **Step 5: Commit** - -```bash -cd /Users/snider/Code/core/gui -git add pkg/display/display.go pkg/display/events.go -git commit -m "feat(display): bridge new service Actions to WSEventManager" -``` - ---- - -### Task 7: Remove extracted code from display - -**Files:** -- Delete: `pkg/display/clipboard.go` -- Delete: `pkg/display/dialog.go` -- Delete: `pkg/display/notification.go` -- Delete: `pkg/display/theme.go` -- Modify: `pkg/display/display.go` — remove ScreenInfo, WorkArea, screen methods, ShowEnvironmentDialog, GetScreenForWindow, notifier field, replace handleTrayAction "env-info" with IPC -- Modify: `pkg/display/events.go` — remove SetupWindowEventListeners, change NewWSEventManager signature -- Modify: `pkg/display/interfaces.go` — remove wailsEventSource (keep wailsDialogManager — still used by handleOpenFile) -- Delete: `pkg/display/types.go` — EventSource interface (only content, file deleted) -- Modify: `pkg/display/mocks_test.go` — remove mockEventSource -- Modify: `pkg/display/display_test.go` — update NewWSEventManager calls, remove SetupWindowEventListeners test - -- [ ] **Step 1: Delete the 4 extracted files** - -```bash -cd /Users/snider/Code/core/gui -rm pkg/display/clipboard.go pkg/display/dialog.go pkg/display/notification.go pkg/display/theme.go -``` - -- [ ] **Step 2: Remove ScreenInfo, WorkArea types and screen methods from display.go** - -Remove the `ScreenInfo` struct (lines 602-611), `WorkArea` struct (lines 613-620), and ALL screen methods: `GetScreens`, `GetWorkAreas`, `GetPrimaryScreen`, `GetScreen`, `GetScreenAtPoint`, `GetScreenForWindow`, `ShowEnvironmentDialog` (lines 622-763 approximately). Also remove the `notifier` field from the Service struct and the `notifications` import. - -- [ ] **Step 3: Replace handleTrayAction "env-info" with IPC** - -The `handleTrayAction` method calls `s.ShowEnvironmentDialog()` which is being removed. Replace with IPC calls: - -```go -case "env-info": - // Query environment info via IPC and show as dialog - result, handled, _ := s.Core().QUERY(environment.QueryInfo{}) - if handled { - info := result.(environment.EnvironmentInfo) - details := fmt.Sprintf("OS: %s\nArch: %s\nPlatform: %s %s", - info.OS, info.Arch, info.Platform.Name, info.Platform.Version) - _, _, _ = s.Core().PERFORM(dialog.TaskMessageDialog{ - Opts: dialog.MessageDialogOptions{ - Type: dialog.DialogInfo, Title: "Environment", - Message: details, Buttons: []string{"OK"}, - }, - }) - } -``` - -- [ ] **Step 4: Delete types.go** - -Delete `pkg/display/types.go` — it only contains the `EventSource` interface which is replaced by `environment.Platform.OnThemeChange()`. - -```bash -rm pkg/display/types.go -``` - -- [ ] **Step 5: Remove wailsEventSource from interfaces.go** - -Remove `wailsEventSource` struct and its methods (lines 80-94) and `newWailsEventSource()` factory. **Keep** `wailsDialogManager` — it is still used by `handleOpenFile` in display.go. Keep `wailsApp`, `wailsEnvManager`, `wailsEventManager`. - -- [ ] **Step 6: Update NewWSEventManager signature in events.go** - -Change `NewWSEventManager(es EventSource)` to `NewWSEventManager()`. Remove the `eventSource` field from WSEventManager. Remove the `SetupWindowEventListeners()` method (theme change now comes via IPC ActionThemeChanged). - -- [ ] **Step 7: Update display.go OnStartup** - -Change the line: -```go -s.events = NewWSEventManager(newWailsEventSource(s.wailsApp)) -s.events.SetupWindowEventListeners() -``` -To: -```go -s.events = NewWSEventManager() -``` - -- [ ] **Step 8: Update display test files** - -In `pkg/display/mocks_test.go`: remove `mockEventSource` struct and `newMockEventSource()`. - -In `pkg/display/display_test.go`: -- Update `TestWSEventManager_Good`: change `NewWSEventManager(es)` to `NewWSEventManager()`, remove `es` variable -- Remove `TestWSEventManager_SetupWindowEventListeners_Good` entirely (method deleted) - -- [ ] **Step 9: Run all tests** - -Run: `cd /Users/snider/Code/core/gui && go test ./... -v` -Expected: ALL PASS across all packages - -- [ ] **Step 10: Commit** - -```bash -cd /Users/snider/Code/core/gui -git add -A pkg/display/ -git commit -m "refactor(display): remove extracted clipboard/dialog/notification/theme/screen code" -``` - ---- - -### Task 8: Final verification and push - -- [ ] **Step 1: Run full test suite** - -Run: `cd /Users/snider/Code/core/gui && go test ./... -count=1` -Expected: ALL PASS - -- [ ] **Step 2: Verify build** - -Run: `cd /Users/snider/Code/core/gui && go build ./...` -Expected: Clean build, no errors - -- [ ] **Step 3: Push to forge** - -```bash -cd /Users/snider/Code/core/gui && git push origin main -``` diff --git a/docs/superpowers/plans/2026-03-13-gui-final-cleanup.md b/docs/superpowers/plans/2026-03-13-gui-final-cleanup.md deleted file mode 100644 index 2823813..0000000 --- a/docs/superpowers/plans/2026-03-13-gui-final-cleanup.md +++ /dev/null @@ -1,1055 +0,0 @@ -# CoreGUI Spec D: Context Menus & Final Cleanup — Implementation Plan - -> **For agentic workers:** REQUIRED: Use superpowers:subagent-driven-development (if subagents available) or superpowers:executing-plans to implement this plan. Steps use checkbox (`- [ ]`) syntax for tracking. - -**Goal:** Add `pkg/contextmenu` as the final Wails v3 Manager API wrapper (completing full coverage), then remove all stale Wails wrapper types from `pkg/display/interfaces.go`, migrating the 4 remaining direct `s.app.*` calls to IPC. The `App` interface shrinks to `Quit()` + `Logger()` only. - -**Architecture:** `pkg/contextmenu` follows the identical three-layer pattern (IPC Bus -> Service -> Platform Interface) established by `pkg/keybinding`. The display orchestrator gains HandleIPCEvents + WS bridge cases for context menu actions, a new `ActionIDECommand` message type, and loses ~75 lines of stale wrapper code. - -**Tech Stack:** Go, core/go DI framework, Wails v3 (behind Platform interfaces) - -**Spec:** `docs/superpowers/specs/2026-03-13-gui-final-cleanup-design.md` - ---- - -## File Structure - -### New files - -| Package | File | Responsibility | -|---------|------|---------------| -| `pkg/contextmenu/` | `platform.go` | Platform interface (4 methods) + `ContextMenuDef`, `MenuItemDef` types | -| | `messages.go` | IPC message types (QueryGet, QueryList, TaskAdd, TaskRemove, ActionItemClicked) | -| | `register.go` | `Register(Platform)` factory closure | -| | `service.go` | Service struct, OnStartup(), query/task handlers, in-memory registry | -| | `service_test.go` | Tests with mock platform (`_Good/_Bad/_Ugly` naming) | - -### Modified files - -| File | Change | -|------|--------| -| `pkg/display/display.go` | Add `contextmenu.ActionItemClicked` + `ActionIDECommand` HandleIPCEvents cases, add `contextmenu:*` WS->IPC cases, migrate `handleOpenFile`/`handleSaveFile`/`handleRun`/`handleBuild` from direct `s.app.*` to IPC | -| `pkg/display/events.go` | Add `EventContextMenuClick` + `EventIDECommand` constants | -| `pkg/display/interfaces.go` | Remove `DialogManager`, `EnvManager`, `EventManager` interfaces + `wailsDialogManager`, `wailsEnvManager`, `wailsEventManager` structs + `App.Dialog()`, `App.Env()`, `App.Event()` methods. Shrink `App` to `Quit()` + `Logger()` | - -### New file in display - -| File | Responsibility | -|------|---------------| -| `pkg/display/messages.go` | `ActionIDECommand` message type | - ---- - -## Task 1: Create pkg/contextmenu - -**Files:** -- Create: `pkg/contextmenu/platform.go` -- Create: `pkg/contextmenu/messages.go` -- Create: `pkg/contextmenu/register.go` -- Create: `pkg/contextmenu/service.go` -- Test: `pkg/contextmenu/service_test.go` - -### Step 1: Create platform.go with types - -- [ ] **Create `pkg/contextmenu/platform.go`** - -```go -// pkg/contextmenu/platform.go -package contextmenu - -// Platform abstracts the context menu backend (Wails v3). -// The Add callback must broadcast ActionItemClicked via s.Core().ACTION() -// when a menu item is clicked — the adapter translates MenuItemDef.ActionID -// to a callback that does this. -type Platform interface { - // Add registers a context menu by name. - // The onItemClick callback is called with (menuName, actionID, data) - // when any item in the menu is clicked. The adapter creates per-item - // OnClick handlers that call this with the appropriate ActionID. - Add(name string, menu ContextMenuDef, onItemClick func(menuName, actionID, data string)) error - - // Remove unregisters a context menu by name. - Remove(name string) error - - // Get returns a context menu definition by name, or false if not found. - Get(name string) (*ContextMenuDef, bool) - - // GetAll returns all registered context menu definitions. - GetAll() map[string]ContextMenuDef -} - -// ContextMenuDef describes a context menu and its items. -type ContextMenuDef struct { - Name string `json:"name"` - Items []MenuItemDef `json:"items"` -} - -// MenuItemDef describes a single item in a context menu. -// Items may be nested (submenu children via Items field). -type MenuItemDef struct { - Label string `json:"label"` - Type string `json:"type,omitempty"` // "" (normal), "separator", "checkbox", "radio", "submenu" - Accelerator string `json:"accelerator,omitempty"` - Enabled *bool `json:"enabled,omitempty"` // nil = true (default) - Checked bool `json:"checked,omitempty"` - ActionID string `json:"actionId,omitempty"` // identifies which item was clicked - Items []MenuItemDef `json:"items,omitempty"` // submenu children (recursive) -} -``` - -### Step 2: Create messages.go - -- [ ] **Create `pkg/contextmenu/messages.go`** - -```go -// pkg/contextmenu/messages.go -package contextmenu - -import "errors" - -// ErrMenuNotFound is returned when attempting to remove or get a menu -// that does not exist in the registry. -var ErrMenuNotFound = errors.New("contextmenu: menu not found") - -// --- Queries --- - -// QueryGet returns a single context menu by name. Result: *ContextMenuDef (nil if not found) -type QueryGet struct { - Name string `json:"name"` -} - -// QueryList returns all registered context menus. Result: map[string]ContextMenuDef -type QueryList struct{} - -// --- Tasks --- - -// TaskAdd registers a context menu. Result: nil -// If a menu with the same name already exists it is replaced (remove + re-add). -type TaskAdd struct { - Name string `json:"name"` - Menu ContextMenuDef `json:"menu"` -} - -// TaskRemove unregisters a context menu. Result: nil -// Returns ErrMenuNotFound if the menu does not exist. -type TaskRemove struct { - Name string `json:"name"` -} - -// --- Actions --- - -// ActionItemClicked is broadcast when a context menu item is clicked. -// The Data field is populated from the CSS --custom-contextmenu-data property -// on the element that triggered the context menu. -type ActionItemClicked struct { - MenuName string `json:"menuName"` - ActionID string `json:"actionId"` - Data string `json:"data,omitempty"` -} -``` - -### Step 3: Create register.go - -- [ ] **Create `pkg/contextmenu/register.go`** - -```go -// pkg/contextmenu/register.go -package contextmenu - -import "forge.lthn.ai/core/go/pkg/core" - -// Register creates a factory closure that captures the Platform adapter. -// The returned function has the signature WithService requires: func(*Core) (any, error). -func Register(p Platform) func(*core.Core) (any, error) { - return func(c *core.Core) (any, error) { - return &Service{ - ServiceRuntime: core.NewServiceRuntime[Options](c, Options{}), - platform: p, - menus: make(map[string]ContextMenuDef), - }, nil - } -} -``` - -### Step 4: Write failing test - -- [ ] **Create `pkg/contextmenu/service_test.go`** - -```go -// pkg/contextmenu/service_test.go -package contextmenu - -import ( - "context" - "sync" - "testing" - - "forge.lthn.ai/core/go/pkg/core" - "github.com/stretchr/testify/assert" - "github.com/stretchr/testify/require" -) - -// mockPlatform records Add/Remove calls and allows simulating clicks. -type mockPlatform struct { - mu sync.Mutex - menus map[string]ContextMenuDef - clickHandlers map[string]func(menuName, actionID, data string) - removed []string - addErr error - removeErr error -} - -func newMockPlatform() *mockPlatform { - return &mockPlatform{ - menus: make(map[string]ContextMenuDef), - clickHandlers: make(map[string]func(menuName, actionID, data string)), - } -} - -func (m *mockPlatform) Add(name string, menu ContextMenuDef, onItemClick func(string, string, string)) error { - m.mu.Lock() - defer m.mu.Unlock() - if m.addErr != nil { - return m.addErr - } - m.menus[name] = menu - m.clickHandlers[name] = onItemClick - return nil -} - -func (m *mockPlatform) Remove(name string) error { - m.mu.Lock() - defer m.mu.Unlock() - if m.removeErr != nil { - return m.removeErr - } - delete(m.menus, name) - delete(m.clickHandlers, name) - m.removed = append(m.removed, name) - return nil -} - -func (m *mockPlatform) Get(name string) (*ContextMenuDef, bool) { - m.mu.Lock() - defer m.mu.Unlock() - menu, ok := m.menus[name] - if !ok { - return nil, false - } - return &menu, true -} - -func (m *mockPlatform) GetAll() map[string]ContextMenuDef { - m.mu.Lock() - defer m.mu.Unlock() - out := make(map[string]ContextMenuDef, len(m.menus)) - for k, v := range m.menus { - out[k] = v - } - return out -} - -// simulateClick simulates a context menu item click by calling the registered handler. -func (m *mockPlatform) simulateClick(menuName, actionID, data string) { - m.mu.Lock() - h, ok := m.clickHandlers[menuName] - m.mu.Unlock() - if ok { - h(menuName, actionID, data) - } -} - -func newTestContextMenuService(t *testing.T, mp *mockPlatform) (*Service, *core.Core) { - t.Helper() - c, err := core.New( - core.WithService(Register(mp)), - core.WithServiceLock(), - ) - require.NoError(t, err) - require.NoError(t, c.ServiceStartup(context.Background(), nil)) - svc := core.MustServiceFor[*Service](c, "contextmenu") - return svc, c -} - -func TestRegister_Good(t *testing.T) { - mp := newMockPlatform() - svc, _ := newTestContextMenuService(t, mp) - assert.NotNil(t, svc) - assert.NotNil(t, svc.platform) -} - -func TestTaskAdd_Good(t *testing.T) { - mp := newMockPlatform() - _, c := newTestContextMenuService(t, mp) - - _, handled, err := c.PERFORM(TaskAdd{ - Name: "file-menu", - Menu: ContextMenuDef{ - Name: "file-menu", - Items: []MenuItemDef{ - {Label: "Open", ActionID: "open"}, - {Label: "Delete", ActionID: "delete"}, - }, - }, - }) - require.NoError(t, err) - assert.True(t, handled) - - // Verify menu registered on platform - _, ok := mp.Get("file-menu") - assert.True(t, ok) -} - -func TestTaskAdd_Good_ReplaceExisting(t *testing.T) { - mp := newMockPlatform() - _, c := newTestContextMenuService(t, mp) - - // Add initial menu - _, _, _ = c.PERFORM(TaskAdd{ - Name: "ctx", - Menu: ContextMenuDef{Name: "ctx", Items: []MenuItemDef{{Label: "A", ActionID: "a"}}}, - }) - - // Replace with new menu - _, handled, err := c.PERFORM(TaskAdd{ - Name: "ctx", - Menu: ContextMenuDef{Name: "ctx", Items: []MenuItemDef{{Label: "B", ActionID: "b"}}}, - }) - require.NoError(t, err) - assert.True(t, handled) - - // Verify registry has new menu - result, _, _ := c.QUERY(QueryGet{Name: "ctx"}) - def := result.(*ContextMenuDef) - require.Len(t, def.Items, 1) - assert.Equal(t, "B", def.Items[0].Label) -} - -func TestTaskRemove_Good(t *testing.T) { - mp := newMockPlatform() - _, c := newTestContextMenuService(t, mp) - - // Add then remove - _, _, _ = c.PERFORM(TaskAdd{ - Name: "test", - Menu: ContextMenuDef{Name: "test"}, - }) - _, handled, err := c.PERFORM(TaskRemove{Name: "test"}) - require.NoError(t, err) - assert.True(t, handled) - - // Verify removed from registry - result, _, _ := c.QUERY(QueryGet{Name: "test"}) - assert.Nil(t, result) -} - -func TestTaskRemove_Bad_NotFound(t *testing.T) { - mp := newMockPlatform() - _, c := newTestContextMenuService(t, mp) - - _, handled, err := c.PERFORM(TaskRemove{Name: "nonexistent"}) - assert.True(t, handled) - assert.ErrorIs(t, err, ErrMenuNotFound) -} - -func TestQueryGet_Good(t *testing.T) { - mp := newMockPlatform() - _, c := newTestContextMenuService(t, mp) - - _, _, _ = c.PERFORM(TaskAdd{ - Name: "my-menu", - Menu: ContextMenuDef{ - Name: "my-menu", - Items: []MenuItemDef{{Label: "Edit", ActionID: "edit"}}, - }, - }) - - result, handled, err := c.QUERY(QueryGet{Name: "my-menu"}) - require.NoError(t, err) - assert.True(t, handled) - def := result.(*ContextMenuDef) - assert.Equal(t, "my-menu", def.Name) - assert.Len(t, def.Items, 1) -} - -func TestQueryGet_Good_NotFound(t *testing.T) { - mp := newMockPlatform() - _, c := newTestContextMenuService(t, mp) - - result, handled, err := c.QUERY(QueryGet{Name: "missing"}) - require.NoError(t, err) - assert.True(t, handled) - assert.Nil(t, result) -} - -func TestQueryList_Good(t *testing.T) { - mp := newMockPlatform() - _, c := newTestContextMenuService(t, mp) - - _, _, _ = c.PERFORM(TaskAdd{Name: "a", Menu: ContextMenuDef{Name: "a"}}) - _, _, _ = c.PERFORM(TaskAdd{Name: "b", Menu: ContextMenuDef{Name: "b"}}) - - result, handled, err := c.QUERY(QueryList{}) - require.NoError(t, err) - assert.True(t, handled) - list := result.(map[string]ContextMenuDef) - assert.Len(t, list, 2) -} - -func TestQueryList_Good_Empty(t *testing.T) { - mp := newMockPlatform() - _, c := newTestContextMenuService(t, mp) - - result, handled, err := c.QUERY(QueryList{}) - require.NoError(t, err) - assert.True(t, handled) - list := result.(map[string]ContextMenuDef) - assert.Len(t, list, 0) -} - -func TestTaskAdd_Good_ClickBroadcast(t *testing.T) { - mp := newMockPlatform() - _, c := newTestContextMenuService(t, mp) - - // Capture broadcast actions - var clicked ActionItemClicked - var mu sync.Mutex - c.RegisterAction(func(_ *core.Core, msg core.Message) error { - if a, ok := msg.(ActionItemClicked); ok { - mu.Lock() - clicked = a - mu.Unlock() - } - return nil - }) - - _, _, _ = c.PERFORM(TaskAdd{ - Name: "file-menu", - Menu: ContextMenuDef{ - Name: "file-menu", - Items: []MenuItemDef{ - {Label: "Open", ActionID: "open"}, - }, - }, - }) - - // Simulate click via mock - mp.simulateClick("file-menu", "open", "file-123") - - mu.Lock() - assert.Equal(t, "file-menu", clicked.MenuName) - assert.Equal(t, "open", clicked.ActionID) - assert.Equal(t, "file-123", clicked.Data) - mu.Unlock() -} - -func TestTaskAdd_Good_SubmenuItems(t *testing.T) { - mp := newMockPlatform() - _, c := newTestContextMenuService(t, mp) - - _, handled, err := c.PERFORM(TaskAdd{ - Name: "nested", - Menu: ContextMenuDef{ - Name: "nested", - Items: []MenuItemDef{ - {Label: "File", Type: "submenu", Items: []MenuItemDef{ - {Label: "New", ActionID: "new"}, - {Label: "Open", ActionID: "open"}, - }}, - {Type: "separator"}, - {Label: "Quit", ActionID: "quit"}, - }, - }, - }) - require.NoError(t, err) - assert.True(t, handled) - - result, _, _ := c.QUERY(QueryGet{Name: "nested"}) - def := result.(*ContextMenuDef) - assert.Len(t, def.Items, 3) - assert.Len(t, def.Items[0].Items, 2) // submenu children -} - -func TestQueryList_Bad_NoService(t *testing.T) { - c, _ := core.New(core.WithServiceLock()) - _, handled, _ := c.QUERY(QueryList{}) - assert.False(t, handled) -} -``` - -- [ ] **Run test to verify it fails** - -```bash -cd /Users/snider/Code/core/gui && go test ./pkg/contextmenu/ -v -``` - -Expected: FAIL -- `Service` type not defined, `Options` type not defined - -### Step 5: Create service.go - -- [ ] **Create `pkg/contextmenu/service.go`** - -```go -// pkg/contextmenu/service.go -package contextmenu - -import ( - "context" - "fmt" - - "forge.lthn.ai/core/go/pkg/core" -) - -// Options holds configuration for the context menu service. -type Options struct{} - -// Service is a core.Service managing context menus via IPC. -// It maintains an in-memory registry of menus (map[string]ContextMenuDef) -// and delegates platform-level registration to the Platform interface. -type Service struct { - *core.ServiceRuntime[Options] - platform Platform - menus map[string]ContextMenuDef -} - -// OnStartup registers IPC handlers. -func (s *Service) OnStartup(ctx context.Context) error { - s.Core().RegisterQuery(s.handleQuery) - s.Core().RegisterTask(s.handleTask) - return nil -} - -// HandleIPCEvents is auto-discovered and registered by core.WithService. -func (s *Service) HandleIPCEvents(c *core.Core, msg core.Message) error { - return nil -} - -// --- Query Handlers --- - -func (s *Service) handleQuery(c *core.Core, q core.Query) (any, bool, error) { - switch q := q.(type) { - case QueryGet: - return s.queryGet(q), true, nil - case QueryList: - return s.queryList(), true, nil - default: - return nil, false, nil - } -} - -// queryGet returns a single menu definition by name, or nil if not found. -func (s *Service) queryGet(q QueryGet) *ContextMenuDef { - menu, ok := s.menus[q.Name] - if !ok { - return nil - } - return &menu -} - -// queryList returns a copy of all registered menus. -func (s *Service) queryList() map[string]ContextMenuDef { - result := make(map[string]ContextMenuDef, len(s.menus)) - for k, v := range s.menus { - result[k] = v - } - return result -} - -// --- Task Handlers --- - -func (s *Service) handleTask(c *core.Core, t core.Task) (any, bool, error) { - switch t := t.(type) { - case TaskAdd: - return nil, true, s.taskAdd(t) - case TaskRemove: - return nil, true, s.taskRemove(t) - default: - return nil, false, nil - } -} - -func (s *Service) taskAdd(t TaskAdd) error { - // If menu already exists, remove it first (replace semantics) - if _, exists := s.menus[t.Name]; exists { - _ = s.platform.Remove(t.Name) - delete(s.menus, t.Name) - } - - // Register on platform with a callback that broadcasts ActionItemClicked - err := s.platform.Add(t.Name, t.Menu, func(menuName, actionID, data string) { - _ = s.Core().ACTION(ActionItemClicked{ - MenuName: menuName, - ActionID: actionID, - Data: data, - }) - }) - if err != nil { - return fmt.Errorf("contextmenu: platform add failed: %w", err) - } - - s.menus[t.Name] = t.Menu - return nil -} - -func (s *Service) taskRemove(t TaskRemove) error { - if _, exists := s.menus[t.Name]; !exists { - return ErrMenuNotFound - } - - err := s.platform.Remove(t.Name) - if err != nil { - return fmt.Errorf("contextmenu: platform remove failed: %w", err) - } - - delete(s.menus, t.Name) - return nil -} -``` - -- [ ] **Run tests to verify they pass** - -```bash -cd /Users/snider/Code/core/gui && go test ./pkg/contextmenu/ -v -``` - -Expected: PASS (13 tests) - -- [ ] **Commit** - -```bash -cd /Users/snider/Code/core/gui -git add pkg/contextmenu/ -git commit -m "feat(contextmenu): add contextmenu core.Service with Platform interface and IPC - -Completes full Wails v3 Manager API coverage through the IPC bus. -Service maintains in-memory registry, delegates to Platform for native -context menu operations, broadcasts ActionItemClicked on menu item clicks. - -Co-Authored-By: Claude Opus 4.6 " -``` - ---- - -## Task 2: Display orchestrator — add contextmenu integration + IDE command message - -**Files:** -- Create: `pkg/display/messages.go` -- Modify: `pkg/display/events.go` -- Modify: `pkg/display/display.go` - -### Step 1: Create messages.go with ActionIDECommand - -- [ ] **Create `pkg/display/messages.go`** - -```go -// pkg/display/messages.go -package display - -// ActionIDECommand is broadcast when a menu handler triggers an IDE command -// (save, run, build). Replaces direct s.app.Event().Emit("ide:*") calls. -// Listeners (e.g. editor windows) handle this via HandleIPCEvents. -type ActionIDECommand struct { - Command string `json:"command"` // "save", "run", "build" -} - -// EventIDECommand is the WS event type for IDE commands. -const EventIDECommand EventType = "ide.command" -``` - -### Step 2: Add EventContextMenuClick to events.go - -- [ ] **Add constant to `pkg/display/events.go`** - -In the `const` block, add after `EventSystemResume`: - -```go -EventContextMenuClick EventType = "contextmenu.item-clicked" -``` - -The full addition is a single line in the existing `const ( ... )` block, placed after `EventSystemResume EventType = "system.resume"`: - -```go -EventContextMenuClick EventType = "contextmenu.item-clicked" -``` - -### Step 3: Add HandleIPCEvents cases for contextmenu + IDE command - -- [ ] **Modify `pkg/display/display.go` — add import for contextmenu** - -Add to the import block: - -```go -"forge.lthn.ai/core/gui/pkg/contextmenu" -``` - -- [ ] **Modify `pkg/display/display.go` — add HandleIPCEvents cases** - -In the `HandleIPCEvents` method, add these two cases inside the `switch m := msg.(type)` block, before the closing `}`: - -```go - case contextmenu.ActionItemClicked: - if s.events != nil { - s.events.Emit(Event{Type: EventContextMenuClick, - Data: map[string]any{ - "menuName": m.MenuName, - "actionId": m.ActionID, - "data": m.Data, - }}) - } - case ActionIDECommand: - if s.events != nil { - s.events.Emit(Event{Type: EventIDECommand, - Data: map[string]any{"command": m.Command}}) - } -``` - -### Step 4: Add WS->IPC cases for contextmenu - -- [ ] **Modify `pkg/display/display.go` — add contextmenu WS cases in `handleWSMessage`** - -Add to the import block (if not already present): - -```go -"encoding/json" -``` - -In the `handleWSMessage` method's `switch msg.Action` block, add before the `default:` case: - -```go - case "contextmenu:add": - name, _ := msg.Data["name"].(string) - menuJSON, _ := json.Marshal(msg.Data["menu"]) - var menuDef contextmenu.ContextMenuDef - _ = json.Unmarshal(menuJSON, &menuDef) - result, handled, err = s.Core().PERFORM(contextmenu.TaskAdd{ - Name: name, Menu: menuDef, - }) - case "contextmenu:remove": - name, _ := msg.Data["name"].(string) - result, handled, err = s.Core().PERFORM(contextmenu.TaskRemove{Name: name}) - case "contextmenu:get": - name, _ := msg.Data["name"].(string) - result, handled, err = s.Core().QUERY(contextmenu.QueryGet{Name: name}) - case "contextmenu:list": - result, handled, err = s.Core().QUERY(contextmenu.QueryList{}) -``` - -**Note on `encoding/json` import:** The `display.go` file does not currently import `encoding/json`. However, the `events.go` file (same package) already uses `encoding/json`. Since this is the same package, the import is available. But since `display.go` is a separate file, you MUST add `"encoding/json"` to its import block. Check whether it is already present before adding. - -- [ ] **Run `go vet` to verify no compilation errors** - -```bash -cd /Users/snider/Code/core/gui && go vet ./pkg/display/ -``` - -Expected: PASS (no errors) - -- [ ] **Commit** - -```bash -cd /Users/snider/Code/core/gui -git add pkg/display/messages.go pkg/display/events.go pkg/display/display.go -git commit -m "feat(display): add contextmenu integration and ActionIDECommand to orchestrator - -Add HandleIPCEvents cases for contextmenu.ActionItemClicked and -ActionIDECommand, WS->IPC bridge cases for contextmenu:add/remove/get/list, -EventContextMenuClick and EventIDECommand constants. - -Co-Authored-By: Claude Opus 4.6 " -``` - ---- - -## Task 3: Display cleanup — migrate stale calls, remove wrappers - -**Files:** -- Modify: `pkg/display/display.go` -- Modify: `pkg/display/interfaces.go` - -### Step 1: Migrate handleSaveFile, handleRun, handleBuild to IPC - -- [ ] **Modify `pkg/display/display.go` — replace 3 direct `s.app.Event().Emit()` calls** - -Replace these three method bodies: - -**Before (line ~829):** -```go -func (s *Service) handleSaveFile() { s.app.Event().Emit("ide:save") } -``` - -**After:** -```go -func (s *Service) handleSaveFile() { _ = s.Core().ACTION(ActionIDECommand{Command: "save"}) } -``` - -**Before (line ~850):** -```go -func (s *Service) handleRun() { s.app.Event().Emit("ide:run") } -``` - -**After:** -```go -func (s *Service) handleRun() { _ = s.Core().ACTION(ActionIDECommand{Command: "run"}) } -``` - -**Before (line ~851):** -```go -func (s *Service) handleBuild() { s.app.Event().Emit("ide:build") } -``` - -**After:** -```go -func (s *Service) handleBuild() { _ = s.Core().ACTION(ActionIDECommand{Command: "build"}) } -``` - -### Step 2: Migrate handleOpenFile to use dialog.TaskOpenFile IPC - -- [ ] **Modify `pkg/display/display.go` — replace handleOpenFile** - -**Before (lines ~810-827):** -```go -func (s *Service) handleOpenFile() { - dialog := s.app.Dialog().OpenFile() - dialog.SetTitle("Open File") - dialog.CanChooseFiles(true) - dialog.CanChooseDirectories(false) - result, err := dialog.PromptForSingleSelection() - if err != nil || result == "" { - return - } - _, _, _ = s.Core().PERFORM(window.TaskOpenWindow{ - Opts: []window.WindowOption{ - window.WithName("editor"), - window.WithTitle(result + " - Editor"), - window.WithURL("/#/developer/editor?file=" + result), - window.WithSize(1200, 800), - }, - }) -} -``` - -**After:** -```go -func (s *Service) handleOpenFile() { - result, handled, err := s.Core().PERFORM(dialog.TaskOpenFile{ - Opts: dialog.OpenFileOptions{ - Title: "Open File", - AllowMultiple: false, - }, - }) - if err != nil || !handled { - return - } - paths, ok := result.([]string) - if !ok || len(paths) == 0 { - return - } - _, _, _ = s.Core().PERFORM(window.TaskOpenWindow{ - Opts: []window.WindowOption{ - window.WithName("editor"), - window.WithTitle(paths[0] + " - Editor"), - window.WithURL("/#/developer/editor?file=" + paths[0]), - window.WithSize(1200, 800), - }, - }) -} -``` - -**Verify import:** `"forge.lthn.ai/core/gui/pkg/dialog"` must be in the import block. It is already imported on the current `display.go` (used in `handleTrayAction` for `dialog.TaskMessageDialog`). - -### Step 3: Remove stale wrappers from interfaces.go - -- [ ] **Rewrite `pkg/display/interfaces.go`** - -Replace the entire file contents with: - -```go -// pkg/display/interfaces.go -package display - -import "github.com/wailsapp/wails/v3/pkg/application" - -// App abstracts the Wails application for the orchestrator. -// After Spec D cleanup, only Quit() and Logger() remain — -// all other Wails Manager APIs are accessed via IPC. -type App interface { - Logger() Logger - Quit() -} - -// Logger wraps Wails logging. -type Logger interface { - Info(message string, args ...any) -} - -// wailsApp wraps *application.App for the App interface. -type wailsApp struct { - app *application.App -} - -func newWailsApp(app *application.App) App { - return &wailsApp{app: app} -} - -func (w *wailsApp) Logger() Logger { return w.app.Logger } -func (w *wailsApp) Quit() { w.app.Quit() } -``` - -**Removed types:** -- `DialogManager` interface (3 methods) -- `EnvManager` interface (2 methods) -- `EventManager` interface (2 methods) -- `wailsDialogManager` struct + 3 methods -- `wailsEnvManager` struct + 2 methods -- `wailsEventManager` struct + 2 methods -- `App.Dialog()`, `App.Env()`, `App.Event()` method requirements - -**Removed imports:** -- `"github.com/wailsapp/wails/v3/pkg/events"` (was only used by `wailsEventManager.OnApplicationEvent`) - -### Step 4: Verify no remaining references to removed methods - -- [ ] **Check for any remaining `s.app.Dialog()`, `s.app.Env()`, `s.app.Event()` calls** - -```bash -cd /Users/snider/Code/core/gui && grep -rn 's\.app\.Dialog()\|s\.app\.Env()\|s\.app\.Event()' pkg/display/ -``` - -Expected: No matches. If there are matches, they indicate missed migrations and must be fixed before proceeding. - -- [ ] **Run `go vet` to verify compilation** - -```bash -cd /Users/snider/Code/core/gui && go vet ./pkg/display/ -``` - -Expected: PASS. If it fails due to unused imports (e.g. the `events` package), remove them. If it fails due to `s.app.Dialog()` or similar calls elsewhere in display.go, those are migrations missed in Steps 1-2 and must be addressed. - -- [ ] **Run all tests** - -```bash -cd /Users/snider/Code/core/gui && go test ./pkg/... -v -``` - -Expected: All tests PASS across all packages. - -- [ ] **Commit** - -```bash -cd /Users/snider/Code/core/gui -git add pkg/display/display.go pkg/display/interfaces.go -git commit -m "refactor(display): migrate stale Wails calls to IPC, remove wrapper types - -Migrate handleOpenFile to dialog.TaskOpenFile IPC, handleSaveFile/handleRun/ -handleBuild to ActionIDECommand IPC. Remove DialogManager, EnvManager, -EventManager interfaces and wailsDialogManager, wailsEnvManager, -wailsEventManager adapter structs. App interface now has Quit() + Logger() only. - -Co-Authored-By: Claude Opus 4.6 " -``` - ---- - -## Task 4: Final verification and commit - -**Files:** -- No new files — verification only - -### Step 1: Full test suite - -- [ ] **Run full test suite** - -```bash -cd /Users/snider/Code/core/gui && go test ./pkg/... -v -count=1 -``` - -Expected: All tests PASS. - -### Step 2: Vet and lint - -- [ ] **Run go vet on entire module** - -```bash -cd /Users/snider/Code/core/gui && go vet ./... -``` - -Expected: PASS. - -### Step 3: Verify no remaining Wails imports leak through display interfaces - -- [ ] **Check that `pkg/display/interfaces.go` only imports `application` (not `events`)** - -```bash -cd /Users/snider/Code/core/gui && grep -n 'events' pkg/display/interfaces.go -``` - -Expected: No matches. - -### Step 4: Verify App interface surface - -- [ ] **Confirm App interface is exactly `Quit() + Logger()`** - -```bash -cd /Users/snider/Code/core/gui && grep -A5 'type App interface' pkg/display/interfaces.go -``` - -Expected output: -``` -type App interface { - Logger() Logger - Quit() -} -``` - -### Step 5: Count Wails Manager API coverage - -- [ ] **Verify all 11 Wails v3 Manager APIs have core.Service wrappers** - -| # | Wails Manager | Package | Spec | -|---|--------------|---------|------| -| 1 | Window | `pkg/window` | A (display split) | -| 2 | Systray | `pkg/systray` | A (display split) | -| 3 | Menu | `pkg/menu` | A (display split) | -| 4 | Clipboard | `pkg/clipboard` | B (extract & insulate) | -| 5 | Dialog | `pkg/dialog` | B (extract & insulate) | -| 6 | Notification | `pkg/notification` | B (extract & insulate) | -| 7 | Environment | `pkg/environment` | B (extract & insulate) | -| 8 | Screen | `pkg/screen` | B (extract & insulate) | -| 9 | Keybinding | `pkg/keybinding` | C (new input services) | -| 10 | Browser | `pkg/browser` | C (new input services) | -| 11 | ContextMenu | `pkg/contextmenu` | D (this spec) | - -All 11 covered. - ---- - -## Summary of Changes - -### Lines added (approx) -| File | Lines | -|------|-------| -| `pkg/contextmenu/platform.go` | ~40 | -| `pkg/contextmenu/messages.go` | ~35 | -| `pkg/contextmenu/register.go` | ~15 | -| `pkg/contextmenu/service.go` | ~95 | -| `pkg/contextmenu/service_test.go` | ~230 | -| `pkg/display/messages.go` | ~10 | -| **Total new** | **~425** | - -### Lines modified -| File | Change | -|------|--------| -| `pkg/display/events.go` | +1 line (EventContextMenuClick) | -| `pkg/display/display.go` | +30 lines (HandleIPCEvents cases, WS cases), -20 lines (migrated handlers) | - -### Lines removed (approx) -| File | Lines removed | -|------|--------------| -| `pkg/display/interfaces.go` | ~55 lines (reduced from ~78 to ~23) | - -### Net effect -- **+425 lines** new contextmenu package (5 files) -- **+10 lines** new display/messages.go -- **+11 lines** net in display.go (new cases minus migrated code) -- **-55 lines** removed from interfaces.go -- **Net: ~+391 lines**, all stale Wails wrappers eliminated, full Manager API coverage achieved diff --git a/docs/superpowers/plans/2026-03-13-gui-mcp-bridge.md b/docs/superpowers/plans/2026-03-13-gui-mcp-bridge.md deleted file mode 100644 index d418ec1..0000000 --- a/docs/superpowers/plans/2026-03-13-gui-mcp-bridge.md +++ /dev/null @@ -1,1393 +0,0 @@ -# MCP Bridge & WebView Service Implementation Plan - -> **For agentic workers:** REQUIRED: Use superpowers:subagent-driven-development (if subagents available) or superpowers:executing-plans to implement this plan. Steps use checkbox (`- [ ]`) syntax for tracking. - -**Goal:** Add `pkg/webview` (CDP-backed core.Service) and `pkg/mcp` (MCP display subsystem with ~74 tools) to core/gui - -**Architecture:** `pkg/webview` wraps go-webview as a core.Service with IPC messages. `pkg/mcp` implements the MCP Subsystem interface via structural typing, translating tool calls to IPC `PERFORM`/`QUERY` calls across all 15 GUI packages. - -**Tech Stack:** Go 1.25, core/go DI framework, go-webview CDP client, MCP SDK (`github.com/modelcontextprotocol/go-sdk/mcp`) - -**Spec:** `docs/superpowers/specs/2026-03-13-gui-mcp-bridge-design.md` - ---- - -## File Structure - -### New files - -| Package | File | Responsibility | -|---------|------|---------------| -| `pkg/webview/` | `messages.go` | IPC message types (6 Queries, 11 Tasks, 2 Actions) + own types (ConsoleMessage, ElementInfo, etc.) | -| | `service.go` | connector interface, Service struct, Register factory, IPC handlers | -| | `service_test.go` | Mock connector, all IPC round-trip tests | -| `pkg/mcp/` | `subsystem.go` | Subsystem struct, New(), Name(), RegisterTools() | -| | `tools_webview.go` | 18 webview tool handlers + Input/Output types | -| | `tools_window.go` | 15 window tool handlers | -| | `tools_layout.go` | 7 layout tool handlers | -| | `tools_screen.go` | 5 screen tool handlers | -| | `tools_clipboard.go` | 4 clipboard tool handlers | -| | `tools_dialog.go` | 5 dialog tool handlers | -| | `tools_notification.go` | 3 notification tool handlers | -| | `tools_tray.go` | 4 systray tool handlers | -| | `tools_environment.go` | 2 environment/theme tool handlers | -| | `tools_browser.go` | 1 browser tool handler | -| | `tools_contextmenu.go` | 4 contextmenu tool handlers | -| | `tools_keybinding.go` | 2 keybinding tool handlers | -| | `tools_dock.go` | 3 dock tool handlers | -| | `tools_lifecycle.go` | 1 lifecycle tool handler | -| | `mcp_test.go` | RegisterTools smoke test + IPC round-trip tests | - -### Modified files - -| File | Changes | -|------|---------| -| `pkg/display/display.go` | Add webview import, HandleIPCEvents cases, handleWSMessage cases, EventType constants | -| `go.mod` | Add `forge.lthn.ai/core/go-webview`, `github.com/modelcontextprotocol/go-sdk` | - -### Prerequisite (separate repo) - -| File | Changes | -|------|---------| -| `go-webview/cdp.go` | Export `targetInfo` → `TargetInfo` | - ---- - -## Task 1: Export TargetInfo in go-webview - -**Files:** -- Modify: `/Users/snider/Code/core/go-webview/cdp.go` - -- [ ] **Step 1: Rename targetInfo → TargetInfo** - -In `/Users/snider/Code/core/go-webview/cdp.go`, rename the struct and update all references: - -```go -// TargetInfo represents Chrome DevTools target information. -type TargetInfo struct { - ID string `json:"id"` - Type string `json:"type"` - Title string `json:"title"` - URL string `json:"url"` - WebSocketDebuggerURL string `json:"webSocketDebuggerUrl"` -} -``` - -Update `ListTargets` return type: `func ListTargets(debugURL string) ([]TargetInfo, error)` -Update `ListTargetsAll` return type: `func ListTargetsAll(debugURL string) iter.Seq[TargetInfo]` -Update all internal references (`var targets []targetInfo` → `var targets []TargetInfo`, etc.) - -- [ ] **Step 2: Run tests** - -Run: `cd /Users/snider/Code/core/go-webview && go build ./...` -Expected: Build succeeds (tests need Chrome, so just verify compilation) - -- [ ] **Step 3: Commit** - -```bash -cd /Users/snider/Code/core/go-webview -git add cdp.go -git commit -m "feat: export TargetInfo type for external CDP target enumeration" -``` - ---- - -## Task 2: pkg/webview — messages.go - -**Files:** -- Create: `pkg/webview/messages.go` - -- [ ] **Step 1: Write messages.go** - -```go -// pkg/webview/messages.go -package webview - -import "time" - -// --- Queries (read-only) --- - -// QueryURL gets the current page URL. Result: string -type QueryURL struct{ Window string `json:"window"` } - -// QueryTitle gets the current page title. Result: string -type QueryTitle struct{ Window string `json:"window"` } - -// QueryConsole gets captured console messages. Result: []ConsoleMessage -type QueryConsole struct { - Window string `json:"window"` - Level string `json:"level,omitempty"` // filter by type: "log", "warn", "error", "info", "debug" - Limit int `json:"limit,omitempty"` // max messages (0 = all) -} - -// QuerySelector finds a single element. Result: *ElementInfo (nil if not found) -type QuerySelector struct { - Window string `json:"window"` - Selector string `json:"selector"` -} - -// QuerySelectorAll finds all matching elements. Result: []*ElementInfo -type QuerySelectorAll struct { - Window string `json:"window"` - Selector string `json:"selector"` -} - -// QueryDOMTree gets HTML content. Result: string (outerHTML) -type QueryDOMTree struct { - Window string `json:"window"` - Selector string `json:"selector,omitempty"` // empty = full document -} - -// --- Tasks (side-effects) --- - -// TaskEvaluate executes JavaScript. Result: any (JS return value) -type TaskEvaluate struct { - Window string `json:"window"` - Script string `json:"script"` -} - -// TaskClick clicks an element. Result: nil -type TaskClick struct { - Window string `json:"window"` - Selector string `json:"selector"` -} - -// TaskType types text into an element. Result: nil -type TaskType struct { - Window string `json:"window"` - Selector string `json:"selector"` - Text string `json:"text"` -} - -// TaskNavigate navigates to a URL. Result: nil -type TaskNavigate struct { - Window string `json:"window"` - URL string `json:"url"` -} - -// TaskScreenshot captures the page as PNG. Result: ScreenshotResult -type TaskScreenshot struct{ Window string `json:"window"` } - -// TaskScroll scrolls to an absolute position (window.scrollTo). Result: nil -type TaskScroll struct { - Window string `json:"window"` - X int `json:"x"` - Y int `json:"y"` -} - -// TaskHover hovers over an element. Result: nil -type TaskHover struct { - Window string `json:"window"` - Selector string `json:"selector"` -} - -// TaskSelect selects an option in a