diff --git a/internal/tui/config_test.go b/internal/tui/config_test.go index 973a857..4f081a5 100644 --- a/internal/tui/config_test.go +++ b/internal/tui/config_test.go @@ -2442,3 +2442,34 @@ func TestAFailedToolDoesNotStrandTheSetup(t *testing.T) { t.Error("the screen does not say what can be done about it") } } + +// Step 3 on a short terminal. The banner went on top and View cut what did not +// fit from the bottom, so the part that went was the field for the key. +// Reported as: "no hay espacio para ponerla en el setup". +func TestTheKeyFieldFitsOnAShortTerminal(t *testing.T) { + for _, size := range [][2]int{{80, 14}, {80, 18}, {120, 22}, {120, 30}} { + m := setupModel(t, &session.Session{Token: "t"}) + m.lay = newLayout(size[0], size[1]) + m.wizard = wizardLink + m.startKeyEdit() + if !strings.Contains(m.View(), "paste your NaN API key here") { + t.Errorf("at %dx%d step 3 has no field to paste the key into", size[0], size[1]) + } + } +} + +// And a key the cluster refuses leaves the field open for another one, instead +// of the error and nothing under it. +func TestARefusedKeyOpensTheFieldAgain(t *testing.T) { + m := setupModel(t, &session.Session{Token: "t", APIKey: testKey}) + m.wizard = wizardKey + + updated, _ := m.Update(keyCheckedMsg{err: errors.New("401")}) + after := updated.(model) + if !after.editingKey { + t.Error("after a refused key step 3 has nowhere to paste another") + } + if !strings.Contains(after.keyCheck, "refused") { + t.Errorf("the refusal is gone: %q", after.keyCheck) + } +} diff --git a/internal/tui/tui.go b/internal/tui/tui.go index 32f1231..b9509e7 100644 --- a/internal/tui/tui.go +++ b/internal/tui/tui.go @@ -437,6 +437,12 @@ func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { // than five times later as a 401 inside five different tools. if msg.err != nil { m.keyCheck = "error: the cluster refused this key — " + msg.err.Error() + // During the guided setup the field closed on enter and stayed + // closed, so a refused key left step 3 with the error and nothing to + // paste a better one into. It opens again, empty, under the error. + if m.wizard == wizardKey && !m.editingKey { + return m, m.startKeyEdit() + } } 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 @@ -500,7 +506,7 @@ func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { m.setupMsg = "error saving: " + err.Error() } else { m.setupMsg = "API key saved" - m.keyCheck = "checking it against the cluster…" + m.keyCheck = keyChecking check = checkKey(val) } } @@ -546,6 +552,11 @@ func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { return m, tea.Quit } case "enter": + // Step 3 with its field closed - the key is still being checked, or + // something closed it - says "enter to continue", so enter opens it. + if m.wizard == wizardKey && m.keyCheck != keyChecking { + return m, m.startKeyEdit() + } if m.wizard == wizardDone { m.wizard = wizardOff m.active = tabIndex(tabHome) @@ -752,6 +763,9 @@ func (m *model) startLogin() tea.Cmd { return m.loginInput.Focus() } +// What step 3 says while the cluster is looking at a key just saved. +const keyChecking = "checking it against the cluster…" + // startKeyEdit opens the API key field, moving to the tab that shows it. func (m *model) startKeyEdit() tea.Cmd { if m.wizard != wizardOff { @@ -3231,11 +3245,6 @@ func (m model) renderWizard(l layout) string { now := lipgloss.NewStyle().Foreground(cCyan).Bold(true) var b strings.Builder - if l.w >= BannerWidthPlain+4 { - b.WriteString(Banner(l.indent, m.wizard.mood(), - l.w >= BannerWidth+4 && l.h >= BannerRoom) + "\n") - } - b.WriteString(l.indent + title.Render("Setting up") + "\n\n") at := m.wizard.index() @@ -3283,7 +3292,26 @@ func (m model) renderWizard(l layout) string { } b.WriteString(m.renderFailures(l)) } - return b.String() + + // The banner goes on top only if it leaves room for the rest. It used to go + // first unconditionally, and View cuts what does not fit from the bottom - + // so on a short terminal the part that went was the field for the key, and + // step 3 asked for one with nowhere to type it. The mascot goes first, then + // the wordmark; the step you are on never does. + body := b.String() + room := l.h - 4 - strings.Count(body, "\n") + if l.w >= BannerWidthPlain+4 { + withMascot := l.w >= BannerWidth+4 && l.h >= BannerRoom + banner := Banner(l.indent, m.wizard.mood(), withMascot) + "\n" + if withMascot && strings.Count(banner, "\n") > room { + withMascot = false + banner = Banner(l.indent, m.wizard.mood(), false) + "\n" + } + if strings.Count(banner, "\n") <= room { + body = banner + body + } + } + return body } // What would not configure, and what to do about it.