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
1 change: 1 addition & 0 deletions docs/DECISIONS.md
Original file line number Diff line number Diff line change
Expand Up @@ -255,3 +255,4 @@ replaces it.
| 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". |
| D283 | 2026-11-11 | The contr plugin row of Settings → Setup gets one action (#713, S3; owner decision D65). `Install` when the plugin is not installed, `Enable` when it is disabled. A click opens a confirm with the exact command, the same text as the copy line; only `Run` runs it, off the UI thread, with a 2 min limit, and only these two lines with the `claude` the checks find. A spinner shows while it runs; a failure shows the last stderr line under the row; a success runs a recheck. The copy line stays. No other row gets an action (meter D66, gh and hc copy only); contr starts no hc core here and edits no file in Claude Code's folders. | Before, the owner had to copy the line to a terminal. The confirm keeps the run explicit. |
14 changes: 12 additions & 2 deletions docs/spec/SettingsSetup.md
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,10 @@ Shared rules: [README.md](README.md).
4. A row with a step has a copy line under it, in line with the name: the command in mono
(`ink-88`) on `hover`, 1px `input-line`, card radius, and `Copy` (plain button) at the
right. After a click it says `Copied` for 1.5 s.
The `contr plugin` row also has an action (outlined button) right of `Copy` (D283):
`Install` when the plugin is not installed, `Enable` when it is disabled. While its command
runs, a 14px spinner takes its place. After a failure, the last stderr line shows under the
copy line, in line with the name (`blocked`, one line with an ellipsis).
5. Foot, `hairline` above: `Done` (plain button).

| Glyph | State |
Expand Down Expand Up @@ -47,7 +51,13 @@ Shared rules: [README.md](README.md).
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
here and does not edit Claude Code's settings.
- No check reads files in Claude Code's or Codex's folders. contr does not edit Claude Code's
settings or files.
- Plugin action (D283): a click on `Install` or `Enable` opens a confirm, `Install the contr
plugin?` or `Enable the contr plugin?`, with `contr runs this command:` over the exact copy
line. `Cancel` and Escape run nothing. `Run` runs the line with the `claude` that the checks
find, off the UI thread, with a 2 min limit (`no answer after 2m0s` when it ends). On success a
recheck runs. No other row has an action: the meter (D66), gh and hc stay copy only, and
contr starts or stops no hc core here.
- While a required check is not ready, the `Setup` tab and the Settings button of the sidebar
have the 6px `ink-100` dot (D146); the button is named `Settings, setup steps left`.
5 changes: 5 additions & 0 deletions internal/app/settings/settings.go
Original file line number Diff line number Diff line change
Expand Up @@ -165,12 +165,16 @@ type Settings struct {
Setup *setup.Env
// SetupRun runs the checks; nil runs setup.Run. Tests give a fake.
SetupRun func(context.Context, setup.Env) []setup.Result
// PluginRun runs the plugin line of the Setup tab (D283); nil runs setup.RunPlugin. Tests give
// a fake.
PluginRun func(ctx context.Context, e setup.Env, line string) error

// tab is the tab that shows: SettingsTabs[tab].
tab int
ag agentsState
hc hcState
su setupState
pl pluginState

// tintWin and tintPan are the values the sliders edit; the Seen ones are what the last build showed.
tintWin, tintPan, tintSeenWin, tintSeenPan float64
Expand Down Expand Up @@ -602,6 +606,7 @@ func (s *Settings) View(c *ui.Context) {
panel.Children(func() { s.body(c) })
})
s.hcConfirm(c)
s.pluginConfirm(c)
if d.Dismissed() || !open {
s.open = false
s.Env.Redraw()
Expand Down
120 changes: 120 additions & 0 deletions internal/app/settings/settings_plugin.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,120 @@
package settings

// The one action of the Setup tab (D283): the contr plugin row gets Install or Enable. A click
// opens a confirm with the exact command; only its Run button runs it, off the UI thread, with
// a time limit. On success a recheck runs; on failure the row shows the last stderr line.

import (
"context"
"errors"
"time"

"github.com/egoist/mygo/ui"

"github.com/contursofran/contr/internal/app/kit"
"github.com/contursofran/contr/internal/setup"
)

// The texts of the plugin action and its confirm.
const (
SetupInstall = "Install"
SetupEnable = "Enable"
TextPluginConfirmBody = "contr runs this command:"
LabelPluginCancel = "Cancel"
LabelPluginRun = "Run"
// LabelPluginBusy names the spinner while the command runs.
LabelPluginBusy = "plugin command running"
)

// PluginTimeout is the time limit of the plugin command. Tests make it short.
var PluginTimeout = 2 * time.Minute

// pluginState is the state of the plugin action.
type pluginState struct {
// confirm is the command the open confirm shows; empty while it is closed.
confirm string
busy bool
err string
}

// pluginAction is the button of a row: Install or Enable on the plugin row with a step, else "".
func pluginAction(r setup.Result) string {
if r.ID != setup.IDPlugin {
return ""
}
switch r.Copy {
case setup.CopyPlugin:
return SetupInstall
case setup.CopyEnable:
return SetupEnable
}
return ""
}

// pluginConfirm builds the confirm while it is open. Cancel and Escape run nothing.
func (s *Settings) pluginConfirm(c *ui.Context) {
line := s.pl.confirm
if line == "" {
return
}
open := true
choice := ui.AlertDialog(c, &open, pluginTitle(line), TextPluginConfirmBody+"\n"+line, LabelPluginCancel, LabelPluginRun)
switch {
case choice == 1:
s.pl.confirm = ""
s.runPlugin(line)
case choice == 0 || choice == ui.AlertDismissed || !open:
s.pl.confirm = ""
s.Env.Redraw()
}
}

// pluginTitle is "Install the contr plugin?" or "Enable the contr plugin?".
func pluginTitle(line string) string {
if line == setup.CopyEnable {
return "Enable the contr plugin?"
}
return "Install the contr plugin?"
}

// runPlugin runs the line off the UI thread; on success it runs a recheck.
func (s *Settings) runPlugin(line string) {
if s.Setup == nil || s.pl.busy {
return
}
env, run := *s.Setup, s.PluginRun
if run == nil {
run = setup.RunPlugin
}
s.pl = pluginState{busy: true}
s.Env.Redraw()
s.Env.Do(func() func() {
ctx, cancel := context.WithTimeout(context.Background(), PluginTimeout)
defer cancel()
err := run(ctx, env, line)
if ctx.Err() == context.DeadlineExceeded {
err = errors.New("no answer after " + PluginTimeout.String())
}
return func() {
s.pl = pluginState{}
if err != nil {
s.pl.err = setup.LastLine(err.Error())
s.Env.Redraw()
return
}
s.recheck()
}
})
}

// pluginButton is the action at the right of the copy line: a spinner while the command runs.
func (s *Settings) pluginButton(c *ui.Context, label, line string) {
if s.pl.busy {
ui.Spinner(c).Label(LabelPluginBusy).Size(14, 14)
return
}
SettingsButton(c, label, kit.ButtonOutlined, func() {
s.pl.confirm = line
s.Env.Redraw()
})
}
153 changes: 153 additions & 0 deletions internal/app/settings/settings_plugin_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,153 @@
package settings

import (
"context"
"errors"
"slices"
"testing"
"time"

"github.com/egoist/mygo/ui"

"github.com/contursofran/contr/internal/setup"
)

// pluginEnv is setupEnv with the contr plugin in the list given as JSON.
func pluginEnv(t *testing.T, list string, runs *int) *setup.Env {
env := setupEnv(t, nil, runs)
run := env.Run
env.Run = func(ctx context.Context, name string, args ...string) ([]byte, error) {
if name == "claude" {
*runs++
return []byte(list), nil
}
return run(ctx, name, args...)
}
return env
}

const (
listMissing = `[]`
listDisabled = `[{"id":"contr@contr","version":"0.1.0","enabled":false}]`
)

// pluginRig opens the Setup tab with the plugin in list and PluginRun set to run.
func pluginRig(t *testing.T, list string, runs *int, run func(context.Context, setup.Env, string) error) *settingsRig {
t.Helper()
r := newSettingsRig(t, nil)
r.s.PluginRun = run
r.openSetup(pluginEnv(t, list, runs), true)
return r
}

// clickPlugin clicks the action, checks the confirm shows line, and clicks button.
func clickPlugin(t *testing.T, r *settingsRig, action, line, button string) {
t.Helper()
if err := r.tt.Click(action); err != nil {
t.Fatal(err)
}
r.tt.Frame()
if !r.tt.HasText(TextPluginConfirmBody + "\n" + line) {
t.Fatalf("no confirm with %q: texts %q", line, r.texts())
}
if button == "" {
return
}
if err := r.tt.Click(button); err != nil {
t.Fatal(err)
}
r.tt.Frame()
}

func TestPluginEnableRunsTheLineAndRechecks(t *testing.T) {
checks, ran := 0, []string{}
r := pluginRig(t, listDisabled, &checks, func(_ context.Context, _ setup.Env, line string) error {
ran = append(ran, line)
return nil
})
before := checks
clickPlugin(t, r, SetupEnable, setup.CopyEnable, LabelPluginRun)
if !slices.Equal(ran, []string{setup.CopyEnable}) || checks <= before {
t.Fatalf("ran %q, checks %d then %d", ran, before, checks)
}
}

func TestPluginInstallShowsTheLastErrorLine(t *testing.T) {
checks, ran := 0, []string{}
r := pluginRig(t, listMissing, &checks, func(_ context.Context, _ setup.Env, line string) error {
ran = append(ran, line)
return errors.New("first\nmarketplace contr not found")
})
before := checks
clickPlugin(t, r, SetupInstall, setup.CopyPlugin, LabelPluginRun)
if !slices.Equal(ran, []string{setup.CopyPlugin}) || checks != before ||
!r.hasText("marketplace contr not found") || !r.has(SetupCopy) {
t.Fatalf("ran %q, checks %d then %d, texts %q", ran, before, checks, r.texts())
}
}

func TestPluginTimeout(t *testing.T) {
old := PluginTimeout
PluginTimeout = 20 * time.Millisecond
t.Cleanup(func() { PluginTimeout = old })
checks := 0
r := pluginRig(t, listMissing, &checks, func(ctx context.Context, _ setup.Env, _ string) error {
<-ctx.Done() // the fake hangs until the time limit
return ctx.Err()
})
clickPlugin(t, r, SetupInstall, setup.CopyPlugin, LabelPluginRun)
if !r.hasText("no answer after " + PluginTimeout.String()) {
t.Fatalf("texts %q", r.texts())
}
}

func TestPluginCancelAndEscapeRunNothing(t *testing.T) {
checks, ran := 0, 0
r := pluginRig(t, listDisabled, &checks, func(context.Context, setup.Env, string) error { ran++; return nil })
clickPlugin(t, r, SetupEnable, setup.CopyEnable, LabelPluginCancel)
clickPlugin(t, r, SetupEnable, setup.CopyEnable, "")
r.tt.Key(0, ui.KeyEscape)
r.tt.Frame()
if ran != 0 || r.s.pl.confirm != "" || r.s.pl.busy {
t.Fatalf("ran %d, state %+v", ran, r.s.pl)
}
}

func TestPluginSpinnerWhileRunning(t *testing.T) {
checks := 0
r := pluginRig(t, listDisabled, &checks, func(context.Context, setup.Env, string) error { return nil })
var held func()
r.s.Env.Run.Go = func(fn func()) { held = fn }
clickPlugin(t, r, SetupEnable, setup.CopyEnable, LabelPluginRun)
if !r.has(LabelPluginBusy) || r.has(SetupEnable) {
t.Fatalf("no spinner: texts %q", r.texts())
}
r.s.Env.Run.Go = nil
held()
r.tt.Frame()
if r.has(LabelPluginBusy) {
t.Fatal("spinner stays after the run")
}
}

func TestPluginNoButtonOnReadyAndCopyOnlyRows(t *testing.T) {
checks := 0
r := newSettingsRig(t, nil)
r.openSetup(setupEnv(t, map[string]bool{"gh": true, "codex": true}, &checks), true)
for _, l := range []string{SetupInstall, SetupEnable} {
if r.has(l) {
t.Fatalf("%s shows with the plugin ready: texts %q", l, r.texts())
}
}
if !r.has(SetupCopy) {
t.Fatal("copy-only rows lost Copy")
}
for _, res := range []setup.Result{
{ID: setup.IDMeter, Copy: setup.CopyMeter}, {ID: setup.IDGH, Copy: setup.CopyGHLogin},
{ID: setup.IDHC, Copy: setup.CopyHC}, {ID: setup.IDPlugin},
} {
if a := pluginAction(res); a != "" {
t.Fatalf("%s gets %q", res.ID, a)
}
}
}
10 changes: 8 additions & 2 deletions internal/app/settings/settings_setup.go
Original file line number Diff line number Diff line change
Expand Up @@ -2,8 +2,8 @@ package settings

// The Setup tab of Settings (D278): a read-only checklist of what contr needs. The checks run off
// the UI thread when Settings opens, when the app becomes active while the tab shows, and on
// Recheck; never while Settings is closed. Rows with a step show a line to copy: contr runs no
// command for the owner here.
// Recheck; never while Settings is closed. Rows with a step show a line to copy. The contr plugin
// row also gets Install or Enable (D283, settings_plugin.go); no other row runs a command.

import (
"context"
Expand Down Expand Up @@ -226,7 +226,13 @@ func (s *Settings) setupRow(c *ui.Context, r setup.Result) {
}
id, text := r.ID, r.Copy
SettingsButton(c, label, kit.ButtonPlain, func() { s.copyLine(id, text) })
if a := pluginAction(r); a != "" {
s.pluginButton(c, a, r.Copy)
}
})
if r.ID == setup.IDPlugin && s.pl.err != "" {
ui.Text(c, s.pl.err).Margin(0, 0, 0, 22).SingleLine().Ellipsis("…").MinWidth(0).TextColor(p.Blocked)
}
})
}

Expand Down
13 changes: 11 additions & 2 deletions internal/app/settings/sheet_test.go
Original file line number Diff line number Diff line change
@@ -1,7 +1,8 @@
package settings

// The SettingsSetup part of the spec sheet (D278): the Setup tab with each row state. It has no
// spec image yet: the spec is docs/spec/SettingsSetup.md.
// spec image yet: the spec is docs/spec/SettingsSetup.md. The SettingsSetupPlugin part is the
// contr plugin row after a failed Install, with the confirm of Install open (D283).
//
// The SettingsUpdates part of the spec sheet (D179, D277): the Updates tab after an hc update
// that restarted the core, then the tab while an hc update runs, then the confirm of "Update hc"
Expand Down Expand Up @@ -53,8 +54,16 @@ func TestSpecSheet(t *testing.T) {
{ID: setup.IDCodex, Name: "codex", State: setup.Optional, Detail: "not installed", Copy: setup.CopyCodex},
{ID: setup.IDContr, Name: "contr command", Detail: "linked"},
}
// The plugin row after a failed Install, with the confirm of the next click open (D283).
pl := &Settings{Setup: &setup.Env{}, CoreVersion: s.CoreVersion,
pl: pluginState{err: "marketplace contr not found", confirm: setup.CopyPlugin}}
pl.su.results = []setup.Result{
{ID: setup.IDHC, Name: "hc", Required: true, Detail: "hc 0.1.18"},
{ID: setup.IDPlugin, Name: "contr plugin", Required: true, State: setup.Missing, Detail: "not installed", Copy: setup.CopyPlugin},
}
apptest.SpecSheet(t, []apptest.SheetPart{
{Name: "SettingsSetupPlugin", W: int(theme.ModalWidth), H: 360, View: func(c *ui.Context) { pl.setupTab(c); pl.pluginConfirm(c) }},
{Name: "SettingsSetup", W: int(theme.ModalWidth), H: 620, View: func(c *ui.Context) { s.setupTab(c) }},
{Name: "SettingsUpdates", W: sheetUpdatesW, H: sheetUpdatesH, View: sheetUpdates},
}, []string{"SettingsSetup", "SettingsUpdates"})
}, []string{"SettingsSetup", "SettingsSetupPlugin", "SettingsUpdates"})
}
Loading
Loading