From edf354a03c84df00d3657daefd6be3b7e22f7690 Mon Sep 17 00:00:00 2001 From: borjaperfra Date: Mon, 14 Sep 2026 15:38:13 +0200 Subject: [PATCH] feat(tui): ask for the account, then the key, then point at the tools Three keys a member had to know to press - `s`, then `e`, then `c` - with nothing leading from one to the next. Home listed them, which is a list of things to look up, not a sequence. The panel asks for the first thing that is missing when it opens, and again after each step finishes. A fresh machine is asked for an account, then for a key, and is left on the tab that configures the tools with the number found and the key to press. That last step is named and not taken. Writing into someone's opencode or their codex is a side effect that has to be asked for, so the chain stops one key short on purpose. It is a nudge and not a gate: esc leaves any of them, and someone who opened the panel to look at their usage is not held hostage by a setup they did not ask for. The Setup tab has always worked with no session at all and that stays true. Init takes a value receiver and returns only a Cmd, so anything it changes about the model is discarded - the question is asked as a message and answered in Update, where the state lives. A test covers that the message is still sent, because if it stops arriving nothing happens on a fresh machine and the whole sequence is silently gone. Co-Authored-By: Claude Opus 5 (1M context) --- internal/tui/config_test.go | 97 +++++++++++++++++++++++++++++++++++++ internal/tui/tui.go | 79 +++++++++++++++++++++++++----- scripts/install.ps1 | 2 +- scripts/install.sh | 2 +- 4 files changed, 166 insertions(+), 14 deletions(-) diff --git a/internal/tui/config_test.go b/internal/tui/config_test.go index d911583..7290ef0 100644 --- a/internal/tui/config_test.go +++ b/internal/tui/config_test.go @@ -1400,3 +1400,100 @@ func TestTheMascotGivesWayOnAShortTerminal(t *testing.T) { t.Error("the mascot never appears, even with room for it") } } + +// ── the first run, as one sequence ─────────────────────────────────────────── + +// Three keys a member had to know to press - s, then e, then c - with nothing +// leading from one to the next. The panel asks for each in turn now. +func TestAFreshMachineIsAskedToSignInImmediately(t *testing.T) { + m := setupModel(t, &session.Session{}) + + if cmd := m.resumeSetup(); cmd == nil { + t.Fatal("nothing happens when the panel opens with no session") + } + if m.loginStage != loginAskEmail { + t.Errorf("the panel opens on stage %v, want the email question", m.loginStage) + } +} + +// Signed in already, but no key: that is the next thing missing, so that is +// what it asks for. +func TestASignedInMachineIsAskedForTheKey(t *testing.T) { + m := setupModel(t, &session.Session{Token: "t"}) + m.lay = newLayout(90, 30) + + m.resumeSetup() + if m.loginStage != loginOff { + t.Error("it asks for an account that is already there") + } + if !m.editingKey { + t.Error("the key field is not open") + } + if m.activeID() != tabSetup { + t.Error("the key field is open on a tab that does not show it") + } +} + +// And a member who has everything is left alone. This is the one that would +// annoy people most if it regressed. +func TestAConfiguredMachineIsNotAskedForAnything(t *testing.T) { + m := setupModel(t, &session.Session{Token: "t", APIKey: testKey}) + + if cmd := m.resumeSetup(); cmd != nil { + t.Error("a configured member is asked to set something up again") + } + if m.loginStage != loginOff || m.editingKey { + t.Error("the panel opens into a setup step that is already done") + } +} + +// Init cannot mutate the model - value receiver, returns only a Cmd - so it +// asks Update instead. If that message ever stops arriving, nothing on a fresh +// machine happens at all and the whole sequence is silently gone. +func TestTheFirstRunQuestionIsActuallyAsked(t *testing.T) { + m := setupModel(t, &session.Session{}) + + cmd := m.Init() + if cmd == nil { + t.Fatal("Init does nothing") + } + updated, _ := m.Update(firstRunMsg{}) + if updated.(model).loginStage != loginAskEmail { + t.Error("the first-run message does not start the sign-in") + } +} + +// Signing in leads to the key without the member pressing anything. +func TestSigningInLeadsStraightToTheKey(t *testing.T) { + home := t.TempDir() + t.Setenv("HOME", home) + t.Setenv("USERPROFILE", home) + t.Setenv("HERMES_HOME", filepath.Join(home, "h")) + + m := newModel(api.New(""), &session.Session{}) + m.lay = newLayout(90, 30) + if err := session.Save(&session.Session{Token: "a-token"}); err != nil { + t.Fatal(err) + } + + updated, _ := m.Update(signedInMsg{nil}) + after := updated.(model) + if !after.editingKey { + t.Error("after signing in the member is left to work out that `e` is next") + } +} + +// And the key leads to the tools - named, not pressed: writing into someone's +// opencode is a side effect that has to be asked for. +func TestTheAcceptedKeyPointsAtTheToolsStep(t *testing.T) { + m := setupModel(t, &session.Session{Token: "t", APIKey: testKey}) + + updated, _ := m.Update(keyCheckedMsg{models: 7}) + got := updated.(model).keyCheck + if !strings.Contains(got, "key accepted") { + t.Errorf("the check says %q", got) + } + if installedTools() > 0 && !strings.Contains(got, "press c") { + t.Errorf("the check says %q, which does not lead anywhere", got) + } +} diff --git a/internal/tui/tui.go b/internal/tui/tui.go index fd4ac05..66ef341 100644 --- a/internal/tui/tui.go +++ b/internal/tui/tui.go @@ -133,6 +133,9 @@ type configuredMsg struct { written []string } +// Sent once, by Init, so the panel can pick up wherever the setup was left. +type firstRunMsg struct{} + type linkSentMsg struct{ err error } type signedInMsg struct{ err error } @@ -208,8 +211,10 @@ func newModel(client *api.Client, sess *session.Session) model { } func (m model) Init() tea.Cmd { - m.loading = true - return tea.Batch(m.spin.Tick, m.fetchTab(tabDefs[0].id)) + // Init takes a value receiver and returns only a Cmd, so anything it + // changes about the model is thrown away. The first-run question is asked + // as a message instead, and answered in Update where the state lives. + return tea.Batch(m.spin.Tick, func() tea.Msg { return firstRunMsg{} }) } func (m model) activeID() tabID { return tabDefs[m.active].id } @@ -252,6 +257,9 @@ func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { m.setupMsg = msg.msg m.configured = msg.written + case firstRunMsg: + return m, m.resumeSetup() + case linkSentMsg: m.loginBusy = false if msg.err != nil { @@ -288,7 +296,9 @@ func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { m.cache = make(map[tabID]any) m.err = nil m.keyAsked = false - return m, m.maybeLoad() + // And straight on to whatever is still missing, which on a fresh + // machine is the key. + return m, tea.Batch(m.maybeLoad(), m.resumeSetup()) case keyStatusMsg: m.keyStatus = msg.status @@ -300,6 +310,14 @@ func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { m.keyCheck = "error: the cluster refused this key — " + msg.err.Error() } else { m.keyCheck = fmt.Sprintf("key accepted by the cluster · %d models", msg.models) + // The last link in the chain. Signing in leads to the key, and the + // key leads here - to the tools, which is the step the panel + // cannot take for someone: writing into their opencode or their + // codex is a side effect that has to be asked for, so this names + // the key to press rather than pressing it. + if n := installedTools(); n > 0 { + m.keyCheck += fmt.Sprintf(" · press c to configure the %d tools found", n) + } } case tea.KeyMsg: @@ -452,14 +470,7 @@ func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { // which is the tab you have to already be on to know that - and // Home tells everyone to press `e` from Home. if !m.showHelp && m.loginStage == loginOff { - if m.activeID() != tabSetup { - m.active = tabIndex(tabSetup) - m.scrollY = 0 - } - m.editingKey = true - m.keyInput.SetValue("") - m.keyInput.Focus() - m.setupMsg = "" + return m, m.startKeyEdit() } case "c": if !m.showHelp && m.activeID() == tabSetup && !m.configuring { @@ -543,6 +554,39 @@ func (m *model) startLogin() tea.Cmd { return m.loginInput.Focus() } +// startKeyEdit opens the API key field, moving to the tab that shows it. +func (m *model) startKeyEdit() tea.Cmd { + if m.activeID() != tabSetup { + m.active = tabIndex(tabSetup) + m.scrollY = 0 + } + m.editingKey = true + m.keyInput.SetValue("") + m.setupMsg = "" + return m.keyInput.Focus() +} + +// resumeSetup asks for the first thing that is missing, and for nothing when +// nothing is. +// +// It runs when the panel opens and again after each step finishes, which is +// what turns three separate keys into one sequence: a fresh machine is asked +// for an account, then for a key, then left on the tab that configures the +// tools. Before this, all three were things the member had to know to press. +// +// It is a nudge and not a gate. Esc leaves any of them, and a member who came +// to look at their usage is not held hostage by a setup they did not ask for - +// the Setup tab has always worked with no session at all, and that stays true. +func (m *model) resumeSetup() tea.Cmd { + switch { + case m.sess.Token == "": + return m.startLogin() + case m.sess.APIKey == "": + return m.startKeyEdit() + } + return nil +} + func (m *model) cancelLogin() { m.loginStage = loginOff m.loginBusy = false @@ -1579,6 +1623,17 @@ func (m model) toolEnabled(name string) bool { // Hermes in the list that is four processes, several seconds, with no repaint // and no key accepted. Reported as "se ha quedado paralizado y no sabia que // pasaba", which is the only thing it could look like. +// How many of the tools it knows about are on this machine. +func installedTools() int { + n := 0 + for _, t := range detectTools() { + if t.installed { + n++ + } + } + return n +} + func configureTools(apiKey string, enabledTools map[string]bool) (string, []string) { isEnabled := func(name string) bool { if enabledTools == nil { @@ -2449,7 +2504,7 @@ func (m model) renderSetup(l layout) string { // ── about renderer ─────────────────────────────────────────────────────────── -const Version = "0.1.14" +const Version = "0.1.15" func renderAbout(l layout) string { var b strings.Builder diff --git a/scripts/install.ps1 b/scripts/install.ps1 index 6eb809c..23e7416 100644 --- a/scripts/install.ps1 +++ b/scripts/install.ps1 @@ -118,7 +118,7 @@ function Get-LatestVersion { could not work out the latest version from the GitHub API it rate limits unauthenticated requests, so this is usually temporary wait a few minutes, or pick a version yourself: - & ([scriptblock]::Create((irm https://nan.builders/install.ps1))) -Version v0.1.14 + & ([scriptblock]::Create((irm https://nan.builders/install.ps1))) -Version v0.1.15 the releases are at https://github.com/$Repo/releases "@ } diff --git a/scripts/install.sh b/scripts/install.sh index d2b0228..4d4355d 100755 --- a/scripts/install.sh +++ b/scripts/install.sh @@ -76,7 +76,7 @@ require_version() { err "could not work out the latest version from the GitHub API" err "it rate limits unauthenticated requests, so this is usually temporary" err "wait a few minutes, or pick a version yourself:" - printf " VERSION=v0.1.14 curl -fsSL https://nan.builders/install | bash + printf " VERSION=v0.1.15 curl -fsSL https://nan.builders/install | bash " >&2 err "the releases are at https://github.com/$REPO/releases" exit 1