From f7f2764bfe24496ff4a1dc7969899195d340f73b Mon Sep 17 00:00:00 2001 From: Franco Conturso Date: Wed, 11 Nov 2026 14:53:03 -0300 Subject: [PATCH 1/2] Settings: the first launch opens the Setup tab (#713 S2, D280) --- cmd/contr/e2e_harness_test.go | 8 +- cmd/contr/e2e_setup_test.go | 30 +++++++ docs/DECISIONS.md | 1 + docs/spec/SettingsSetup.md | 8 +- internal/app/appearance.go | 3 +- internal/app/attach.go | 20 ++++- internal/app/deps.go | 2 + internal/app/frame.go | 12 +-- internal/app/settings/settings.go | 2 + internal/app/settings/settings_setup.go | 36 ++++++++- internal/app/settings/settings_setup_test.go | 29 +++++++ internal/app/setup_first_test.go | 83 ++++++++++++++++++++ internal/prefs/prefs.go | 3 + internal/prefs/prefs_test.go | 19 +++++ 14 files changed, 244 insertions(+), 12 deletions(-) create mode 100644 cmd/contr/e2e_setup_test.go create mode 100644 internal/app/setup_first_test.go diff --git a/cmd/contr/e2e_harness_test.go b/cmd/contr/e2e_harness_test.go index 41106512..389620a7 100644 --- a/cmd/contr/e2e_harness_test.go +++ b/cmd/contr/e2e_harness_test.go @@ -48,6 +48,9 @@ type harness struct { runs int // app launches, for a new socket each time proc *exec.Cmd c *client + // firstLaunch keeps settings.json without setupSeen, so the app opens Settings on the + // Setup tab when a required check is not ready (D280). Other tests start with it seen. + firstLaunch bool } var ( @@ -217,7 +220,7 @@ func (h *harness) settings(t *testing.T, doc string) { // theme sets the appearance: "dark", "light" or "system". func (h *harness) theme(t *testing.T, name string) { t.Helper() - h.settings(t, `{"version": 1, "appearance": "`+name+`"}`) + h.settings(t, `{"version": 1, "appearance": "`+name+`", "setupSeen": `+strconv.FormatBool(!h.firstLaunch)+`}`) } // appEnv is the env of the app: the scratch core, the scratch home and the @@ -236,6 +239,9 @@ func (h *harness) launch(t *testing.T) *client { t.Helper() h.quit() h.runs++ + if _, err := os.Stat(filepath.Join(h.home, "Library", "Application Support", "contr", "settings.json")); os.IsNotExist(err) && !h.firstLaunch { + h.settings(t, `{"version": 1, "setupSeen": true}`) + } sock := filepath.Join(h.dir, fmt.Sprintf("a%d.sock", h.runs)) proc := exec.Command(filepath.Join(h.out, "contr-e2e")) proc.Env = h.appEnv(sock) diff --git a/cmd/contr/e2e_setup_test.go b/cmd/contr/e2e_setup_test.go new file mode 100644 index 00000000..4aeed25b --- /dev/null +++ b/cmd/contr/e2e_setup_test.go @@ -0,0 +1,30 @@ +//go:build e2e + +package main + +import ( + "testing" + "time" + + "github.com/contursofran/contr/internal/app/settings" + "github.com/contursofran/contr/internal/app/sidebar" +) + +// TestSetupFirstLaunch: on the first launch with required steps left, Settings opens on the +// Setup tab once (D280). The next launch shows only the dot on the Settings entry. +func TestSetupFirstLaunch(t *testing.T) { + h := newHarness(t) + seeSetupMissing(t, h) + h.firstLaunch = true + c := h.launch(t) + c.waitLabel(t, settings.SetupRecheck) + c.settle(t) + h.capture(t, c, "setup-first-launch.png") + + c = h.launch(t) + c.waitLabel(t, sidebar.LabelSettingsSetup) + time.Sleep(500 * time.Millisecond) + if c.hasPrefix(t, settings.SetupRecheck) { + t.Fatal("Settings opened by itself on the second launch") + } +} diff --git a/docs/DECISIONS.md b/docs/DECISIONS.md index e15f4b19..bdfdda1b 100644 --- a/docs/DECISIONS.md +++ b/docs/DECISIONS.md @@ -253,3 +253,4 @@ replaces it. | D276 | 2026-11-11 | The composer gets layout B of board Y5 (#711, owner pick, tracker #634; docs/spec/ChatView.md, Composer). The card holds the field (14px on 22px lines) and one 30px round button, with `space-8` padding: Send (`ink-100` fill, arrow up) while the field has text, Stop (1px ring, 9px square) while the agent works and the field is empty, and no button when idle and empty (was Send dimmed, D271). The Working line inside the card goes away. Under the card, outside it, one footer line 18px high, 16px side inset, mono `meta`, `ink-48`: at the left the working glyph and "Working 7s · Bash · gh pr diff 633" while the agent works (what it does clips with `…`; the model text never clips), else "⏎ send · ⇧⏎ new line"; at the right model, effort and context. The team row moves out of the card: it sits directly above it, 16px inset, no fill or border, and has no live text, so working shows once. Replaces the top line and foot parts of D271. | | D277 | 2026-11-11 | Settings → Updates updates hc (#712). The hc row gets an `Update hc` button that runs `hc update --json` off the UI thread after a confirm (`Update hc?`, `hc downloads the newest version and restarts the core. Running terminals keep running.`, `Cancel` and `Update`; Cancel and Escape run nothing) (5 minute limit) and shows `updating hc…`, the new version, `hc is up to date` or hc's error. hc has no check or dry-run flag, so contr shows no "newer hc" before the update and uses no release URL of its own. `hc update` replaces the hc program and restarts the core by itself; the terminals keep running (tried on a scratch core). So the result line says the core restarted on the new version; contr has no restart button of its own and never runs the update without a click (docs/spec/SettingsUpdates.md). | Owner in #712. | | D278 | 2026-11-11 | Settings → Setup: a read-only setup checklist (#713, S1). `Setup` is the first Settings tab: `Setup`, `General`, `Agents`, `Updates` (changes the tab list of D186). Package `internal/setup` checks hc (`hc --version`), the hc core (the frame's live state), the contr plugin (`claude plugin list --json`), the Claude meter (data under 24 h old), gh (`gh auth status`), and the optional codex and `contr` command. Each command has a 5 s limit and runs off the UI thread when Settings opens, on Recheck, and on activation while the tab shows; nothing runs while Settings is closed. No check reads files in `~/.claude` or `~/.codex`. A step shows a line with a Copy button; contr runs no command here (S3 adds a confirmed plugin install, D65) and never edits Claude Code's settings (the meter stays copy only, D66). The Setup tab and the sidebar Settings button get the D146 dot while a required check is not ready. Spec: `docs/spec/SettingsSetup.md`. | Owner, issue #713: a new Mac shows what is missing in one place. | +| D280 | 2026-11-11 | The first launch opens Settings → Setup (#713, S2). The setup checks of D278 run once at start, off the UI thread, after the window shows, so the D146 dot shows without opening Settings; then only when Settings opens, on Recheck and on activation while the tab shows; no timer. When the start pass finds a required check not ready (the hc core not counted: it can still be connecting) and `setupSeen` in the settings file is false, Settings opens on the Setup tab once and `setupSeen` is stored. It never opens by itself again. Spec: `docs/spec/SettingsSetup.md`. | Owner, issue #713: a new Mac shows what is missing without a search. | diff --git a/docs/spec/SettingsSetup.md b/docs/spec/SettingsSetup.md index b99e9a79..20ad49b3 100644 --- a/docs/spec/SettingsSetup.md +++ b/docs/spec/SettingsSetup.md @@ -39,8 +39,12 @@ Shared rules: [README.md](README.md). ## Behavior - The checks run off the UI thread, each command with a 5 s limit, when Settings opens, on - `Recheck`, and when the app becomes active while the tab shows. Nothing runs while Settings - is closed. + `Recheck`, and when the app becomes active while the tab shows. They also run once at start, + after the window shows, so the dot shows without opening Settings (D280). Nothing else runs + while Settings is closed, and no timer runs them again. +- First launch (D280): when that start pass finds a required check not ready, the hc core not + counted (it can still be connecting), and `setupSeen` is not set in the settings file, + Settings opens on the `Setup` tab and `setupSeen` is stored. It never opens by itself again. - A command that times out shows `timed out`; output in another format shows `unexpected … output`. - No check reads files in Claude Code's or Codex's folders. contr runs no command for the owner diff --git a/internal/app/appearance.go b/internal/app/appearance.go index 2b08538c..21300ac4 100644 --- a/internal/app/appearance.go +++ b/internal/app/appearance.go @@ -52,6 +52,7 @@ func (f *Frame) StartTheme() { src = p.Source() f.tint = p.Tint f.chatText = prefs.NormalizeChatText(p.ChatText) + f.setupSeen = p.SetupSeen theme.SetTint(true, f.tint.Dark.Window, f.tint.Dark.Panel) theme.SetTint(false, f.tint.Light.Window, f.tint.Light.Panel) } @@ -102,7 +103,7 @@ func (f *Frame) persist(delay time.Duration) { f.saveMu.Lock() defer f.saveMu.Unlock() f.appearanceMu.Lock() - cur := prefs.Prefs{Appearance: f.appearance, Tint: f.tint, ChatText: f.chatText} + cur := prefs.Prefs{Appearance: f.appearance, Tint: f.tint, ChatText: f.chatText, SetupSeen: f.setupSeen} f.appearanceMu.Unlock() if err := save(cur); err != nil && post != nil { post(func() { f.Toasts.Show(err.Error()) }) diff --git a/internal/app/attach.go b/internal/app/attach.go index 899b3347..88b8541d 100644 --- a/internal/app/attach.go +++ b/internal/app/attach.go @@ -228,12 +228,13 @@ func (f *Frame) attachSettings(w Window, env view.Env) (stop func()) { Relaunch: f.deps.Relaunch, OnReady: f.updateReady, Visible: func() bool { return f.Visibility == nil || f.Visibility.Visible() }, AgentsConfig: f.deps.AgentsConfig, Setup: f.deps.Setup, Appearance: f.Appearance, SetAppearance: f.SetAppearance, Tint: f.Tint, SetTint: f.SetTint, ResetTint: f.ResetTint, ChatText: f.ChatText, SetChatText: f.SetChatText, RecordInstall: f.recordInstall, - HCUpdate: settings.RunHCUpdate} + HCUpdate: settings.RunHCUpdate, SetupRun: f.deps.SetupRun} f.loadUpdated(env) f.SpacesList.OnSettings = func() { f.Settings.Toggle() } f.SpacesList.UpdateReady = f.Settings.Ready f.SpacesList.SetupSteps = f.Settings.SetupSteps stopChecks := f.Settings.StartChecks() + f.Settings.FirstSetup(f.firstSetup) offActive := mygo.App.OnDidBecomeActive(func() { w.Update(f.Settings.Activated) }) return func() { stopChecks() @@ -321,3 +322,20 @@ func (f *Frame) attachMenu(w Window, src agentstate.AgentSource) (stop func()) { setHandler(handlerSettings, nil) } } + +// firstSetup runs on the UI thread after the first setup pass. When a required check is not +// ready and the Setup tab never opened by itself, it opens Settings on that tab once and stores +// that (D280). +func (f *Frame) firstSetup(steps int) { + f.appearanceMu.Lock() + seen := f.setupSeen + if steps > 0 && !seen { + f.setupSeen = true + } + f.appearanceMu.Unlock() + if steps == 0 || seen { + return + } + f.Settings.OpenSetup() + f.persist(0) +} diff --git a/internal/app/deps.go b/internal/app/deps.go index 99e30bb4..af1b8631 100644 --- a/internal/app/deps.go +++ b/internal/app/deps.go @@ -74,6 +74,8 @@ type Deps struct { // Setup is what the checks of Settings → Setup read (D278). Nil runs no check: tests leave it // nil, so no test runs a real program. Setup *setup.Env + // SetupRun runs the setup checks; nil runs setup.Run. Tests give a fake. + SetupRun func(context.Context, setup.Env) []setup.Result // ModelOf names the model that an agent profile starts, for line 2 of the agent rows. // Nil shows no model. ModelOf func(profile string) string diff --git a/internal/app/frame.go b/internal/app/frame.go index cc1e70ac..839cc40f 100644 --- a/internal/app/frame.go +++ b/internal/app/frame.go @@ -190,11 +190,13 @@ type Frame struct { saveMu sync.Mutex prefsTimer *time.Timer tint prefs.Tints - chatText string - follower *theme.Follower - lateWin lateWindow - themeErr error - saveTimer stopper + // setupSeen is prefs.SetupSeen: the first launch opened the Setup tab (D280). appearanceMu guards it. + setupSeen bool + chatText string + follower *theme.Follower + lateWin lateWindow + themeErr error + saveTimer stopper // term is the plugin's terminal of the selected agent and conn its hc // connection; both are nil with no terminal. termAddr is the hc terminal // they show; termSet is false before the first sync. termState is the diff --git a/internal/app/settings/settings.go b/internal/app/settings/settings.go index b2abcb6e..35e8b2c0 100644 --- a/internal/app/settings/settings.go +++ b/internal/app/settings/settings.go @@ -163,6 +163,8 @@ type Settings struct { HCUpdate HCRunner // Setup is what the checks of the Setup tab read (D278); nil runs no check. Setup *setup.Env + // SetupRun runs the checks; nil runs setup.Run. Tests give a fake. + SetupRun func(context.Context, setup.Env) []setup.Result // tab is the tab that shows: SettingsTabs[tab]. tab int diff --git a/internal/app/settings/settings_setup.go b/internal/app/settings/settings_setup.go index 388792b3..75fc176f 100644 --- a/internal/app/settings/settings_setup.go +++ b/internal/app/settings/settings_setup.go @@ -48,6 +48,8 @@ type setupState struct { copiedUntil time.Time // write puts text on the clipboard; nil uses the system clipboard. Tests give a fake. write func(string) + // passDone runs on the UI thread once, when the next pass ends (D280). + passDone func() } // SetupSteps is the number of required checks that are not ready, the core included. It is 0 @@ -84,18 +86,48 @@ func (s *Settings) recheck() { if s.Setup == nil { return } - env := *s.Setup + env, run := *s.Setup, s.SetupRun + if run == nil { + run = setup.Run + } s.su.latest.Reset() s.su.latest.Read(s.Env, setupLimit, func(ctx context.Context) func() { - rs := setup.Run(ctx, env) + rs := run(ctx, env) return func() { s.su.results = rs + if done := s.su.passDone; done != nil { + s.su.passDone = nil + done() + } s.Env.Redraw() } }) s.Env.Redraw() } +// FirstSetup runs the checks once at start, off the UI thread, so the D146 dot shows without +// opening Settings (D280). done runs on the UI thread when the pass ends, with the number of +// required checks that are not ready, the hc core not counted: the core can still be connecting. +// Without Setup it does nothing. +func (s *Settings) FirstSetup(done func(steps int)) { + if s.Setup == nil { + return + } + s.su.passDone = func() { done(setup.Required(s.su.results)) } + s.recheck() +} + +// OpenSetup opens the modal on the Setup tab, without a new pass. +func (s *Settings) OpenSetup() { + s.tab = TabSetup + if !s.open { + s.open = true + s.ag = agentsState{} + s.probe() + } + s.Env.Redraw() +} + // copyLine puts a line on the clipboard and makes its button say Copied for a short time. func (s *Settings) copyLine(id, text string) { if s.su.write != nil { diff --git a/internal/app/settings/settings_setup_test.go b/internal/app/settings/settings_setup_test.go index 11eb6705..b78af6f4 100644 --- a/internal/app/settings/settings_setup_test.go +++ b/internal/app/settings/settings_setup_test.go @@ -6,6 +6,7 @@ import ( "testing" "time" + "github.com/contursofran/contr/internal/app/theme" "github.com/contursofran/contr/internal/setup" "github.com/contursofran/contr/internal/usage" ) @@ -141,3 +142,31 @@ func TestSetupCopiedGoesBackToCopy(t *testing.T) { t.Fatalf("still Copied; texts %q", r.texts()) } } + +func TestFirstSetupCountsStepsWithoutTheCoreAndOpensOnSetup(t *testing.T) { + r := newSettingsRig(t, nil) + runs := 0 + r.s.Setup = setupEnv(t, nil, &runs) + r.s.CoreVersion = func() (string, bool) { return "", false } + r.s.SelectTab(TabGeneral) + got := -1 + r.s.FirstSetup(func(n int) { got = n }) + if runs == 0 || got != 1 { + t.Fatalf("runs %d, steps %d; want a pass and 1 step (the meter, not the core)", runs, got) + } + if r.s.Opened() || !r.s.NeedsDot() { + t.Fatal("want Settings closed and the dot after the start pass") + } + before := runs + r.s.OpenSetup() + r.tt.Frame() + r.tt.Advance(theme.MotionPopIn) + if !r.s.Opened() || runs != before || !r.hasText("Required") { + t.Fatalf("OpenSetup: open %v, runs %d then %d, texts %q", r.s.Opened(), before, runs, r.texts()) + } +} + +func TestFirstSetupWithoutEnvDoesNothing(t *testing.T) { + r := newSettingsRig(t, nil) + r.s.FirstSetup(func(int) { t.Fatal("done ran without Setup") }) +} diff --git a/internal/app/setup_first_test.go b/internal/app/setup_first_test.go new file mode 100644 index 00000000..07f48788 --- /dev/null +++ b/internal/app/setup_first_test.go @@ -0,0 +1,83 @@ +package app + +import ( + "context" + "testing" + "time" + + "github.com/contursofran/contr/internal/prefs" + "github.com/contursofran/contr/internal/setup" +) + +// setupLaunch attaches a frame whose stored prefs are stored and whose setup pass gives rs, +// waits for the first pass and returns the frame and the prefs it saved. +func setupLaunch(t *testing.T, stored prefs.Prefs, rs []setup.Result) (*Frame, chan prefs.Prefs) { + t.Helper() + f, _ := themeFrame(t, func() (prefs.Prefs, error) { return stored, nil }) + saved := make(chan prefs.Prefs, 8) + f.deps.Prefs.Save = func(v prefs.Prefs) error { saved <- v; return nil } + ran := make(chan struct{}, 8) + f.deps.Setup = &setup.Env{} + f.deps.SetupRun = func(context.Context, setup.Env) []setup.Result { ran <- struct{}{}; return rs } + f.StartTheme() + win := &fakeWindow{visible: true} + stop := f.Attach(win) + t.Cleanup(stop) + select { + case <-ran: + case <-time.After(5 * time.Second): + t.Fatal("no setup pass at start") + } + for deadline := time.Now().Add(5 * time.Second); time.Now().Before(deadline) && f.Settings.SetupSteps() == 0 && len(rs) > 0; { + win.flush() + time.Sleep(5 * time.Millisecond) + } + win.flush() + return f, saved +} + +var notReady = []setup.Result{{ID: setup.IDHC, Name: "hc", Required: true, State: setup.Missing}} + +func TestFirstLaunchOpensSetupOnceAndStoresIt(t *testing.T) { + f, saved := setupLaunch(t, prefs.Prefs{}, notReady) + if !f.Settings.Opened() { + t.Fatal("Settings did not open on the first launch with a step left") + } + select { + case p := <-saved: + if !p.SetupSeen { + t.Errorf("saved SetupSeen = false, want true") + } + case <-time.After(5 * time.Second): + t.Fatal("SetupSeen not saved") + } + f.Settings.Close() + f.firstSetup(1) + if f.Settings.Opened() { + t.Error("Settings opened a second time") + } +} + +func TestSetupDoesNotOpenWhenSeen(t *testing.T) { + f, _ := setupLaunch(t, prefs.Prefs{SetupSeen: true}, notReady) + if f.Settings.Opened() { + t.Error("Settings opened though SetupSeen is set") + } + if !f.Settings.NeedsDot() { + t.Error("no dot on the Settings entry after the start pass") + } +} + +func TestSetupDoesNotOpenWhenAllReady(t *testing.T) { + f, saved := setupLaunch(t, prefs.Prefs{}, []setup.Result{{ID: setup.IDHC, Required: true, State: setup.Ready}}) + time.Sleep(20 * time.Millisecond) + if f.Settings.Opened() { + t.Error("Settings opened though every check is ready") + } + select { + case p := <-saved: + t.Errorf("saved %+v, want no save", p) + default: + } +} + diff --git a/internal/prefs/prefs.go b/internal/prefs/prefs.go index ab740c77..f63bc44d 100644 --- a/internal/prefs/prefs.go +++ b/internal/prefs/prefs.go @@ -26,6 +26,9 @@ type Prefs struct { // ChatText is the text size of the Chat view (D272): one of ChatTextSizes. Empty or unknown // is ChatDefault. ChatScale reads it. ChatText string `json:"chatText,omitempty"` + // SetupSeen is set once the first launch opened Settings on the Setup tab (D280). Missing + // is false. + SetupSeen bool `json:"setupSeen,omitempty"` } // The chat text sizes (D272). diff --git a/internal/prefs/prefs_test.go b/internal/prefs/prefs_test.go index abe836e8..ca4f445d 100644 --- a/internal/prefs/prefs_test.go +++ b/internal/prefs/prefs_test.go @@ -145,3 +145,22 @@ func TestChatTextMissingOrUnknownIsDefault(t *testing.T) { t.Error("zero Prefs: scale is not 1") } } + +func TestSetupSeenSaveThenLoad(t *testing.T) { + path := filepath.Join(t.TempDir(), "settings.json") + if got, err := Load(path); err != nil || got.SetupSeen { + t.Fatalf("missing file: SetupSeen = %v, %v; want false", got.SetupSeen, err) + } + if err := os.WriteFile(path, []byte(`{"version":1}`), 0o600); err != nil { + t.Fatal(err) + } + if got, err := Load(path); err != nil || got.SetupSeen { + t.Fatalf("missing field: SetupSeen = %v, %v; want false", got.SetupSeen, err) + } + if err := Save(path, Prefs{SetupSeen: true}); err != nil { + t.Fatal(err) + } + if got, err := Load(path); err != nil || !got.SetupSeen { + t.Fatalf("SetupSeen = %v, %v; want true", got.SetupSeen, err) + } +} From c03ca5785325d74e7a718efd98ceca0c0b6f3718 Mon Sep 17 00:00:00 2001 From: Franco Conturso Date: Wed, 11 Nov 2026 14:55:13 -0300 Subject: [PATCH 2/2] Format setup_first_test.go --- internal/app/setup_first_test.go | 1 - 1 file changed, 1 deletion(-) diff --git a/internal/app/setup_first_test.go b/internal/app/setup_first_test.go index 07f48788..6d485485 100644 --- a/internal/app/setup_first_test.go +++ b/internal/app/setup_first_test.go @@ -80,4 +80,3 @@ func TestSetupDoesNotOpenWhenAllReady(t *testing.T) { default: } } -