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
67 changes: 66 additions & 1 deletion internal/tui/banner.go
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
package tui

import (
"fmt"
"strings"

"github.com/charmbracelet/lipgloss"
Expand Down Expand Up @@ -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 {
Expand All @@ -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)
Expand Down
95 changes: 94 additions & 1 deletion internal/tui/config_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ package tui

import (
"encoding/json"
"errors"
"os"
"os/exec"
"path/filepath"
Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -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")
}
}
29 changes: 27 additions & 2 deletions internal/tui/tui.go
Original file line number Diff line number Diff line change
Expand Up @@ -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()
Expand Down Expand Up @@ -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:
Expand Down Expand Up @@ -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
Expand Down
34 changes: 29 additions & 5 deletions scripts/install.ps1
Original file line number Diff line number Diff line change
Expand Up @@ -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
"@
}
Expand Down Expand Up @@ -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
}
Expand Down
2 changes: 1 addition & 1 deletion scripts/install.sh
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
Loading