Skip to content

tweak command and coordinator skeleton #660

Description

@plastikfan

Tweak Command and Coordinator Skeleton

Issue: #660 (scoped from #457 point .5)
Depends on: Issue #4 (PreviewSession and View Isolation Audit) - completed
Status: Planning


1. Design Principles

These principles guide every decision in this issue. When facing a design
fork, test both options against this list.

  1. Nav-view consistency - the tweak home screen follows the same
    Bubble Tea patterns as highway and porthole views. Where the home
    screen has a distinct purpose (menu-driven entry points), the
    interaction style prefers charm huh for forms and wizards.
  2. Charm-first UI - all tweak UI elements use charm.sh libraries.
    Forms use huh, the main loop uses bubbletea, and static styling
    uses lipgloss with predefined themes.
  3. Safe colour boundary - user-selected colours apply only to the
    embedded navigation preview, never to the tweak UI itself. The tweak
    UI (home screen, menus, forms) uses predefined charm themes so it
    remains legible regardless of the user's colour experiments.
  4. Additive only - no existing file is modified. New files are
    created alongside existing ones.
  5. Skeleton first - all four entry points (gradient workshop, palette
    editor, bindings, import) are visible on the home screen but are
    non-functional. Their models and screens are implemented in later
    issues.

2. Scope

Delivers the tweak command wiring, coordinator skeleton with three-layer
state model, and the home screen TUI with entry points visible but not
functional.

This issue does not include:


3. Deliverables

# File New / Modified Description
1 src/app/command/tweak-cmd.go New Cobra adapter for jay tweak
2 src/app/controller/tweak-coordinator.go New TweakCoordinator - state model, dirty tracking, undo, exit flow, auto-restart loop
3 src/app/controller/tweak-home-model.go New Bubble Tea model for the home screen TUI
4 go.mod / go.sum Modified Add github.com/charmbracelet/huh dependency
5 src/app/command/bootstrap.go Modified Wire tweak command in Root(), add tweakState
6 src/app/controller/tweak-coordinator_test.go New Ginkgo specs for coordinator state model
7 src/app/controller/tweak-home-model_test.go New Ginkgo specs for home screen model

4. Resolved Decisions

4.1 Package Layout

  • TweakCoordinator lives in src/app/controller/ alongside the
    existing Coordinator. The two are independent structs with no shared
    code beyond common imports (pref, core, contract, report).
  • tweakHomeModel (the Bubble Tea model for the tweak home screen)
    lives in src/app/controller/ alongside the coordinator for this
    skeleton. In later issues, sub-screens may move to dedicated packages
    under src/prism/tweak/.
  • The home screen model is unexported (tweakHomeModel). The
    TweakCoordinator owns the model and the tea.Program.

4.2 State Model Location

The three-layer state model (raw/upscaled/working) and dirty tracking
live inside TweakCoordinator. They are not extracted into a separate
package because:

4.3 Charm Dependency

huh is a new direct dependency. Use the latest stable version
compatible with the existing bubbletea/v2 dependency (v2.0.7).

4.4 Tweak UI Theme

The tweak home screen and all forms use a fixed internal style set
built with lipgloss at construction time, derived from a small
hardcoded palette of high-contrast, universally legible colours. These
styles are defined as private constants/helpers in
tweak-home-model.go and are never user-configurable.

The navigation preview embedded alongside the home screen (or toggled)
uses the user's working-state palette via contract.NewTheme and
ThemeUpdateMsg. This ensures the colour experiment boundary is clear
and enforced by construction.

4.5 Auto-Restart Loop

The auto-restart loop lives in TweakCoordinator, not in the model.
The coordinator launches a goroutine that:

  1. Creates a fresh PreviewSession.
  2. Runs an agenor traversal against the configured preview path.
  3. On End lifecycle event, checks context cancellation.
  4. If context is still alive, re-arms with a new PreviewSession and
    repeats.
  5. When context is cancelled (user presses Ctrl-C or selects Quit),
    the goroutine exits and MarkComplete is never called.

The model is not aware of the traversal lifecycle. It receives only
messages (node events, completion events) via program.Send().

4.6 Exit Flow Ownership

The exit flow (checking dirty flags, showing confirmation prompts)
lives in TweakCoordinator, not the model. When the model receives a
Quit keypress, it sends an internal quitRequestedMsg to the
coordinator, which checks state and either:

  • Exits immediately (no changes).
  • Shows a prompt (changes pending) via a sub-model or huh form.
  • Cancels the exit and returns to the home screen.

4.7 No Signal Setup in TweakCoordinator

Ctrl-C is handled by the caller context, not by signal.Notify inside
TweakCoordinator. This matches the PreviewSession pattern
established in Issue #4. The caller (the tweak command handler)
creates a cancellable context; Ctrl-C cancels it, which terminates the
auto-restart loop and triggers a clean exit.

4.8 No ThemeUpdateMsg Arms Yet

The ThemeUpdateMsg Update arms in highway and porthole models are
deferred to Issue #5 (this issue) but only as a presence check - the
case is added but the body simply returns the model unchanged or
logs. Real hot-swap implementation is part of Issue #6 when the
gradient workshop produces palette changes.


5. Required Changes

5.1 go.mod - New Dependency

github.com/charmbracelet/huh v<latest-compatible>

Picked to be compatible with the existing bubbletea/v2 dependency.
Run go get github.com/charmbracelet/huh@latest and verify no version
conflicts.

5.2 src/app/command/tweak-cmd.go

package command

import (
    "github.com/snivilised/jaywalk/src/app/controller"
    "github.com/snivilised/li18ngo"
    "github.com/snivilised/jaywalk/locale"
    "github.com/snivilised/mamba/assist"
)

// tweakState holds param-sets owned exclusively by the tweak command.
type tweakState struct {
    previewPathPs  *assist.ParamSet[whatever]
}

func (b *Bootstrap) buildTweakCommand(container *assist.CobraContainer) {
    tweakCmd := &cobra.Command{
        Use:   "tweak",
        Short: li18ngo.Text(locale.TweakCmdShortDescTemplData{}),
        Long:  li18ngo.Text(locale.TweakCmdLongDescTemplData{}),
        Args:  cobra.NoArgs,
        RunE:  b.runTweak,
    }

    // --preview-path flag (optional, overrides jay.ui.yml tweak.preview-path)
    b.tweak.previewPathPs = assist.NewParamSet[store.PreviewParameterSet](cmd)
    // ... bind preview-path flag ...

    container.MustRegisterRootedCommand(tweakCmd)
}

func (b *Bootstrap) runTweak(cmd *cobra.Command, _ []string) error {
    // 1. Resolve preview path: flag > jay.ui.yml tweak.preview-path > $HOME
    previewPath := resolvePreviewPath(b.tweak.previewPathPs, b.UI)

    // 2. Create cancellable context for clean Ctrl-C
    ctx, cancel := context.WithCancel(cmd.Context())
    defer cancel()

    // 3. Create TweakCoordinator with working palette
    coordinator := controller.NewTweakCoordinator(
        controller.TweakCoordinatorOptions{
            PreviewPath: previewPath,
            Palette:     currentPalette,
            Logger:      b.logger,
        },
    )

    // 4. Run the tweak TUI (blocking until user quits)
    return coordinator.Run(ctx)
}

Note: i18n template data structs for locale strings are defined in
locale/ and follow the conventions from .claude/skills/go-i18n/SKILL.md.

5.3 src/app/controller/tweak-coordinator.go

package controller

import (
    "context"
    "sync/atomic"
    "time"

    "github.com/snivilised/jaywalk/src/agenor"
    "github.com/snivilised/jaywalk/src/agenor/core"
    "github.com/snivilised/jaywalk/src/agenor/pref"
    "github.com/snivilised/jaywalk/src/prism/contract"
    tea "charm.land/bubbletea/v2"
)

// TweakCoordinatorOptions configures the coordinator.
type TweakCoordinatorOptions struct {
    PreviewPath string
    Palette     contract.Palette
    Logger      *slog.Logger
}

// tweakDirty tracks whether the user has made changes.
type tweakDirty struct {
    upscaleCreative bool  // layer 1 differs from layer 0
    creative        bool  // layer 2 differs from layer 1
}

// TweakCoordinator manages the tweak TUI lifecycle, state model,
// dirty tracking, undo, exit flow, and the perpetual preview
// traversal auto-restart loop.
type TweakCoordinator struct {
    previewPath string

    // three-layer state model
    layer0 contract.Palette  // raw loaded (read-only)
    layer1 contract.Palette  // upscaled (read-only)
    layer2 contract.Palette  // working (mutable)

    dirty tweakDirty

    // program is the Bubble Tea program for the tweak home screen.
    program *tea.Program

    // cancel is the context cancellation function for the preview
    // traversal auto-restart loop.
    cancel context.CancelFunc

    logger *slog.Logger
}

// NewTweakCoordinator creates a TweakCoordinator with the palette
// loaded into layer 0. Layer 1 is produced by upscaling layer 0.
// Layer 2 is a copy of layer 1 (no creative changes yet).
func NewTweakCoordinator(opts TweakCoordinatorOptions) *TweakCoordinator {
    return &TweakCoordinator{
        previewPath: opts.PreviewPath,
        layer0:      opts.Palette,
        layer1:      opts.Palette,  // upscaled in Issue #1
        layer2:      opts.Palette,  // copy of layer1
        logger:      opts.Logger,
    }
}

// Run starts the tweak TUI and blocks until the user exits.
func (tc *TweakCoordinator) Run(ctx context.Context) error {
    ctx, tc.cancel = context.WithCancel(ctx)
    defer tc.cancel()

    model := newTweakHomeModel(tc)
    tc.program = tea.NewProgram(model)

    // Start the preview traversal auto-restart loop.
    go tc.runPreviewLoop(ctx)

    // Run the TUI (blocking).
    _, err := tc.program.Run()
    return err
}

// runPreviewLoop runs agenor traversals in a perpetual loop until
// the context is cancelled.
func (tc *TweakCoordinator) runPreviewLoop(ctx context.Context) {
    for {
        select {
        case <-ctx.Done():
            return
        default:
            tc.runSinglePreview(ctx)
        }
    }
}

// runSinglePreview runs one agenor traversal with PreviewSession.
func (tc *TweakCoordinator) runSinglePreview(ctx context.Context) {
    session := NewPreviewSession()
    defer session.MarkComplete()

    // Build a pref.Facade using the working palette's theme.
    // The callback sends node events to the tea program.

    traversal := // ... build agenor traversal with session ...
    _ = traversal.Navigate(ctx)

    // On completion, re-arm (loop will call again).
}

// ---------------------------------------------------------------------------
// State model operations
// ---------------------------------------------------------------------------

// WorkingPalette returns the current working state palette.
func (tc *TweakCoordinator) WorkingPalette() contract.Palette {
    return tc.layer2
}

// Undo resets layer 2 to a copy of layer 1.
func (tc *TweakCoordinator) Undo() {
    tc.layer2 = tc.layer1
    tc.dirty.creative = false
    // re-evaluate upscale dirty after undo
}

// IsDirty returns true if either dirty flag is set.
func (tc *TweakCoordinator) IsDirty() bool {
    return tc.dirty.upscaleCreative || tc.dirty.creative
}

// ExitFlow handles the exit prompt sequence based on dirty state.
// Returns true if the application should exit, false if cancelled.
func (tc *TweakCoordinator) ExitFlow() bool {
    switch {
    case tc.dirty.creative:
        // "You have unsaved changes. [S] Save [D] Discard [C] Cancel"
    case tc.dirty.upscaleCreative:
        // "No changes to save, but your theme has been enriched..."
    default:
        return true  // exit silently
    }
    return false
}

TweakCoordinator Design Notes

  • TweakCoordinator does not implement report.Presenter. The
    existing Presenter interface is for production traversal events.
    The tweak coordinator uses tea.Program.Send() directly.
  • The three-layer state model mirrors the design doc sections 5.1-5.3.
    Layer 1 upscaling is a no-op until Issue chore(gh-actions): apply auto-check edits #1 implements
    UpscalePalette. For the skeleton, all three layers hold the same
    palette.
  • Exit flow prompts use huh forms for consistent charm styling.
  • The preview loop receives the working palette theme and sends it to
    the embedded nav view via ThemeUpdateMsg on each palette change.

5.4 src/app/controller/tweak-home-model.go

package controller

import (
    tea "charm.land/bubbletea/v2"
    "github.com/charmbracelet/huh"
    "github.com/snivilised/jaywalk/src/prism/contract"
)

// ---------------------------------------------------------------------------
// Messages
// ---------------------------------------------------------------------------

// entrySelectedMsg is sent when the user selects a home screen entry.
// For the skeleton, all entries are non-functional; the message is
// defined so Issue #6-9 can handle it.
type entrySelectedMsg struct {
    entry int  // 1=gradient workshop, 2=palette editor, 3=bindings, 4=import
}

// exitRequestedMsg is sent when the user triggers an exit flow.
type exitRequestedMsg struct{}

// ---------------------------------------------------------------------------
// Model constants
// ---------------------------------------------------------------------------

// home screen style definitions use a fixed high-contrast palette.
// These styles are never user-configurable (see principle 3).
var (
    homeTitleStyle = lipgloss.NewStyle().
        Bold(true).
        Foreground(lipgloss.Color("#FFF")).
        Background(lipgloss.Color("#333"))

    homeItemStyle = lipgloss.NewStyle().
        Foreground(lipgloss.Color("#DDD"))

    homeKeyStyle = lipgloss.NewStyle().
        Bold(true).
        Foreground(lipgloss.Color("#7C3AED"))  // purple accent
)

// ---------------------------------------------------------------------------
// tweakHomeModel
// ---------------------------------------------------------------------------

// tweakHomeModel is the Bubble Tea model for the tweak home screen.
// It displays the four entry points, the theme name, and keyboard
// shortcuts for file operations, undo, and quit.
type tweakHomeModel struct {
    coordinator *TweakCoordinator
    themeName   string
    width       int
    height      int
}

func newTweakHomeModel(tc *TweakCoordinator) tweakHomeModel {
    return tweakHomeModel{
        coordinator: tc,
        themeName:   "starship",  // from current palette name
    }
}

func (m tweakHomeModel) Init() tea.Cmd {
    return nil
}

func (m tweakHomeModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
    switch msg := msg.(type) {
    case tea.WindowSizeMsg:
        m.width = msg.Width
        m.height = msg.Height

    case tea.KeyMsg:
        switch msg.String() {
        case "1", "2", "3", "4":
            // Entry points non-functional in skeleton.
            // In later issues, sends entrySelectedMsg{entry: N}.
            return m, nil

        case "f", "F":
            // File menu (open/new/save/save-as) - non-functional.
            return m, nil

        case "z", "Z":
            m.coordinator.Undo()
            return m, nil

        case "q", "Q":
            if m.coordinator.ExitFlow() {
                return m, tea.Quit
            }
            return m, nil

        case "ctrl+c":
            return m, tea.Quit
        }
    }
    return m, nil
}

func (m tweakHomeModel) View() tea.View {
    var b strings.Builder

    // Title bar
    title := fmt.Sprintf(" jay tweak        [theme: %s] ", m.themeName)
    b.WriteString(homeTitleStyle.Render(title))
    b.WriteString("\n\n")

    // Entry points
    entries := []struct {
        key  string
        name string
        desc string
    }{
        {"1", "Gradient Workshop", "Define seed gradient, harvest steps to palette roles"},
        {"2", "Palette Editor",   "Edit palette roles directly"},
        {"3", "Bindings",         "Map component slots to gradients"},
        {"4", "Import Theme",     "Convert iTerm2 / VS Code / Alacritty / Warp themes"},
    }

    for _, e := range entries {
        b.WriteString(homeKeyStyle.Render("  " + e.key + ". "))
        b.WriteString(homeItemStyle.Render(e.name))
        b.WriteString("\n")
        b.WriteString("       ")
        b.WriteString(mutedStyle.Render(e.desc))
        b.WriteString("\n\n")
    }

    // Footer shortcuts
    b.WriteString(homeDividerStyle.Render(strings.Repeat("─", m.width)))
    b.WriteString("\n")
    b.WriteString(homeKeyStyle.Render("  F"))
    b.WriteString(homeItemStyle.Render(" File"))
    b.WriteString("    ")
    b.WriteString(homeKeyStyle.Render("Z"))
    b.WriteString(homeItemStyle.Render(" Undo"))
    b.WriteString("    ")
    b.WriteString(homeKeyStyle.Render("Q"))
    b.WriteString(homeItemStyle.Render(" Quit"))
    b.WriteString("\n")

    return tea.NewView(b.String())
}

Tweak Home Model Design Notes

  • The home screen is a single Bubble Tea model with no child widgets
    (unlike highway which has track/status). Complexity is minimal.
  • Style definitions are package-level vars in tweak-home-model.go,
    constructed once at package init. This follows a different pattern
    from the nav views (which use Theme structs) because the tweak UI
    styles are fixed and never user-configurable.
  • The huh library is imported and available for the exit flow
    prompts (used by TweakCoordinator.ExitFlow), but not used in the
    home screen model directly for the skeleton. In later issues, huh
    forms replace manual string-building for wizards and input screens.

5.5 src/app/command/bootstrap.go Changes

Add to the Bootstrap struct:

type Bootstrap struct {
    // ... existing fields ...

    // tweak holds param-sets owned exclusively by the tweak command.
    tweak   tweakState
}

Add to Root():

// In Root(), after b.buildQueryCommand(b.container):
b.buildTweakCommand(b.container)

No other part of Root() changes. The tweak command shares the
persistent flags (--tui, --theme) inherited from root.

5.6 src/app/controller/tweak-coordinator_test.go

Describe("TweakCoordinator")

  Describe("NewTweakCoordinator")
    It("initialises all three layers to the provided palette")
    It("starts with no dirty flags set")

  Describe("Undo")
    It("resets layer 2 to layer 1")
    It("clears the creative dirty flag")
    It("does not affect layer 0 or layer 1")

  Describe("IsDirty")
    It("returns false when no changes have been made")
    It("returns true when creative changes exist")

  Describe("ExitFlow - no changes")
    It("returns true (exit silently) when IsDirty is false")

  Describe("WorkingPalette")
    It("returns layer 2")

5.7 src/app/controller/tweak-home-model_test.go

Describe("tweakHomeModel")

  Describe("Init")
    It("returns nil command")

  Describe("Update - WindowSizeMsg")
    It("stores width and height")

  Describe("Update - KeyMsg")
    It("handles 1/2/3/4 without error (entry points non-functional)")
    It("handles Z/z by calling Undo on the coordinator")
    It("sends tea.Quit on Q/q when ExitFlow returns true")
    It("stays running on Q/q when ExitFlow returns false")
    It("sends tea.Quit on ctrl+c")

  Describe("View")
    It("renders the title bar with theme name")
    It("renders all four entry points")
    It("renders keyboard shortcuts in the footer")

6. Testing Strategy

6.1 TweakCoordinator Tests

Unit tests only. No real filesystem, no agenor traversal, no Bubble
Tea program. The coordinator is a plain Go struct in these tests.

  • Use a contract.Palette fixture (e.g. contract.SystemPalette())
    as the input.
  • Undo tests modify layer 2 directly (via a test helper or by
    setting it) then verify reset.
  • ExitFlow tests verify the returned boolean only (prompt UI tested
    separately in view model tests).

6.2 tweakHomeModel Tests

The model is tested directly via Ginkgo without running a
tea.NewProgram. Tests construct the model, send tea.Msg values
through Update(), and inspect the returned state.

  • A spy coordinator is injected to verify Undo and ExitFlow calls.
  • View output is tested with Gomega matchers (ContainSubstring,
    HavePrefix, etc.).

6.3 What Is Not Tested Here

  • Integration with agenor traversal: the auto-restart loop is tested
    in Issue feat: add prototype tapable code (#4) #6 when real preview content is needed.
  • ThemeUpdateMsg dispatch: covered by Issue feat: add prototype tapable code (#4) #6.
  • huh form rendering: covered by exit flow integration tests in
    later issues.
  • Signal handling / Ctrl-C: the caller owns the context; tested at
    the command integration level.

7. Acceptance Criteria

  • jay tweak --help shows the command with correct description.
  • jay tweak launches the Bubble Tea TUI and shows the home screen.
  • Home screen displays all four entry points (non-functional).
  • Home screen displays the current theme name in the title bar.
  • Z/z triggers Undo (resets working state to upscaled state).
  • Q/q triggers exit flow:
    • No changes: exits silently.
    • Changes pending: shows prompt (deferred to later issue for actual
      huh form; skeleton returns true/false based on dirty flags).
  • Ctrl-C exits the TUI cleanly without saving.
  • F/f is a no-op (file menu deferred to later issues).
  • 1/2/3/4 are no-ops (entry point screens deferred).
  • TweakCoordinator initialises all three layers to the provided
    palette.
  • TweakCoordinator.Undo() resets layer 2 to layer 1 and clears
    the creative dirty flag.
  • TweakCoordinator.IsDirty() reflects the correct state.
  • TweakCoordinator.ExitFlow() returns the correct boolean based
    on dirty state.
  • All existing tests in src/app/command/ and
    src/app/controller/ continue to pass without modification.
  • go vet and linter report no issues.
  • go build ./... succeeds with the new huh dependency.

8. Implementation Order

  1. go get github.com/charmbracelet/huh - add dependency, verify
    compatibility with bubbletea/v2.
  2. src/app/controller/tweak-coordinator.go - coordinator struct,
    state model, dirty tracking, Undo, ExitFlow, auto-restart loop
    skeleton (loop body is a no-op for now).
  3. src/app/controller/tweak-home-model.go - home screen Bubble Tea
    model with all four entry points visible.
  4. src/app/command/tweak-cmd.go - cobra adapter wiring.
  5. src/app/command/bootstrap.go - add tweakState, wire command in
    Root().
  6. src/app/controller/tweak-coordinator_test.go - coordinator unit
    tests.
  7. src/app/controller/tweak-home-model_test.go - home screen model
    tests.
  8. Verify: go build ./..., go vet ./..., full test suite.
  9. Manual: jay tweak launches, home screen renders, keyboard
    shortcuts work.

9. Files to Create

File Description
src/app/command/tweak-cmd.go Cobra adapter for jay tweak
src/app/controller/tweak-coordinator.go TweakCoordinator with state model
src/app/controller/tweak-home-model.go Bubble Tea model for home screen
src/app/controller/tweak-coordinator_test.go Coordinator unit tests
src/app/controller/tweak-home-model_test.go Home screen model tests

10. Files Modified

File Change Required?
go.mod, go.sum Add huh dependency Yes
src/app/command/bootstrap.go Add tweakState, wire buildTweakCommand Yes

11. Dependencies

New Library Dependencies

Library Version Purpose
github.com/charmbracelet/huh Latest compatible Forms for exit flow, wizards

Existing Library Dependencies (no change)

Library Package Used by
charm.land/bubbletea/v2 tea TweakCoordinator, tweakHomeModel
charm.land/lipgloss/v2 lipgloss tweakHomeModel styles
github.com/snivilised/jaywalk/src/agenor/core core PreviewSession, Session
github.com/snivilised/jaywalk/src/agenor/pref pref Options for preview traversal
github.com/snivilised/jaywalk/src/prism/contract contract Palette, Theme, ThemeUpdateMsg
github.com/snivilised/jaywalk/src/app/controller local PreviewSession

12. Key Integration Points

Component Integrates With Mechanism
tweak-cmd.go TweakCoordinator Creates coordinator, calls Run(ctx)
TweakCoordinator PreviewSession Uses NewPreviewSession for traversal
TweakCoordinator tweakHomeModel Owns model, creates tea.NewProgram
tweakHomeModel TweakCoordinator Calls Undo(), ExitFlow(), IsDirty()
TweakCoordinator agenor traversal runPreviewLoop -> Navigate(ctx)
TweakCoordinator nav view models Sends ThemeUpdateMsg via program.Send()

13. Open Questions (Resolved as Noted)

Question Resolution
Where does huh form for exit flow live? Inline in TweakCoordinator.ExitFlow() for now. Extracted when the prompt grows complex.
How does the home screen model get the preview nav view rendered alongside? The skeleton does not embed a nav view. The auto-restart loop runs in a goroutine and sends messages to a nav view model if one is active. For the skeleton, the loop is a no-op body. Full preview integration is Issue #6.
What happens to the theme name on the title bar? It comes from the palette name carried by the working state. For the skeleton, it is hardcoded to "starship" until the file management flow is implemented.
Does TweakCoordinator need its own test suite bootstrap file? No. The existing controller-suite_test.go runs all *_test.go files in the package via RunSpecs.

Metadata

Metadata

Assignees

Labels

featureNew feature or request

Fields

No fields configured for Feature.

Projects

No projects

Milestone

No milestone

Relationships

None yet

Development

No branches or pull requests

Issue actions