diff --git a/internal/tui/banner.go b/internal/tui/banner.go index 21e2134..5fd7a3d 100644 --- a/internal/tui/banner.go +++ b/internal/tui/banner.go @@ -1,6 +1,7 @@ package tui import ( + "fmt" "strings" "github.com/charmbracelet/lipgloss" @@ -170,7 +171,69 @@ func WelcomeStacked(indent string) string { // what you were running. The keys are along the bottom of every tab too, but a // one-line footer is where you look when you already know what you are doing, // not when you have just arrived. -func renderHome(l layout) string { +// What a member has to do before the panel is any use to them, in the order +// they have to do it. +// +// The first run of this on a fresh machine landed on Home, which explained +// the arrow keys, and then answered every data tab with `unauthorized`. +// Nothing anywhere said "log in", and the command that does it is a +// subcommand you have to already know about. So Home leads with the step +// that is missing, and stops mentioning it once it is done. +func renderNextStep(l layout, loggedIn, hasKey bool) string { + if loggedIn && hasKey { + return "" + } + + section := lipgloss.NewStyle().Foreground(cGray).Bold(true) + num := lipgloss.NewStyle().Foreground(cCyan).Bold(true) + cmd := lipgloss.NewStyle().Foreground(cWhite).Bold(true) + desc := lipgloss.NewStyle().Foreground(cGray) + done := lipgloss.NewStyle().Foreground(cDimGray) + + var b strings.Builder + b.WriteString(l.indent + section.Render("Start here") + "\n\n") + + steps := []struct { + what, why string + done bool + }{ + // Quitting comes first because it is not a key inside the panel: the + // next step is a command in the shell, and you cannot run one from here. + {"q", "quit, so you have your shell back", loggedIn}, + {"nan auth login", "sign in - a link goes to your email", loggedIn}, + {"nan", "come back, and press right for Setup", loggedIn}, + {"e", "paste your API key in Setup", hasKey}, + {"space, then c", "pick your tools and apply", false}, + } + + // One column for the commands, measured over all of them, so the reasons + // line up under each other the way the two lists below this one do. + column := 0 + for _, s := range steps { + if w := lipgloss.Width(s.what); w > column { + column = w + } + } + column += 2 + + for i, st := range steps { + marker := num.Render(fmt.Sprintf("%d.", i+1)) + body := cmd.Width(column).Render(st.what) + desc.Render(st.why) + if st.done { + marker = done.Render("✓ ") + body = done.Width(column).Render(st.what) + done.Render(st.why) + } + b.WriteString(l.indent + marker + " " + body + "\n") + } + + if !loggedIn { + b.WriteString("\n" + l.indent + done.Render("Profile, Usage and Costs stay empty until you sign in.") + "\n") + } + b.WriteString("\n") + return b.String() +} + +func renderHome(l layout, loggedIn, hasKey bool) string { var b strings.Builder if l.w >= BannerWidth+4 { @@ -181,6 +244,8 @@ func renderHome(l layout) string { b.WriteString(l.indent + dim.Render("cloud CLI · v"+Version) + "\n\n") } + b.WriteString(renderNextStep(l, loggedIn, hasKey)) + section := lipgloss.NewStyle().Foreground(cGray).Bold(true) key := lipgloss.NewStyle().Foreground(lipgloss.Color(brandVioletText)).Bold(true) desc := lipgloss.NewStyle().Foreground(cGray) diff --git a/internal/tui/config_test.go b/internal/tui/config_test.go index 434071c..2a1ea83 100644 --- a/internal/tui/config_test.go +++ b/internal/tui/config_test.go @@ -2,6 +2,7 @@ package tui import ( "encoding/json" + "errors" "os" "os/exec" "path/filepath" @@ -568,7 +569,7 @@ func TestHomeIsTheFirstTabAndNeedsNoNetwork(t *testing.T) { } func TestHomeSaysHowToMoveAround(t *testing.T) { - out := renderHome(newLayout(80, 24)) + out := renderHome(newLayout(80, 24), true, true) for _, want := range []string{"█", "welcome to", "←/→", "↑/↓", "refresh", "quit", "Setup"} { if !strings.Contains(out, want) { t.Errorf("the Home tab does not mention %q", want) @@ -1007,3 +1008,95 @@ func TestAboutShowsTheSessionPathThatExists(t *testing.T) { t.Errorf("session.Path() = %q, which is not under the home it was given", session.Path()) } } + +// A fresh install on a machine that has never logged in drew "unauthorized" +// over Profile, Usage and Models: the platform's word for what it decided, and +// not one word about what to do next. Reported from a real first run. +func TestDataTabsSayToLogInRatherThanUnauthorized(t *testing.T) { + m := setupModel(t, &session.Session{}) + + for _, id := range []tabID{tabProfile, tabUsage, tabModels, tabCosts} { + if !m.needsLogin(id) { + t.Errorf("%v: would go to the network with no session and report `unauthorized`", id) + } + } + + // And the message is the one that names the command to run. + msg, ok := m.fetchTab(tabProfile)().(fetchErrMsg) + if !ok || !errors.Is(msg.err, session.ErrNotLoggedIn) { + t.Errorf("Profile reports %v, want the error that says `nan auth login`", msg.err) + } +} + +// The three tabs that need nothing from the platform have to stay usable with +// no session: Setup is where a member pastes the key in the first place. +func TestTheOfflineTabsNeverAskForALogin(t *testing.T) { + m := setupModel(t, &session.Session{}) + + for _, id := range []tabID{tabHome, tabAbout, tabSetup} { + if m.needsLogin(id) { + t.Errorf("%v: asks for a login it does not need", id) + } + } +} + +// An API key opens /v1/models on its own, so that tab stays useful to someone +// who pasted a key and never logged in. +func TestModelsStillLoadsWithAKeyAndNoSession(t *testing.T) { + m := setupModel(t, &session.Session{APIKey: testKey}) + + if m.needsLogin(tabModels) { + t.Error("a member with an API key is told to log in for the Models tab") + } +} + +// ── the first run ──────────────────────────────────────────────────────────── + +// The first time this was opened on a machine that had never logged in, Home +// explained the arrow keys and every data tab answered "unauthorized". Nothing +// anywhere said to log in, and the command that does it is a subcommand you +// have to already know exists. +func TestHomeLeadsWithSigningInWhenThereIsNoSession(t *testing.T) { + out := renderHome(newLayout(90, 40), false, false) + + for _, want := range []string{"Start here", "nan auth login", "stay empty until you sign in"} { + if !strings.Contains(out, want) { + t.Errorf("Home does not mention %q to someone with no session", want) + } + } + // Quitting is step one and not an afterthought: the next step is a command + // in the shell, which cannot be run with this panel open. + if !strings.Contains(out, "quit, so you have your shell back") { + t.Error("Home tells a member to run a shell command without telling them to leave first") + } +} + +// Signed in but with no key, the sign-in steps are done and the one that is +// not is the key. +func TestHomeMovesOnOnceSignedIn(t *testing.T) { + out := renderHome(newLayout(90, 40), true, false) + + if !strings.Contains(out, "Start here") { + t.Fatal("Home stops guiding before the setup is finished") + } + if !strings.Contains(out, "paste your API key") { + t.Error("Home does not name the step that is actually left") + } + // The done ones are still listed, struck through by their marker, so the + // list does not renumber itself between runs. + if !strings.Contains(out, "✓") { + t.Error("finished steps are not marked as finished") + } +} + +// And once there is nothing left to do it gets out of the way. +func TestHomeDropsTheGuideWhenSetupIsDone(t *testing.T) { + out := renderHome(newLayout(90, 40), true, true) + + if strings.Contains(out, "Start here") { + t.Error("Home still shows the first-run steps to a configured member") + } + if !strings.Contains(out, "Getting around") { + t.Error("the rest of Home went with it") + } +} diff --git a/internal/tui/tui.go b/internal/tui/tui.go index 10a2bed..5748c8c 100644 --- a/internal/tui/tui.go +++ b/internal/tui/tui.go @@ -397,10 +397,35 @@ func checkKey(apiKey string) tea.Cmd { } } +// Whether this tab has nothing to ask for until the member logs in. +// +// Every tab below Home asks the platform for something, and the platform +// answers a request with no session `unauthorized`. That word reached the +// screen verbatim: a fresh install drew "unauthorized" over three of its six +// tabs, which says what the server decided and not one word about what to do +// about it. Reported from a real first run on Windows. +// +// The Models tab is the exception. An API key opens /v1/models on its own, so +// a member who pasted a key into Setup and never logged in still gets that +// one - which is the point of Setup working without a session at all. +func (m model) needsLogin(id tabID) bool { + if m.sess.Token != "" { + return false + } + if id == tabModels && m.sess.APIKey != "" { + return false + } + return id != tabHome && id != tabAbout && id != tabSetup +} + func (m model) fetchTab(id tabID) tea.Cmd { client := m.client apiKey := m.sess.APIKey + needsLogin := m.needsLogin(id) return func() tea.Msg { + if needsLogin { + return fetchErrMsg{session.ErrNotLoggedIn} + } switch id { case tabProfile: data, err := client.GetMe() @@ -474,7 +499,7 @@ func (m model) View() string { content = renderCosts(usageData.(map[string]any), l) } case tabHome: - content = renderHome(l) + content = renderHome(l, m.sess.Token != "", m.sess.APIKey != "") case tabAbout: content = renderAbout(l) case tabSetup: @@ -2114,7 +2139,7 @@ func (m model) renderSetup(l layout) string { // ── about renderer ─────────────────────────────────────────────────────────── -const Version = "0.1.6" +const Version = "0.1.7" func renderAbout(l layout) string { var b strings.Builder diff --git a/scripts/install.ps1 b/scripts/install.ps1 index e815c49..ef55075 100644 --- a/scripts/install.ps1 +++ b/scripts/install.ps1 @@ -108,7 +108,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.6 + & ([scriptblock]::Create((irm https://nan.builders/install.ps1))) -Version v0.1.7 the releases are at https://github.com/$Repo/releases "@ } @@ -204,12 +204,36 @@ function Install-NanCli { Write-Done "installed $Version to $(Join-Path $InstallDir 'nan.exe')" - if (Add-ToUserPath $InstallDir) { - Write-Warn "$InstallDir was added to your PATH" - Write-Warn 'already-open terminals will not see it until they are restarted' + $pathAdded = Add-ToUserPath $InstallDir + + # Numbered, because "Run nan to get started" was true and not enough: it + # was printed under a warning about restarting, from a shell that could not + # find `nan` yet, to someone who then had to work out that signing in is a + # subcommand nothing had mentioned. Three things have to happen in order, + # so they are listed in order. + Write-Host '' + Write-Host 'Next:' -ForegroundColor White + Write-Host '' + $step = 0 + $next = { + param($what, $why) + $script:step++ + Write-Host (" {0}. " -f $script:step) -NoNewline + Write-Host $what.PadRight(24) -ForegroundColor Cyan -NoNewline + Write-Host $why -ForegroundColor DarkGray } + if ($pathAdded) { + # Not "open a new tab": a terminal that keeps one process alive for all + # of its tabs - Warp, Windows Terminal with a running profile - hands + # each new tab the environment it started with, PATH included. + & $next 'Restart your terminal' 'close it completely and open it again' + } + & $next 'nan auth login' 'sign in - a link goes to your email' + & $next 'nan' 'open the panel' Write-Host '' - Write-Host 'Run ' -NoNewline; Write-Host 'nan' -ForegroundColor Cyan -NoNewline; Write-Host ' to get started.' + if ($pathAdded) { + Write-Warn "$InstallDir was added to your PATH, which is why the restart matters" + } } finally { Remove-Item -Path $tmp -Recurse -Force -ErrorAction SilentlyContinue } diff --git a/scripts/install.sh b/scripts/install.sh index b1b2864..2389710 100755 --- a/scripts/install.sh +++ b/scripts/install.sh @@ -62,7 +62,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.6 curl -fsSL https://nan.builders/install | bash + printf " VERSION=v0.1.7 curl -fsSL https://nan.builders/install | bash " >&2 err "the releases are at https://github.com/$REPO/releases" exit 1