docs: add go-config wiring design spec and implementation plan (2 tasks)

Wire go-config into display orchestrator to replace the in-memory
loadConfig() stub with real .core/gui/config.yaml file loading and
persistence via handleConfigTask.

Co-Authored-By: Virgil <virgil@lethean.io>
This commit is contained in:
Snider 2026-03-13 13:45:46 +00:00
parent dbd357f0fd
commit 09ff28fca1
2 changed files with 433 additions and 0 deletions

View file

@ -0,0 +1,312 @@
# 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

View file

@ -0,0 +1,121 @@
# GUI Config Wiring — Design Spec
**Date:** 2026-03-13
**Status:** Approved
**Scope:** Replace the in-memory `loadConfig()` stub in `pkg/display` with real `go-config` file-backed configuration, and wire `handleConfigTask` to persist changes to disk.
## Context
The Service Conclave IPC integration (previous spec) established the pattern: display owns config, sub-services QUERY for their section during `OnStartup`, and saves route through `TaskSaveConfig` back to display. All the IPC plumbing works today — but `loadConfig()` creates empty maps and `handleConfigTask` only updates memory. Nothing reads from or writes to disk.
`go-config` (`forge.lthn.ai/core/go-config`) provides exactly what we need: `config.New(config.WithPath(...))` loads a YAML file via Viper, `Get(key, &out)` reads sections by dot-notation key, `Set(key, v)` updates in memory, and `Commit()` persists to disk. It handles directory creation, file-not-exists (returns empty config), and concurrent access via `sync.RWMutex`.
## Design
### Config File Location
`.core/gui/config.yaml` — resolved relative to `$HOME`:
```
~/.core/gui/config.yaml
```
This follows the go-config convention (`~/.core/config.yaml`) but scoped to the GUI module.
### Config Format
```yaml
window:
state_file: window_state.json
default_width: 1024
default_height: 768
systray:
icon: apptray.png
tooltip: "Core GUI"
menu:
show_dev_tools: true
```
Top-level keys map 1:1 to sub-service names. Each sub-service receives its section as `map[string]any` via the existing `QueryConfig` / `handleConfigQuery` pattern.
### Changes to `pkg/display/display.go`
1. **New field**: Add `cfg *config.Config` to `Service` struct (replaces reliance on `configData` maps for initial load).
2. **`loadConfig()`**: Replace stub with:
```go
func (s *Service) loadConfig() {
cfg, err := config.New(config.WithPath(guiConfigPath()))
if err != nil {
// Log warning, continue with empty config — GUI should not crash
// if config file is missing or malformed.
s.cfg = nil
return
}
s.cfg = cfg
// Populate configData sections from file
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
}
}
}
```
3. **`handleConfigTask()`**: After updating `configData` in memory, persist via `cfg.Set()` + `cfg.Commit()`:
```go
case window.TaskSaveConfig:
s.configData["window"] = t.Value
if s.cfg != nil {
_ = s.cfg.Set("window", t.Value)
_ = s.cfg.Commit()
}
return nil, true, nil
```
4. **`guiConfigPath()`**: Helper returning `~/.core/gui/config.yaml`. Falls back to `.core/gui/config.yaml` in CWD if `$HOME` is unresolvable (shouldn't happen in practice).
### What Does NOT Change
- **`configData` map**: Remains the in-memory cache. `handleConfigQuery` still returns from it. This avoids Viper's type coercion quirks — sub-services get raw `map[string]any`.
- **Sub-service `applyConfig()` methods**: Already wired via IPC. They receive real values once `loadConfig()` populates `configData` from the file. No changes needed — the stubs in `window.applyConfig`, `systray.applyConfig`, and `menu.applyConfig` just need to use the values they already receive.
- **IPC message types**: `QueryConfig`, `TaskSaveConfig` — unchanged.
- **go.mod**: Add `forge.lthn.ai/core/go-config` as a direct dependency. The Go workspace resolves it locally.
### Sub-Service `applyConfig()` Implementations
These are small enhancements to existing stubs (not new architecture):
- **`window.applyConfig()`**: Read `default_width`, `default_height` from config and set on Manager defaults. Read `state_file` to configure StateManager path.
- **`systray.applyConfig()`**: Already partially implemented (reads `tooltip`). Add `icon` path reading.
- **`menu.applyConfig()`**: Read `show_dev_tools` bool and store it for `buildMenu()` to conditionally include the Developer menu.
### Error Handling
Config is non-critical. If the file is missing, malformed, or unreadable:
- `loadConfig()` logs a warning and continues with empty `configData` maps.
- Sub-services receive empty maps and apply their hardcoded defaults.
- `handleConfigTask` saves still work — `config.New` creates the file on first `Commit()`.
### Future Work: Manifest/Slots Integration
The go-scm manifest (`Manifest.Layout`, `Manifest.Slots`) declares which UI components go where (HLCRF positions to component names). Config could store user preferences for those slots (e.g., "I want the terminal in the bottom panel"). This is deferred — the current config covers operational settings only. When implemented, it would add a `layout` section to the config file that overrides manifest slot defaults.
## Dependency Direction
```
pkg/display (orchestrator)
├── imports forge.lthn.ai/core/go-config // NEW
├── imports pkg/window (message types)
├── imports pkg/systray (message types)
├── imports pkg/menu (message types)
└── imports core/go (DI, IPC)
```
No new imports in sub-packages. Only the display orchestrator touches go-config.
## Licence
EUPL-1.2