Onboarding, Settings screen and host picker #24

Closed
opened 2026-10-02 12:01:03 -05:00 by deadstyle · 6 comments
Owner

Problem

The first launch should walk the user through setup, and settings should be changeable later without editing a file. Split from #5, which now covers only the config layer underneath these screens.

Plan

  1. Onboarding flow in internal/ui (docs/design.md → Onboarding, steps 1–7), shown when no config exists. The connection test uses the adapter's "current user" and "list repos" calls. Renovate bot detection looks for open PRs from renovate/* branches and suggests their author.
  2. Settings screen (S): hosts (add using the same flow, edit, remove, set default), the update check toggle, and the "only merge when CI is green" setting per host with a per-repo override. Changes save immediately.
  3. Host picker (navigation level 0) with a + Add host entry, used when #5's host selection finds no single host.

Depends on

  • #5 (config load/save, token resolution, host selection).
  • #8 (UI shell), which sets the UI patterns these screens follow.

Context

  • docs/design.md → Onboarding, Settings, Navigation model level 0.
  • Product detail: the exact permission list shown in onboarding step 4 depends on what the adapter needs. Engineering fills it in.
## Problem The first launch should walk the user through setup, and settings should be changeable later without editing a file. Split from #5, which now covers only the config layer underneath these screens. ## Plan 1. **Onboarding flow** in `internal/ui` (`docs/design.md` → Onboarding, steps 1–7), shown when no config exists. The connection test uses the adapter's "current user" and "list repos" calls. Renovate bot detection looks for open PRs from `renovate/*` branches and suggests their author. 2. **Settings screen** (`S`): hosts (add using the same flow, edit, remove, set default), the update check toggle, and the "only merge when CI is green" setting per host with a per-repo override. Changes save immediately. 3. **Host picker** (navigation level 0) with a **+ Add host** entry, used when #5's host selection finds no single host. ## Depends on - #5 (config load/save, token resolution, host selection). - #8 (UI shell), which sets the UI patterns these screens follow. ## Context - `docs/design.md` → Onboarding, Settings, Navigation model level 0. - Product detail: the exact permission list shown in onboarding step 4 depends on what the adapter needs. Engineering fills it in.
Author
Owner

🤖 Building now. design.md doesn't spell out a few details, so these are the defaults going in. Each is cheap to change, so tell me if any should be different:

  1. Settings keys: no new letters. j/k move, enter opens Edit · Set as default · Remove, space toggles, esc goes back.
  2. Edit a host: re-runs onboarding from the server step with everything filled in. You can rename the host there.
  3. Remove the host you're connected to: refused with "Switch to another host first". Removing any other host asks for confirmation.
  4. h from the repo list: always opens the host picker, even when it was skipped at startup. The picker includes + Add host.
  5. Wrong server type (you picked Forgejo, the server is Gitea): an error naming what was found, and you stay on that step.
  6. S during onboarding: does nothing, because keys type into the fields.
  7. Per-repo "only merge when green" overrides: for a host you aren't connected to, Settings shows only the overrides you already have. Adding a new one needs that host's repo list.
  8. Adding a host from Settings: takes you straight into the new host's repo list, matching onboarding's last step.
  9. Host picker order: hosts sorted by name, + Add host last, and the cursor starting on the host you used last.

The onboarding sign-in step asks for a token with read:user, write:repository and write:issue.

🤖 Building now. design.md doesn't spell out a few details, so these are the defaults going in. Each is cheap to change, so tell me if any should be different: 1. **Settings keys:** no new letters. `j`/`k` move, `enter` opens Edit · Set as default · Remove, `space` toggles, `esc` goes back. 2. **Edit a host:** re-runs onboarding from the server step with everything filled in. You can rename the host there. 3. **Remove the host you're connected to:** refused with "Switch to another host first". Removing any other host asks for confirmation. 4. **`h` from the repo list:** always opens the host picker, even when it was skipped at startup. The picker includes **+ Add host**. 5. **Wrong server type** (you picked Forgejo, the server is Gitea): an error naming what was found, and you stay on that step. 6. **`S` during onboarding:** does nothing, because keys type into the fields. 7. **Per-repo "only merge when green" overrides:** for a host you aren't connected to, Settings shows only the overrides you already have. Adding a new one needs that host's repo list. 8. **Adding a host from Settings:** takes you straight into the new host's repo list, matching onboarding's last step. 9. **Host picker order:** hosts sorted by name, **+ Add host** last, and the cursor starting on the host you used last. The onboarding sign-in step asks for a token with `read:user`, `write:repository` and `write:issue`.
Author
Owner

🤖 Handoff: stopped for the day. Pick up here on any machine.

State: branch feature/24-onboarding-settings is pushed at 8ea83f7, rebased onto develop 0b2c232 (after #9), and every package passes go test -race ./.... There's no MR yet. The delivery ran through /labs:composer: planning, building all five parts, consolidation, and both Phase 6 reviews are done. The full plan is in the next comment.

What's built:

  • the token-free server check, plus the Renovate bot suggestion
  • the app root above the browsing UI: host picker with + Add host, h back to it, S for Settings, session switching that drops results from the old host, saves written in order, the last-used host remembered
  • onboarding steps 1–7, with the pasted token masked
  • Settings: hosts, the update check, and "only merge when green" per host and per repo, saving immediately
  • startup wiring: no config opens onboarding, and a host picker shows when one is needed
  • docs: ADR 0012 and the architecture UI section. design.md step 4 now lists the token permissions.

Open Phase 6 findings. All go to one fresh composer-fixer, followed by a targeted re-review of each axis:

  1. High: make lint is red.
    • contextcheck at internal/ui/app.go:83 and :85: NewApp calls openOnboarding and openSession, and that chain reaches calls that use a stored ctx. Pass ctx explicitly; no //nolint.
    • ineffassign at internal/ui/onboarding_test.go:205
    • revive unused-parameter t in obRun, onboarding_test.go:60
  2. Low: failure reasons are cut off at 80 columns. The picker status shows connect to http://127.0.0.1:1: GET /version: Get "http://127.0.0.1:1/ap. Fix: wrap the onboarding error line to the frame width, and make the picker message Can't connect to NAME: <innermost cause>.
  3. Low: drop the unused keyMap/help.Model params on onboarding.view, settings.view and settings.hints.
  4. Low: newSettings() returns the zero value. Use settings{}.
  5. Low: in onboarding stepTest, turn the empty-case switch into an early return.
  6. Low: cmd/lazyforge/main.go run() repeats tea.NewProgram(...).Run() in both branches. Run it once.
  7. Low: appDeps calls config.DefaultPaths() even with --config. Only the state path needs it.
  8. Info, fix: step 4's text for write:repository should also say "close PRs".

Accepted, no change:

  • model.go was touched by a behaviour-preserving extraction.
  • The atotto/clipboard indirect dependency comes with the approved text input.
  • gitea.New now returns "not found" for a /version reply that isn't a valid version.
  • The unstamped editor message can't be reached.

Phase 8 notes: TestLiveGreenCI covers the host-level toggle only. Add the per-repo override case and the Settings path.

Remaining after the fix:

  • full check
  • test backfill, one writer each for ui, cmd and gitea/core
  • comments and test-quality review
  • re-verify, then sync develop
  • push and open the MR (Closes #24)
  • CI green, squash-merge, close the ticket

Decisions so far:

  • Session messages carry a generation stamp.
  • Saves happen in order, and tests use temp-dir paths.
  • Connecting to the selected host at startup stays in cmd.
  • Settings uses the existing keys.
  • The token scopes were checked against this instance's API.
  • --demo never touches your config, and its h/S hints are hidden.
  • The nine UI defaults in the comment above stand unless the PM says otherwise.
🤖 **Handoff: stopped for the day. Pick up here on any machine.** **State:** branch `feature/24-onboarding-settings` is pushed at `8ea83f7`, rebased onto develop `0b2c232` (after #9), and every package passes `go test -race ./...`. There's no MR yet. The delivery ran through `/labs:composer`: planning, building all five parts, consolidation, and both Phase 6 reviews are done. The full plan is in the next comment. **What's built:** - the token-free server check, plus the Renovate bot suggestion - the app root above the browsing UI: host picker with **+ Add host**, `h` back to it, `S` for Settings, session switching that drops results from the old host, saves written in order, the last-used host remembered - onboarding steps 1–7, with the pasted token masked - Settings: hosts, the update check, and "only merge when green" per host and per repo, saving immediately - startup wiring: no config opens onboarding, and a host picker shows when one is needed - docs: ADR 0012 and the architecture UI section. design.md step 4 now lists the token permissions. **Open Phase 6 findings. All go to one fresh `composer-fixer`, followed by a targeted re-review of each axis:** 1. **High: `make lint` is red.** - `contextcheck` at `internal/ui/app.go:83` and `:85`: NewApp calls openOnboarding and openSession, and that chain reaches calls that use a stored ctx. Pass ctx explicitly; no `//nolint`. - `ineffassign` at `internal/ui/onboarding_test.go:205` - `revive` unused-parameter `t` in `obRun`, `onboarding_test.go:60` 2. **Low: failure reasons are cut off at 80 columns.** The picker status shows `connect to http://127.0.0.1:1: GET /version: Get "http://127.0.0.1:1/ap`. Fix: wrap the onboarding error line to the frame width, and make the picker message `Can't connect to NAME: <innermost cause>`. 3. **Low:** drop the unused `keyMap`/`help.Model` params on `onboarding.view`, `settings.view` and `settings.hints`. 4. **Low:** `newSettings()` returns the zero value. Use `settings{}`. 5. **Low:** in onboarding `stepTest`, turn the empty-case switch into an early return. 6. **Low:** `cmd/lazyforge/main.go` `run()` repeats `tea.NewProgram(...).Run()` in both branches. Run it once. 7. **Low:** `appDeps` calls `config.DefaultPaths()` even with `--config`. Only the state path needs it. 8. **Info, fix:** step 4's text for `write:repository` should also say "close PRs". **Accepted, no change:** - `model.go` was touched by a behaviour-preserving extraction. - The `atotto/clipboard` indirect dependency comes with the approved text input. - `gitea.New` now returns "not found" for a `/version` reply that isn't a valid version. - The unstamped editor message can't be reached. **Phase 8 notes:** `TestLiveGreenCI` covers the host-level toggle only. Add the per-repo override case and the Settings path. **Remaining after the fix:** - full check - test backfill, one writer each for ui, cmd and gitea/core - comments and test-quality review - re-verify, then sync develop - push and open the MR (`Closes #24`) - CI green, squash-merge, close the ticket **Decisions so far:** - Session messages carry a generation stamp. - Saves happen in order, and tests use temp-dir paths. - Connecting to the selected host at startup stays in cmd. - Settings uses the existing keys. - The token scopes were checked against this instance's API. - `--demo` never touches your config, and its `h`/`S` hints are hidden. - The nine UI defaults in the comment above stand unless the PM says otherwise.
Author
Owner

🤖 #24 plan (composer Phase 1, with the lead rulings at the end). It is posted here so a session on another machine can continue without re-planning.

Plan: #24 onboarding, Settings screen, host picker

Working dir /Users/bobparsons/Development/personal/lazyforge-24, branch feature/24-onboarding-settings, fork_point b1beb5c (stacked on #9).
Conventions: skills go-development and comment-standard; repo CLAUDE.md. Verify with make check.

Goal

Replace cmd's two dead ends ("no config yet: onboarding is not built yet", "pick a host with --host") with real screens:
onboarding (design.md steps 1-7), the host picker (level 0, with + Add host), and Settings (S) that saves on every change.

Task type / size / parallelism

Feature. Size L (≈1,300-1,700 lines incl. tests across 4 packages).
Three waves, five subtasks:

Wave Subtasks Why this order
1 S1 (forge/gitea + core) ∥ S2 (ui app shell + picker + config.Clone + docs) No shared files. S2 writes compiling stubs for onboarding.go / settings.go with the exact seam signatures below.
2 S3 (onboarding) ∥ S4 (settings) ∥ S5 (cmd wiring) Each replaces/edits only its own files; all need S1's funcs and S2's types.

Decisions (engineering, made here; confirm under QUESTIONS in the report)

D1. New root model ui.App (internal/ui/app.go) owns the screen (onboarding | picker | settings | session), the config, and the one live session. The existing ui.Model stays the session model unchanged; ui.New(ctx, svc) keeps working (--demo and every existing test use it). model.go, load.go, actions.go, dialog.go are not edited.

D2. App intercepts two keys before the session instead of editing model.go: S (open Settings) and h/left at levelRepos (open the picker), both only when session.dialog == nil && !session.showHelp. Everything else goes to the session.

D3. Stale session messages are dropped by generation stamping. Switching host cancels the old session's context, bumps App.gen, and wraps every command the session returns with stamp(gen, cmd). stamp runs the inner cmd; a tea.BatchMsg is re-stamped element-wise; a msg implementing the unexported marker interface{ fromSession() } is wrapped in stampedMsg{gen, msg}; anything else (the runtime's exec message for $EDITOR, tea.QuitMsg) passes through untouched. App delivers a stampedMsg only when gen == a.gen. The marker methods for reposLoadedMsg, changeRequestsLoadedMsg, issuesLoadedMsg, runsLoadedMsg, refreshTickMsg, recheckedMsg, mergeDoneMsg, actionDoneMsg are declared in app.go next to the interface (so load.go/actions.go stay untouched). Unstamped non-App messages (e.g. editorDoneMsg from the ExecProcess callback) go to the current session. Why: two hosts can carry the same owner/name (mirrors), so the existing core.Key repo check doesn't protect a new session from an old host's results.

D4. UI never imports forge/gitea. cmd injects Connect func(context.Context, config.Host) (forge.Forge, error) (type check, config.ResolveToken, gitea.New) and Probe func(context.Context, string) (forge.Kind, error) (gitea.Probe). ui may import config (leaf package) and core.

D5. Live "only merge when green". App holds live *atomic.Pointer[config.Config] (allocated in NewApp, shared by value copies). A session for host name is built with core.New(f, core.Options{RequireGreenCI: func(r domain.RepoRef) bool { return live.Load().Hosts[name].RequiresGreenCI(r.String()) }}). Every applied config change does live.Store(&next). Race-free (core.Merge reads it off the UI goroutine); a Settings toggle takes effect on the next merge dialog without reconnecting. Configs are never mutated in place: every change starts from cfg.Clone() (new config.Config.Clone, deep-copies Hosts and each Repos map).

D6. Saves are tea.Cmds, ordered by sequence number. saver (in app.go) = struct{ mu sync.Mutex; path string; written int } held by pointer. a.save(next) tea.Cmd increments a.saveSeq and returns a cmd that locks mu, skips the write if seq < written (a newer snapshot already landed), else config.Save(path, next) and returns configSavedMsg{seq, err}. Settings changes apply optimistically (a.cfg = next; live.Store) and show a status error if the save fails. Onboarding's final save is not optimistic: App applies next only on configSavedMsg{seq: thatSeq, err: nil}; on failure onboarding stays on step 7 with the error. The file seam for tests is the path (Deps.ConfigPath/StatePath into t.TempDir()), so tests never touch the user's files and still prove the real 0600 write.

D7. State.LastHost is written fire-and-forget by a cmd (LoadState → set LastHost → SaveState, errors ignored: losing state is harmless) whenever a session starts from the picker or from onboarding.

D8. Startup connect stays in cmd. When SelectHost returns a name, cmd connects before the UI as today (errors still exit with the existing messages). Only picker picks, onboarding and edits connect from inside the UI.

D9. Onboarding async results are checked against their inputs, not an id. A probe result carries the URL it probed, a connect result carries the config.Host it tried, a Renovate result carries nothing but is accepted only on step 6 with the field still untouched. A result is used only if the step and the current input still match; esc cancels the step's context.

D10. Renovate suggestion lives in core (core.(*Service).SuggestRenovateUser): scan the most recently active repos (cap 10, ponytail: comment naming the ceiling), first open CR whose SourceBranch starts with renovate/ wins, return its Author. Per-repo errors other than ctx errors are skipped. Reuses Repos/ChangeRequests (cached, semaphore-bounded); no adapter change.

D11. Permissions shown at step 4 (Forgejo/Gitea token scope names), derived from the adapter's calls:

  • read:user: sign-in check (GET /user)
  • write:repository: list repos, PRs, CI status and runs, merge, approve, close PRs
  • write:issue: issues, comments, closing issues, ticking dashboard entries

Token page link: <url>/user/settings/applications. Verify the list against the live server once it's back (see report RISKS); add read:misc only if /version turns out to need it.

D12. In-screen keys reuse existing bindings only (no new letters): j/k move, l/enter in/activate, space toggle/cycle, esc back/cancel, y/enter confirm, tab switch sign-in mode, q quit (picker only). keys.go gains exactly two bindings: Settings (S, help "settings") and Enter (enter only, so text inputs can receive y, l, q, j, k, h, S). Text-input screens match only Enter, Close (esc), NextBox (tab, step 4), Up/Down restricted to arrow keys on list steps, and Interrupt.

D13. Host name rules (step 7): ^[A-Za-z0-9._-]+$, unique among configured hosts (except the host being edited). Suggested from the URL hostname: strip a leading git./www./code., take the first label (git.bobparsons.dev → bobparsons, codeberg.org → codeberg); if taken, append -2, -3, ….

D14. URL normalisation (step 3): trim spaces and trailing /; prepend https:// when there's no scheme; must parse with a non-empty host, else "Enter a server address like https://git.example.com" without probing.

Seams (public and package-internal interfaces the test-writers target)

// internal/config/config.go (S2)
func (c Config) Clone() Config // deep copy of Hosts and every Host.Repos; RepoSettings pointers may be shared (never mutated in place)

// internal/forge/gitea/gitea.go (S1)
// Probe reads GET /api/v1/version without a token. ErrUnauthorized (sign-in-only instance) returns ("", nil):
// reachable, kind unknown until the connection test.
func Probe(ctx context.Context, baseURL string, hc *http.Client) (forge.Kind, error)

// internal/core/renovate.go (S1)
func (s *Service) SuggestRenovateUser(ctx context.Context) (string, error) // "" , nil when none found

// internal/ui/app.go (S2)
type Deps struct {
    ConfigPath, StatePath string
    Config   config.Config  // Update.Check true when Fresh
    Fresh    bool           // no config file: start onboarding at step 1
    LastHost string         // from state; picker cursor
    Host     string         // selected host; "" shows the picker (or onboarding when Fresh)
    Forge    forge.Forge    // connected forge for Host; nil when Host == ""
    Connect  func(context.Context, config.Host) (forge.Forge, error)
    Probe    func(ctx context.Context, url string) (forge.Kind, error)
}
func NewApp(ctx context.Context, d Deps) App
func (a App) Init() tea.Cmd
func (a App) Update(tea.Msg) (tea.Model, tea.Cmd)
func (a App) View() tea.View

// package-internal seams between S2 and S3/S4 (S2 writes stubs with exactly these signatures)
type onboardStart struct {
    welcome  bool           // first run: begin at step 1; otherwise step 2 (add) or step 3 (edit)
    edit     string         // name of the host being edited; "" when adding
    host     config.Host    // prefill when editing
    taken    func(name string) bool
}
func newOnboarding(ctx context.Context, connect func(context.Context, config.Host) (forge.Forge, error),
    probe func(context.Context, string) (forge.Kind, error), s onboardStart) onboarding
func (o onboarding) update(msg tea.Msg, k keyMap) (onboarding, tea.Cmd)
func (o onboarding) view(w, h int, k keyMap, hm help.Model) string
func (o onboarding) hints(k keyMap) []key.Binding
func (o *onboarding) saveFailed(err error)            // App reports a failed step-7 save
type onboardDoneMsg struct { name, replaces string; host config.Host; f forge.Forge } // replaces = old name on edit
type onboardCancelMsg struct{}

type settingsView struct {
    cfg    config.Config
    active string           // session host; "" when none
    repos  []domain.RepoRef // active host's repos from svc.PeekRepos(); nil when unknown
}
type settingsIntent struct {
    kind intentKind // intentNone | intentChange | intentAdd | intentEdit | intentClose
    cfg  config.Config // intentChange: the new config (already cloned)
    host string        // intentEdit: host name
}
func newSettings() settings
func (s settings) update(msg tea.KeyPressMsg, v settingsView, k keyMap) (settings, settingsIntent)
func (s settings) view(w, h int, v settingsView, k keyMap, hm help.Model) string
func (s settings) hints(k keyMap, v settingsView) []key.Binding

// cmd/lazyforge/main.go (S5)
func appDeps(ctx context.Context, configPath, hostName string, noUpdateCheck bool) (ui.Deps, error) // replaces realService
func connect(ctx context.Context, h config.Host) (forge.Forge, error)

Subtasks

S1: Probe and Renovate suggestion (wave 1, independent)

File domain: internal/forge/gitea/gitea.go, internal/forge/gitea/probe_test.go (seam check), internal/core/renovate.go, internal/core/renovate_test.go (seam check).

Steps:

  1. gitea.Probe: unauthenticated GET {base}/api/v1/version; +gitea- in the version → KindForgejo, else KindGitea. Reuse the existing request/statusError path (a forge with empty token; omit the Authorization header when the token is empty). Wrap errors probe %s: %w. Non-JSON or missing version → error wrapping forge.ErrNotFound ("no Forgejo or Gitea API"). ErrUnauthorized → ("", nil). New may call the same unexported version helper.
  2. SuggestRenovateUser per D10.

AC:

  • S1-1 Probe against an httptest server returning {"version":"16.0.5+gitea-1.22.0"} → KindForgejo; {"version":"1.22.0"} → KindGitea; request carries no Authorization header.
  • S1-2 Probe: 404 or HTML body → errors.Is(err, forge.ErrNotFound); 401 → ("", nil); closed server/refused port → error that is neither (a *net.OpError reachable via errors.As).
  • S1-3 New behavior unchanged (existing gitea tests pass).
  • S1-4 Suggest: Fake with a repo whose open CR has SourceBranch "renovate/foo", Author "renovate-bot" → "renovate-bot"; no such CR → "", nil; a repo whose CR listing errors is skipped and a later repo still wins; cancelled ctx → error; only the first 10 repos by activity are scanned (11th repo's renovate CR not found).

S2: App shell, picker, session switching, saves (wave 1)

File domain: internal/ui/app.go, internal/ui/picker.go, internal/ui/keys.go, internal/ui/onboarding.go (stub only), internal/ui/settings.go (stub only), internal/ui/app_test.go (seam check), internal/config/config.go (add Clone), docs/architecture.md (UI section: App root, D3, D4, D5), docs/adr/0012-ui-app-root-and-settings.md (D1-D7).

Steps:

  1. config.Clone.
  2. keys.go: add Settings (S, "settings") and Enter (enter) bindings; S in fullHelp and in the repos-level short help; repos-level hint for h = "hosts".
  3. App per D1-D8: screens; NewApp picks the start screen (Fresh → onboarding with welcome; Host != "" → session from Forge; else picker); startSession(name, f) (cancel previous ctx, gen++, core.New with D5 closure, New(sctx, svc), replay last WindowSizeMsg, return stamp(gen, session.Init()) batched with the D7 state write); key interception D2; stamp D3; saver D6; WindowSizeMsg stored and forwarded.
  4. Picker (picker.go): rows = configured hosts sorted by name, then + Add host; a row shows name, URL and (default) for DefaultHost; initial cursor on LastHost if configured, else DefaultHost, else row 0. j/k/gg/G move; l/enter on a host: if it's the active session's host return to the session without reconnecting, else Connect in a cmd (status "Connecting to NAME…"; result connectedMsg{name, f, err} used only if name == a.connecting); error → status error, stay. l/enter on + Add host → onboarding from step 2. S → Settings. q quits. Header breadcrumb: lazyforge › Hosts.
  5. App handling of S3/S4 seams: onboardDoneMsg → build next (Clone; on edit with renamed host delete the old key and move DefaultHost if it pointed there; copy RequireGreenCI and Repos from the old host) → D6 non-optimistic save → on success: add → startSession(name, f) (straight into the repo list); edit → back to Settings, and if the edited host is the active one, startSession(newName, f) while staying on Settings. onboardCancelMsg → previous screen (quit when Fresh). settingsIntent: change → optimistic apply + save; add → onboarding step 2; edit → onboarding step 3 prefilled; close → previous screen (session or picker).
  6. Stubs: onboarding.go / settings.go compile with the seam signatures, render a placeholder, and the stub settings returns intentClose on esc / stub onboarding emits onboardCancelMsg on esc.
  7. Docs: architecture.md UI section + ADR 0012.

AC:

  • S2-1 Clone: mutating the clone's Hosts, a host's Repos, or adding a repo override leaves the original unchanged.
  • S2-2 Start screen: Fresh → onboarding; Host set with Forge → session (repo list loads); neither → picker.
  • S2-3 Picker order: hosts b, a, c → rows a, b, c, + Add host; (default) on the default host; cursor starts on LastHost, falls back to DefaultHost, then row 0; LastHost naming an unknown host falls back.
  • S2-4 Picking a host calls Connect once with that host's config.Host; success → session for that host and the state file at StatePath has last_host = NAME (other state fields preserved); Connect error → status shows it, screen stays picker, no session.
  • S2-5 + Add host → onboarding (not at step 1). Zero configured hosts → picker shows only + Add host.
  • S2-6 From the session's repo list, h → picker; picking the same host returns to the same session with no Connect call; h inside an open dialog or the help overlay does not open the picker.
  • S2-7 S from the session (repos, boxes, details) and from the picker opens Settings; S with a dialog open or help shown does nothing; Settings close returns to the screen it came from.
  • S2-8 Stale messages: after switching from host A to host B, a reposLoadedMsg (or refreshTickMsg) produced by A's session is dropped; B's are applied. A non-marker msg returned by the session's cmd passes through stamp unwrapped.
  • S2-9 Live green CI: session over host h with require_green_ci = false; apply a Settings change setting it true; svc.RequiresGreenCI(repo) flips to true without a new session. Same for a per-repo override.
  • S2-10 Saves: a change writes ConfigPath with mode 0600; two quick changes leave the file equal to the second; a save error (ConfigPath inside a read-only dir / path is a directory) shows a status error.
  • S2-11 Onboarding done (add) → config file contains the new host; app is on that host's repo list. Save failure → no host added in memory, saveFailed called, still on onboarding. Edit with rename → old key gone, default and overrides moved.
  • S2-12 go build ./... passes with the stubs; existing ui tests pass unchanged.

S3: Onboarding flow (wave 2)

File domain: internal/ui/onboarding.go (replaces stub), internal/ui/onboarding_test.go (seam check), docs/design.md (step 4 only: record the D11 permission list, per the ticket's "engineering fills it in").

Steps: implement the flow with bubbles/v2/textinput; one input per step; all I/O via cmds (D9).

  1. Welcome (first run only): one screen of copy; enter continue; esc → onboardCancelMsg (App quits on Fresh).
  2. Forge type: list Forgejo, Gitea, GitHub "coming soon", GitLab "coming soon"; the cursor skips the coming-soon rows and enter on them does nothing; enter selects.
  3. Address: input (D14). enter → Probe cmd ("Checking…"). Success with matching kind (or kind "") → step 4. Kind mismatch → "That server runs Gitea, not Forgejo" (names swapped as appropriate), stay. ErrNotFound → "No Forgejo or Gitea API at that address", stay. Network error → "Can't reach the server: ", stay.
  4. Sign in: tab toggles "Paste a token" / "Command that prints one"; token mode uses textinput.EchoPassword; shows the D11 permission list and the token page URL. Empty input → inline error, stay. enter → step 5.
  5. Connection test (runs on entry): builds config.Host{Type, URL, Token|TokenCmd}, connect then core.New(f, core.Options{}).Repos in one cmd; success "Signed in as LOGIN · N repositories", enter → step 6. Failure, stay on step 5 with enter retry, esc back to step 4:
    • errors.Is(err, forge.ErrUnauthorized) from connect → "The token was rejected. Check it's valid and has read:user."
    • ErrUnauthorized from Repos → "Signed in, but the token can't list repositories: it needs write:repository."
    • *net.OpError / *url.Error timeout → "Can't reach the server: …"
    • anything else → err.Error() (token_cmd errors are already output-free).
  6. Renovate: input prefilled from SuggestRenovateUser on the step-5 service when it arrives ("Looking for Renovate PRs…" meanwhile, "No Renovate PRs found" when ""); typing before it arrives keeps the typed value. enter accepts (blank = skip). Editing prefills from host.RenovateUser and skips the scan.
  7. Name: input prefilled per D13; enter validates (D13 errors inline) → emit onboardDoneMsg. saveFailed(err) shows "Couldn't save: …" and stays.
  • esc on steps 3-7 goes back one step (cancelling that step's in-flight cmd ctx); esc on the first shown step → onboardCancelMsg.

AC:

  • S3-1 Step 2: GitHub/GitLab render "coming soon"; Down from Gitea doesn't land on them; Forgejo and Gitea select.
  • S3-2 Step 3: git.example.com probes https://git.example.com; probe success → step 4; mismatch, ErrNotFound and network errors each show their message and stay on step 3; not a url with no host shows the format hint without calling Probe.
  • S3-3 Step 4: in token mode the View never contains the typed token (assert on ANSI-stripped View, with a distinctive fake token); the permission list (read:user, write:repository, write:issue) and <url>/user/settings/applications are shown; empty input stays.
  • S3-4 Step 5 success shows "Signed in as LOGIN · N repositories" (Fake user/repo count); each failure class above shows its message, stays on step 5, and enter retries (Connect called again). Token mode sets Host.Token, command mode sets Host.TokenCmd, never both.
  • S3-5 A connect result for a host other than the current inputs (user went back and changed the token) is ignored.
  • S3-6 Step 6: suggestion prefilled; user-typed value survives a late suggestion; blank accepted.
  • S3-7 Step 7: suggestion bobparsons for https://git.bobparsons.dev; taken name → -2; invalid characters and duplicates rejected inline; enter emits onboardDoneMsg with type, URL, token/token_cmd, renovate_user and name.
  • S3-8 Add flow starts at step 2; edit flow starts at step 3 prefilled with URL/token mode/renovate user and edit name, and allows keeping the same name.
  • S3-9 esc walks back one step; on the first shown step it emits onboardCancelMsg.
  • S3-10 Typing q, S, h, j, y into any input inserts the character (no quit, no Settings, no navigation).
  • S3-11 End-to-end through App with a pasted token: the saved file at ConfigPath is mode 0600 and loads back with config.Load.

S4: Settings screen (wave 2)

File domain: internal/ui/settings.go (replaces stub), internal/ui/settings_test.go (seam check).

Layout (one navigable list, header lazyforge › Settings):

  • Hosts: one row per host (sorted), showing name, URL, (default), (connected) for active; then + Add host.
  • Updates: Check for updates at startup on|off.
  • Merging: per host, Only merge when CI is green on|off, followed by its repo override rows owner/name inherit|on|off — rows for the active host come from v.repos merged with existing overrides; for other hosts only existing overrides.

Keys (D12): j/k, gg/G move; on a host row l/enter opens an inline menu Edit · Set as default · Remove (j/k, enter, esc); + Add host → intentAdd; space/enter on the update or host-merging row toggles; space/enter on a repo row cycles inherit → on → off → inherit; esc/h → intentClose.
Remove asks inline Remove NAME? y/enter confirm · esc cancel; removing the default also clears DefaultHost; removing the active host is refused: "Switch to another host before removing NAME". Set as default on the current default does nothing.
Every change returns intentChange with a Cloned config; pure helpers do the edits.

AC:

  • S4-1 Rows render in the order above; (default) and (connected) markers correct.
  • S4-2 Update toggle flips Update.Check; App saves it (file reflects it immediately).
  • S4-3 Host merging toggle flips Hosts[h].RequireGreenCI; repo row cycles Repos[r].RequireGreenCI nil → true → false → nil, and at nil the repo key is removed from Repos (the file has no empty [hosts.h.repos."o/r"] table).
  • S4-4 Active host lists its repos (from v.repos) plus overrides for repos not in that list; another host lists only its overrides.
  • S4-5 Set as default sets DefaultHost; Remove + confirm deletes the host and clears DefaultHost when it pointed there; esc at the confirm leaves config unchanged; removing the active host returns no change and shows the refusal.
  • S4-6 Edit → intentEdit{host}; + Add host → intentAdd; esc → intentClose.
  • S4-7 The input cfg is never mutated (compare against a pre-captured copy after each intent).

S5: cmd wiring (wave 2)

File domain: cmd/lazyforge/main.go, cmd/lazyforge/main_test.go.

Steps: replace realService with appDeps (signature above) and runUI with tea.NewProgram(ui.NewApp(ctx, d)).Run(); --demo path unchanged. Missing config → maybeUpdate(ctx, !noUpdateCheck) then Deps{Fresh: true, Config: config.Config{Update: config.Update{Check: true}}}. SelectHost → ErrNeedPicker → Deps{Host: ""}; other errors returned. Named host → connect (moves today's type check, ResolveToken, gitea.New with the 30 s client) and errors keep today's host %q: … wrapping. StatePath from DefaultPaths even when --config is given; LastHost from LoadState (error → zero). Probe = gitea.Probe with a 10 s client.

AC:

  • S5-1 No config file → Deps.Fresh, no error, Update.Check true.
  • S5-2 Two hosts, no default, no --host → Deps.Host == "", no error, Deps.Forge == nil.
  • S5-3 Existing error cases keep their messages: type "github" has no adapter yet, token_cmd failure hides output, unknown --host names the host.
  • S5-4 Single forgejo host on an httptest server → Deps.Host set, Deps.Forge.Info().User from /user.
  • S5-5 connect for a github host returns the "no adapter yet" error without running token_cmd.
  • (The green-CI wiring test moves to S2-9; delete TestRealServiceWiresGreenCI here.)

Shared constraints

  • ui imports config, core, forge, domain; never forge/gitea or forgetest outside tests (depguard).
  • No fmt.Print/logging of tokens anywhere; error messages built here never include Token or TokenCmd output.
  • All disk, network and process work in tea.Cmds; Update never blocks.
  • No new modules (textinput is in bubbles/v2).
  • Seam signatures above are fixed; a subtask that needs to change one stops and reports.
  • Each implementer leaves one seam check per non-trivial branchy function; the full suite comes in Phase 8.

Verification

make check (build, test, lint, format) on the integration branch; tmux check of: first run in an empty XDG_CONFIG_HOME through to the repo list (fake token against an httptest/fixture server is fine while the git server is down), picker with two hosts, S toggles reflected in config.toml, h from repo list → picker → same host.

Lead rulings (Opus, override anything above that conflicts)

  1. Q1 (D3 generation stamping), Q2 (D6 seq saver; tests use t.TempDir paths with the real config.Save — never the user's files), Q3 (D8 startup connect stays in cmd), Q5 (D12 S + enter-only bindings; S is already in design.md's keymap) — accepted as written.
  2. Q4: the onboarding permission list is verified against the instance's API/token-scope docs before merge (merge waits for the git server anyway). Implementers use the plan's list.
  3. Product gaps 1-9: build with the plan's stated defaults. The lead lists them on the ticket for the PM as adjustable assumptions; none blocks.
🤖 **#24 plan (composer Phase 1, with the lead rulings at the end).** It is posted here so a session on another machine can continue without re-planning. # Plan: #24 onboarding, Settings screen, host picker Working dir `/Users/bobparsons/Development/personal/lazyforge-24`, branch `feature/24-onboarding-settings`, fork_point `b1beb5c` (stacked on #9). Conventions: skills `go-development` and `comment-standard`; repo `CLAUDE.md`. Verify with `make check`. ## Goal Replace cmd's two dead ends ("no config yet: onboarding is not built yet", "pick a host with --host") with real screens: onboarding (design.md steps 1-7), the host picker (level 0, with **+ Add host**), and Settings (`S`) that saves on every change. ## Task type / size / parallelism Feature. Size **L** (≈1,300-1,700 lines incl. tests across 4 packages). Three waves, five subtasks: | Wave | Subtasks | Why this order | |---|---|---| | 1 | S1 (forge/gitea + core) ∥ S2 (ui app shell + picker + config.Clone + docs) | No shared files. S2 writes compiling **stubs** for `onboarding.go` / `settings.go` with the exact seam signatures below. | | 2 | S3 (onboarding) ∥ S4 (settings) ∥ S5 (cmd wiring) | Each replaces/edits only its own files; all need S1's funcs and S2's types. | ## Decisions (engineering, made here; confirm under QUESTIONS in the report) D1. **New root model `ui.App`** (`internal/ui/app.go`) owns the screen (`onboarding | picker | settings | session`), the config, and the one live session. The existing `ui.Model` stays the session model unchanged; `ui.New(ctx, svc)` keeps working (`--demo` and every existing test use it). `model.go`, `load.go`, `actions.go`, `dialog.go` are not edited. D2. **App intercepts two keys before the session** instead of editing `model.go`: `S` (open Settings) and `h`/`left` at `levelRepos` (open the picker), both only when `session.dialog == nil && !session.showHelp`. Everything else goes to the session. D3. **Stale session messages are dropped by generation stamping.** Switching host cancels the old session's context, bumps `App.gen`, and wraps every command the session returns with `stamp(gen, cmd)`. `stamp` runs the inner cmd; a `tea.BatchMsg` is re-stamped element-wise; a msg implementing the unexported marker `interface{ fromSession() }` is wrapped in `stampedMsg{gen, msg}`; anything else (the runtime's exec message for `$EDITOR`, `tea.QuitMsg`) passes through untouched. App delivers a `stampedMsg` only when `gen == a.gen`. The marker methods for `reposLoadedMsg, changeRequestsLoadedMsg, issuesLoadedMsg, runsLoadedMsg, refreshTickMsg, recheckedMsg, mergeDoneMsg, actionDoneMsg` are declared in `app.go` next to the interface (so load.go/actions.go stay untouched). Unstamped non-App messages (e.g. `editorDoneMsg` from the ExecProcess callback) go to the current session. Why: two hosts can carry the same `owner/name` (mirrors), so the existing `core.Key` repo check doesn't protect a new session from an old host's results. D4. **UI never imports `forge/gitea`.** cmd injects `Connect func(context.Context, config.Host) (forge.Forge, error)` (type check, `config.ResolveToken`, `gitea.New`) and `Probe func(context.Context, string) (forge.Kind, error)` (`gitea.Probe`). `ui` may import `config` (leaf package) and `core`. D5. **Live "only merge when green".** App holds `live *atomic.Pointer[config.Config]` (allocated in `NewApp`, shared by value copies). A session for host `name` is built with `core.New(f, core.Options{RequireGreenCI: func(r domain.RepoRef) bool { return live.Load().Hosts[name].RequiresGreenCI(r.String()) }})`. Every applied config change does `live.Store(&next)`. Race-free (core.Merge reads it off the UI goroutine); a Settings toggle takes effect on the next merge dialog without reconnecting. Configs are never mutated in place: every change starts from `cfg.Clone()` (new `config.Config.Clone`, deep-copies `Hosts` and each `Repos` map). D6. **Saves are tea.Cmds, ordered by sequence number.** `saver` (in `app.go`) = `struct{ mu sync.Mutex; path string; written int }` held by pointer. `a.save(next) tea.Cmd` increments `a.saveSeq` and returns a cmd that locks `mu`, skips the write if `seq < written` (a newer snapshot already landed), else `config.Save(path, next)` and returns `configSavedMsg{seq, err}`. Settings changes apply optimistically (`a.cfg = next; live.Store`) and show a status error if the save fails. Onboarding's final save is **not** optimistic: App applies `next` only on `configSavedMsg{seq: thatSeq, err: nil}`; on failure onboarding stays on step 7 with the error. The file seam for tests is the path (`Deps.ConfigPath`/`StatePath` into `t.TempDir()`), so tests never touch the user's files and still prove the real 0600 write. D7. **State.LastHost** is written fire-and-forget by a cmd (`LoadState` → set `LastHost` → `SaveState`, errors ignored: losing state is harmless) whenever a session starts from the picker or from onboarding. D8. **Startup connect stays in cmd.** When `SelectHost` returns a name, cmd connects before the UI as today (errors still exit with the existing messages). Only picker picks, onboarding and edits connect from inside the UI. D9. **Onboarding async results are checked against their inputs, not an id.** A probe result carries the URL it probed, a connect result carries the `config.Host` it tried, a Renovate result carries nothing but is accepted only on step 6 with the field still untouched. A result is used only if the step and the current input still match; esc cancels the step's context. D10. **Renovate suggestion lives in core** (`core.(*Service).SuggestRenovateUser`): scan the most recently active repos (cap 10, `ponytail:` comment naming the ceiling), first open CR whose `SourceBranch` starts with `renovate/` wins, return its `Author`. Per-repo errors other than ctx errors are skipped. Reuses `Repos`/`ChangeRequests` (cached, semaphore-bounded); no adapter change. D11. **Permissions shown at step 4** (Forgejo/Gitea token scope names), derived from the adapter's calls: - `read:user`: sign-in check (`GET /user`) - `write:repository`: list repos, PRs, CI status and runs, merge, approve, close PRs - `write:issue`: issues, comments, closing issues, ticking dashboard entries Token page link: `<url>/user/settings/applications`. Verify the list against the live server once it's back (see report RISKS); add `read:misc` only if `/version` turns out to need it. D12. **In-screen keys reuse existing bindings only** (no new letters): `j/k` move, `l`/`enter` in/activate, `space` toggle/cycle, `esc` back/cancel, `y/enter` confirm, `tab` switch sign-in mode, `q` quit (picker only). keys.go gains exactly two bindings: `Settings` (`S`, help "settings") and `Enter` (`enter` only, so text inputs can receive `y`, `l`, `q`, `j`, `k`, `h`, `S`). Text-input screens match only `Enter`, `Close` (esc), `NextBox` (tab, step 4), `Up`/`Down` restricted to arrow keys on list steps, and `Interrupt`. D13. **Host name rules** (step 7): `^[A-Za-z0-9._-]+$`, unique among configured hosts (except the host being edited). Suggested from the URL hostname: strip a leading `git.`/`www.`/`code.`, take the first label (`git.bobparsons.dev` → `bobparsons`, `codeberg.org` → `codeberg`); if taken, append `-2`, `-3`, …. D14. **URL normalisation** (step 3): trim spaces and trailing `/`; prepend `https://` when there's no scheme; must parse with a non-empty host, else "Enter a server address like https://git.example.com" without probing. ## Seams (public and package-internal interfaces the test-writers target) ```go // internal/config/config.go (S2) func (c Config) Clone() Config // deep copy of Hosts and every Host.Repos; RepoSettings pointers may be shared (never mutated in place) // internal/forge/gitea/gitea.go (S1) // Probe reads GET /api/v1/version without a token. ErrUnauthorized (sign-in-only instance) returns ("", nil): // reachable, kind unknown until the connection test. func Probe(ctx context.Context, baseURL string, hc *http.Client) (forge.Kind, error) // internal/core/renovate.go (S1) func (s *Service) SuggestRenovateUser(ctx context.Context) (string, error) // "" , nil when none found // internal/ui/app.go (S2) type Deps struct { ConfigPath, StatePath string Config config.Config // Update.Check true when Fresh Fresh bool // no config file: start onboarding at step 1 LastHost string // from state; picker cursor Host string // selected host; "" shows the picker (or onboarding when Fresh) Forge forge.Forge // connected forge for Host; nil when Host == "" Connect func(context.Context, config.Host) (forge.Forge, error) Probe func(ctx context.Context, url string) (forge.Kind, error) } func NewApp(ctx context.Context, d Deps) App func (a App) Init() tea.Cmd func (a App) Update(tea.Msg) (tea.Model, tea.Cmd) func (a App) View() tea.View // package-internal seams between S2 and S3/S4 (S2 writes stubs with exactly these signatures) type onboardStart struct { welcome bool // first run: begin at step 1; otherwise step 2 (add) or step 3 (edit) edit string // name of the host being edited; "" when adding host config.Host // prefill when editing taken func(name string) bool } func newOnboarding(ctx context.Context, connect func(context.Context, config.Host) (forge.Forge, error), probe func(context.Context, string) (forge.Kind, error), s onboardStart) onboarding func (o onboarding) update(msg tea.Msg, k keyMap) (onboarding, tea.Cmd) func (o onboarding) view(w, h int, k keyMap, hm help.Model) string func (o onboarding) hints(k keyMap) []key.Binding func (o *onboarding) saveFailed(err error) // App reports a failed step-7 save type onboardDoneMsg struct { name, replaces string; host config.Host; f forge.Forge } // replaces = old name on edit type onboardCancelMsg struct{} type settingsView struct { cfg config.Config active string // session host; "" when none repos []domain.RepoRef // active host's repos from svc.PeekRepos(); nil when unknown } type settingsIntent struct { kind intentKind // intentNone | intentChange | intentAdd | intentEdit | intentClose cfg config.Config // intentChange: the new config (already cloned) host string // intentEdit: host name } func newSettings() settings func (s settings) update(msg tea.KeyPressMsg, v settingsView, k keyMap) (settings, settingsIntent) func (s settings) view(w, h int, v settingsView, k keyMap, hm help.Model) string func (s settings) hints(k keyMap, v settingsView) []key.Binding // cmd/lazyforge/main.go (S5) func appDeps(ctx context.Context, configPath, hostName string, noUpdateCheck bool) (ui.Deps, error) // replaces realService func connect(ctx context.Context, h config.Host) (forge.Forge, error) ``` ## Subtasks ### S1: Probe and Renovate suggestion (wave 1, independent) File domain: `internal/forge/gitea/gitea.go`, `internal/forge/gitea/probe_test.go` (seam check), `internal/core/renovate.go`, `internal/core/renovate_test.go` (seam check). Steps: 1. `gitea.Probe`: unauthenticated `GET {base}/api/v1/version`; `+gitea-` in the version → `KindForgejo`, else `KindGitea`. Reuse the existing request/`statusError` path (a forge with empty token; omit the Authorization header when the token is empty). Wrap errors `probe %s: %w`. Non-JSON or missing `version` → error wrapping `forge.ErrNotFound` ("no Forgejo or Gitea API"). ErrUnauthorized → `("", nil)`. `New` may call the same unexported version helper. 2. `SuggestRenovateUser` per D10. AC: - S1-1 Probe against an httptest server returning `{"version":"16.0.5+gitea-1.22.0"}` → `KindForgejo`; `{"version":"1.22.0"}` → `KindGitea`; request carries no Authorization header. - S1-2 Probe: 404 or HTML body → `errors.Is(err, forge.ErrNotFound)`; 401 → `("", nil)`; closed server/refused port → error that is neither (a `*net.OpError` reachable via `errors.As`). - S1-3 `New` behavior unchanged (existing gitea tests pass). - S1-4 Suggest: Fake with a repo whose open CR has `SourceBranch "renovate/foo"`, `Author "renovate-bot"` → `"renovate-bot"`; no such CR → `""`, nil; a repo whose CR listing errors is skipped and a later repo still wins; cancelled ctx → error; only the first 10 repos by activity are scanned (11th repo's renovate CR not found). ### S2: App shell, picker, session switching, saves (wave 1) File domain: `internal/ui/app.go`, `internal/ui/picker.go`, `internal/ui/keys.go`, `internal/ui/onboarding.go` (**stub only**), `internal/ui/settings.go` (**stub only**), `internal/ui/app_test.go` (seam check), `internal/config/config.go` (add `Clone`), `docs/architecture.md` (UI section: App root, D3, D4, D5), `docs/adr/0012-ui-app-root-and-settings.md` (D1-D7). Steps: 1. `config.Clone`. 2. keys.go: add `Settings` (`S`, "settings") and `Enter` (`enter`) bindings; `S` in `fullHelp` and in the repos-level short help; repos-level hint for `h` = "hosts". 3. `App` per D1-D8: screens; `NewApp` picks the start screen (Fresh → onboarding with `welcome`; `Host != ""` → session from `Forge`; else picker); `startSession(name, f)` (cancel previous ctx, gen++, `core.New` with D5 closure, `New(sctx, svc)`, replay last `WindowSizeMsg`, return `stamp(gen, session.Init())` batched with the D7 state write); key interception D2; `stamp` D3; saver D6; `WindowSizeMsg` stored and forwarded. 4. Picker (`picker.go`): rows = configured hosts sorted by name, then `+ Add host`; a row shows name, URL and `(default)` for `DefaultHost`; initial cursor on `LastHost` if configured, else `DefaultHost`, else row 0. `j/k`/`gg`/`G` move; `l`/`enter` on a host: if it's the active session's host return to the session without reconnecting, else `Connect` in a cmd (status "Connecting to NAME…"; result `connectedMsg{name, f, err}` used only if `name == a.connecting`); error → status error, stay. `l`/`enter` on `+ Add host` → onboarding from step 2. `S` → Settings. `q` quits. Header breadcrumb: `lazyforge › Hosts`. 5. App handling of S3/S4 seams: `onboardDoneMsg` → build next (`Clone`; on edit with renamed host delete the old key and move `DefaultHost` if it pointed there; copy `RequireGreenCI` and `Repos` from the old host) → D6 non-optimistic save → on success: add → `startSession(name, f)` (straight into the repo list); edit → back to Settings, and if the edited host is the active one, `startSession(newName, f)` while staying on Settings. `onboardCancelMsg` → previous screen (quit when Fresh). `settingsIntent`: change → optimistic apply + save; add → onboarding step 2; edit → onboarding step 3 prefilled; close → previous screen (session or picker). 6. Stubs: `onboarding.go` / `settings.go` compile with the seam signatures, render a placeholder, and the stub settings returns `intentClose` on esc / stub onboarding emits `onboardCancelMsg` on esc. 7. Docs: architecture.md UI section + ADR 0012. AC: - S2-1 `Clone`: mutating the clone's `Hosts`, a host's `Repos`, or adding a repo override leaves the original unchanged. - S2-2 Start screen: `Fresh` → onboarding; `Host` set with `Forge` → session (repo list loads); neither → picker. - S2-3 Picker order: hosts `b, a, c` → rows `a, b, c, + Add host`; `(default)` on the default host; cursor starts on `LastHost`, falls back to `DefaultHost`, then row 0; `LastHost` naming an unknown host falls back. - S2-4 Picking a host calls `Connect` once with that host's `config.Host`; success → session for that host and the state file at `StatePath` has `last_host = NAME` (other state fields preserved); `Connect` error → status shows it, screen stays picker, no session. - S2-5 `+ Add host` → onboarding (not at step 1). Zero configured hosts → picker shows only `+ Add host`. - S2-6 From the session's repo list, `h` → picker; picking the same host returns to the same session with no `Connect` call; `h` inside an open dialog or the help overlay does not open the picker. - S2-7 `S` from the session (repos, boxes, details) and from the picker opens Settings; `S` with a dialog open or help shown does nothing; Settings close returns to the screen it came from. - S2-8 Stale messages: after switching from host A to host B, a `reposLoadedMsg` (or `refreshTickMsg`) produced by A's session is dropped; B's are applied. A non-marker msg returned by the session's cmd passes through `stamp` unwrapped. - S2-9 Live green CI: session over host `h` with `require_green_ci = false`; apply a Settings change setting it true; `svc.RequiresGreenCI(repo)` flips to true without a new session. Same for a per-repo override. - S2-10 Saves: a change writes `ConfigPath` with mode 0600; two quick changes leave the file equal to the second; a save error (ConfigPath inside a read-only dir / path is a directory) shows a status error. - S2-11 Onboarding done (add) → config file contains the new host; app is on that host's repo list. Save failure → no host added in memory, `saveFailed` called, still on onboarding. Edit with rename → old key gone, default and overrides moved. - S2-12 `go build ./...` passes with the stubs; existing ui tests pass unchanged. ### S3: Onboarding flow (wave 2) File domain: `internal/ui/onboarding.go` (replaces stub), `internal/ui/onboarding_test.go` (seam check), `docs/design.md` (step 4 only: record the D11 permission list, per the ticket's "engineering fills it in"). Steps: implement the flow with `bubbles/v2/textinput`; one input per step; all I/O via cmds (D9). 1. Welcome (first run only): one screen of copy; `enter` continue; `esc` → `onboardCancelMsg` (App quits on Fresh). 2. Forge type: list Forgejo, Gitea, GitHub "coming soon", GitLab "coming soon"; the cursor skips the coming-soon rows and `enter` on them does nothing; `enter` selects. 3. Address: input (D14). `enter` → Probe cmd ("Checking…"). Success with matching kind (or kind `""`) → step 4. Kind mismatch → "That server runs Gitea, not Forgejo" (names swapped as appropriate), stay. ErrNotFound → "No Forgejo or Gitea API at that address", stay. Network error → "Can't reach the server: <err>", stay. 4. Sign in: `tab` toggles "Paste a token" / "Command that prints one"; token mode uses `textinput.EchoPassword`; shows the D11 permission list and the token page URL. Empty input → inline error, stay. `enter` → step 5. 5. Connection test (runs on entry): builds `config.Host{Type, URL, Token|TokenCmd}`, `connect` then `core.New(f, core.Options{}).Repos` in one cmd; success "Signed in as LOGIN · N repositories", `enter` → step 6. Failure, stay on step 5 with `enter` retry, `esc` back to step 4: - `errors.Is(err, forge.ErrUnauthorized)` from connect → "The token was rejected. Check it's valid and has read:user." - ErrUnauthorized from Repos → "Signed in, but the token can't list repositories: it needs write:repository." - `*net.OpError` / `*url.Error` timeout → "Can't reach the server: …" - anything else → `err.Error()` (token_cmd errors are already output-free). 6. Renovate: input prefilled from `SuggestRenovateUser` on the step-5 service when it arrives ("Looking for Renovate PRs…" meanwhile, "No Renovate PRs found" when ""); typing before it arrives keeps the typed value. `enter` accepts (blank = skip). Editing prefills from `host.RenovateUser` and skips the scan. 7. Name: input prefilled per D13; `enter` validates (D13 errors inline) → emit `onboardDoneMsg`. `saveFailed(err)` shows "Couldn't save: …" and stays. - `esc` on steps 3-7 goes back one step (cancelling that step's in-flight cmd ctx); `esc` on the first shown step → `onboardCancelMsg`. AC: - S3-1 Step 2: `GitHub`/`GitLab` render "coming soon"; Down from Gitea doesn't land on them; Forgejo and Gitea select. - S3-2 Step 3: `git.example.com` probes `https://git.example.com`; probe success → step 4; mismatch, ErrNotFound and network errors each show their message and stay on step 3; `not a url` with no host shows the format hint without calling Probe. - S3-3 Step 4: in token mode the View never contains the typed token (assert on ANSI-stripped View, with a distinctive fake token); the permission list (`read:user`, `write:repository`, `write:issue`) and `<url>/user/settings/applications` are shown; empty input stays. - S3-4 Step 5 success shows "Signed in as LOGIN · N repositories" (Fake user/repo count); each failure class above shows its message, stays on step 5, and `enter` retries (Connect called again). Token mode sets `Host.Token`, command mode sets `Host.TokenCmd`, never both. - S3-5 A connect result for a host other than the current inputs (user went back and changed the token) is ignored. - S3-6 Step 6: suggestion prefilled; user-typed value survives a late suggestion; blank accepted. - S3-7 Step 7: suggestion `bobparsons` for `https://git.bobparsons.dev`; taken name → `-2`; invalid characters and duplicates rejected inline; `enter` emits `onboardDoneMsg` with type, URL, token/token_cmd, renovate_user and name. - S3-8 Add flow starts at step 2; edit flow starts at step 3 prefilled with URL/token mode/renovate user and `edit` name, and allows keeping the same name. - S3-9 `esc` walks back one step; on the first shown step it emits `onboardCancelMsg`. - S3-10 Typing `q`, `S`, `h`, `j`, `y` into any input inserts the character (no quit, no Settings, no navigation). - S3-11 End-to-end through App with a pasted token: the saved file at `ConfigPath` is mode 0600 and loads back with `config.Load`. ### S4: Settings screen (wave 2) File domain: `internal/ui/settings.go` (replaces stub), `internal/ui/settings_test.go` (seam check). Layout (one navigable list, header `lazyforge › Settings`): - **Hosts**: one row per host (sorted), showing name, URL, `(default)`, `(connected)` for `active`; then `+ Add host`. - **Updates**: `Check for updates at startup on|off`. - **Merging**: per host, `Only merge when CI is green on|off`, followed by its repo override rows `owner/name inherit|on|off` — rows for the active host come from `v.repos` merged with existing overrides; for other hosts only existing overrides. Keys (D12): `j/k`, `gg/G` move; on a host row `l/enter` opens an inline menu `Edit · Set as default · Remove` (`j/k`, `enter`, `esc`); `+ Add host` → `intentAdd`; `space`/`enter` on the update or host-merging row toggles; `space`/`enter` on a repo row cycles inherit → on → off → inherit; `esc`/`h` → `intentClose`. Remove asks inline `Remove NAME? y/enter confirm · esc cancel`; removing the default also clears `DefaultHost`; removing the active host is refused: "Switch to another host before removing NAME". Set as default on the current default does nothing. Every change returns `intentChange` with a `Clone`d config; pure helpers do the edits. AC: - S4-1 Rows render in the order above; `(default)` and `(connected)` markers correct. - S4-2 Update toggle flips `Update.Check`; App saves it (file reflects it immediately). - S4-3 Host merging toggle flips `Hosts[h].RequireGreenCI`; repo row cycles `Repos[r].RequireGreenCI` nil → true → false → nil, and at nil the repo key is removed from `Repos` (the file has no empty `[hosts.h.repos."o/r"]` table). - S4-4 Active host lists its repos (from `v.repos`) plus overrides for repos not in that list; another host lists only its overrides. - S4-5 Set as default sets `DefaultHost`; Remove + confirm deletes the host and clears `DefaultHost` when it pointed there; `esc` at the confirm leaves config unchanged; removing the active host returns no change and shows the refusal. - S4-6 Edit → `intentEdit{host}`; `+ Add host` → `intentAdd`; `esc` → `intentClose`. - S4-7 The input `cfg` is never mutated (compare against a pre-captured copy after each intent). ### S5: cmd wiring (wave 2) File domain: `cmd/lazyforge/main.go`, `cmd/lazyforge/main_test.go`. Steps: replace `realService` with `appDeps` (signature above) and `runUI` with `tea.NewProgram(ui.NewApp(ctx, d)).Run()`; `--demo` path unchanged. Missing config → `maybeUpdate(ctx, !noUpdateCheck)` then `Deps{Fresh: true, Config: config.Config{Update: config.Update{Check: true}}}`. `SelectHost` → `ErrNeedPicker` → `Deps{Host: ""}`; other errors returned. Named host → `connect` (moves today's type check, `ResolveToken`, `gitea.New` with the 30 s client) and errors keep today's `host %q: …` wrapping. `StatePath` from `DefaultPaths` even when `--config` is given; `LastHost` from `LoadState` (error → zero). `Probe` = `gitea.Probe` with a 10 s client. AC: - S5-1 No config file → `Deps.Fresh`, no error, `Update.Check` true. - S5-2 Two hosts, no default, no `--host` → `Deps.Host == ""`, no error, `Deps.Forge == nil`. - S5-3 Existing error cases keep their messages: `type "github" has no adapter yet`, token_cmd failure hides output, unknown `--host` names the host. - S5-4 Single forgejo host on an httptest server → `Deps.Host` set, `Deps.Forge.Info().User` from `/user`. - S5-5 `connect` for a `github` host returns the "no adapter yet" error without running `token_cmd`. - (The green-CI wiring test moves to S2-9; delete `TestRealServiceWiresGreenCI` here.) ## Shared constraints - `ui` imports `config`, `core`, `forge`, `domain`; never `forge/gitea` or `forgetest` outside tests (depguard). - No `fmt.Print`/logging of tokens anywhere; error messages built here never include `Token` or `TokenCmd` output. - All disk, network and process work in `tea.Cmd`s; `Update` never blocks. - No new modules (`textinput` is in `bubbles/v2`). - Seam signatures above are fixed; a subtask that needs to change one stops and reports. - Each implementer leaves one seam check per non-trivial branchy function; the full suite comes in Phase 8. ## Verification `make check` (build, test, lint, format) on the integration branch; tmux check of: first run in an empty `XDG_CONFIG_HOME` through to the repo list (fake token against an httptest/fixture server is fine while the git server is down), picker with two hosts, `S` toggles reflected in `config.toml`, `h` from repo list → picker → same host. ## Lead rulings (Opus, override anything above that conflicts) 1. Q1 (D3 generation stamping), Q2 (D6 seq saver; tests use t.TempDir paths with the real config.Save — never the user's files), Q3 (D8 startup connect stays in cmd), Q5 (D12 S + enter-only bindings; S is already in design.md's keymap) — accepted as written. 2. Q4: the onboarding permission list is verified against the instance's API/token-scope docs before merge (merge waits for the git server anyway). Implementers use the plan's list. 3. Product gaps 1-9: build with the plan's stated defaults. The lead lists them on the ticket for the PM as adjustable assumptions; none blocks.
Author
Owner

🤖 resumed: running the Phase 6 fixer from the handoff list, then test backfill and MR.

🤖 resumed: running the Phase 6 fixer from the handoff list, then test backfill and MR.
Author
Owner

🤖 MR #32 open: reviews passed, tests backfilled. Merging once CI is green.

🤖 MR #32 open: reviews passed, tests backfilled. Merging once CI is green.
Author
Owner

🤖 merged in #32

🤖 merged in #32
Sign in to join this conversation.
No milestone
No project
No assignees
1 participant
Notifications
Due date
The due date is invalid or out of range. Please use the format "yyyy-mm-dd".

No due date set.

Dependencies

No dependencies set

Reference
deadstyle/lazyforge#24
No description provided.