Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 7 additions & 1 deletion cmd/contr/e2e_harness_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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 (
Expand Down Expand Up @@ -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
Expand All @@ -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)
Expand Down
30 changes: 30 additions & 0 deletions cmd/contr/e2e_setup_test.go
Original file line number Diff line number Diff line change
@@ -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")
}
}
1 change: 1 addition & 0 deletions docs/DECISIONS.md
Original file line number Diff line number Diff line change
Expand Up @@ -253,4 +253,5 @@ 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. |
| D282 | 2026-11-11 | A Claude worker gets the PATH of the process that spawns it (#728). `hc update` restarts the core under launchd, whose PATH is short, and each terminal inherits the PATH of the core, so `claude` was not found and the worker ended at once. Spawn now sets `CONTR_PATH` in the terminal: the PATH of the spawning process, then `~/.local/bin`, `/opt/homebrew/bin` and `/usr/local/bin`, without doubles. The clean wrapper (D216) adds these folders after the PATH of the core and removes the variable, so the PATH of the core still comes first. A program that does not start through the clean wrapper (not Claude) gets no change. | Seen on 2026-11-11 after the first `Update hc` (D277): two spawns ended with "claude: not found". |
8 changes: 6 additions & 2 deletions docs/spec/SettingsSetup.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
3 changes: 2 additions & 1 deletion internal/app/appearance.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)
}
Expand Down Expand Up @@ -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()) })
Expand Down
20 changes: 19 additions & 1 deletion internal/app/attach.go
Original file line number Diff line number Diff line change
Expand Up @@ -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()
Expand Down Expand Up @@ -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)
}
2 changes: 2 additions & 0 deletions internal/app/deps.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
12 changes: 7 additions & 5 deletions internal/app/frame.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
2 changes: 2 additions & 0 deletions internal/app/settings/settings.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
36 changes: 34 additions & 2 deletions internal/app/settings/settings_setup.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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 {
Expand Down
29 changes: 29 additions & 0 deletions internal/app/settings/settings_setup_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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"
)
Expand Down Expand Up @@ -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") })
}
82 changes: 82 additions & 0 deletions internal/app/setup_first_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,82 @@
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:
}
}
3 changes: 3 additions & 0 deletions internal/prefs/prefs.go
Original file line number Diff line number Diff line change
Expand Up @@ -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).
Expand Down
19 changes: 19 additions & 0 deletions internal/prefs/prefs_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)
}
}
Loading