diff --git a/app/store/database.go b/app/store/database.go index 2a6c0b2f6..8c29a6135 100644 --- a/app/store/database.go +++ b/app/store/database.go @@ -1268,7 +1268,7 @@ func (db *database) setSettings(s Settings) error { _, err := db.conn.Exec(` UPDATE settings - SET expose = ?, survey = ?, browser = ?, models = ?, agent = ?, tools = ?, working_dir = ?, context_length = ?, turbo_enabled = ?, websearch_enabled = ?, selected_model = ?, sidebar_open = ?, last_home_view = ?, onboarding_version = ?, think_enabled = ?, think_level = ?, auto_update_enabled = ?, claude_desktop_used = ? + SET expose = ?, survey = ?, browser = ?, models = ?, agent = ?, tools = ?, working_dir = ?, context_length = ?, turbo_enabled = ?, websearch_enabled = ?, selected_model = ?, sidebar_open = ?, last_home_view = ?, onboarding_version = MAX(onboarding_version, ?), think_enabled = ?, think_level = ?, auto_update_enabled = ?, claude_desktop_used = ? `, s.Expose, s.Survey, s.Browser, s.Models, s.Agent, s.Tools, s.WorkingDir, s.ContextLength, s.TurboEnabled, s.WebSearchEnabled, s.SelectedModel, s.SidebarOpen, lastHomeView, s.OnboardingVersion, s.ThinkEnabled, s.ThinkLevel, s.AutoUpdateEnabled, s.ClaudeDesktopUsed) if err != nil { return fmt.Errorf("set settings: %w", err) @@ -1276,6 +1276,11 @@ func (db *database) setSettings(s Settings) error { return nil } +func (db *database) markOnboardingCompleted() error { + _, err := db.conn.Exec("UPDATE settings SET onboarding_version = MAX(onboarding_version, ?)", CurrentOnboardingVersion) + return err +} + func (db *database) markCodexDesktopUsed() error { _, err := db.conn.Exec(`UPDATE settings SET codex_desktop_used = 1`) return err diff --git a/app/store/store.go b/app/store/store.go index 2c004029e..a838dd3fb 100644 --- a/app/store/store.go +++ b/app/store/store.go @@ -17,6 +17,7 @@ import ( "github.com/google/uuid" "github.com/ollama/ollama/app/types/not" + "github.com/ollama/ollama/internal/onboarding" ) type File struct { @@ -185,10 +186,11 @@ type Settings struct { } // Keep in sync with CURRENT_ONBOARDING_VERSION in app/ui/app/src/lib/onboarding.ts. -const CurrentOnboardingVersion = 1 +const CurrentOnboardingVersion = onboarding.CurrentVersion type Store struct { - // DBPath allows overriding the default database path (mainly for testing) + // DBPath overrides the database path. Custom stores keep their shared + // onboarding record alongside the database, isolated from the user's state. DBPath string // dbMu protects database initialization only @@ -196,16 +198,7 @@ type Store struct { db *database } -var defaultDBPath = func() string { - switch runtime.GOOS { - case "windows": - return filepath.Join(os.Getenv("LOCALAPPDATA"), "Ollama", "db.sqlite") - case "darwin": - return filepath.Join(os.Getenv("HOME"), "Library", "Application Support", "Ollama", "db.sqlite") - default: - return filepath.Join(os.Getenv("HOME"), ".ollama", "db.sqlite") - } -}() +var defaultDBPath = onboarding.AppDatabasePath() // legacyConfigPath is the path to the old config.json file var legacyConfigPath = func() string { @@ -401,6 +394,9 @@ func (s *Store) Settings() (Settings, error) { if err != nil { return Settings{}, err } + if err := s.syncOnboarding(&settings); err != nil { + return Settings{}, fmt.Errorf("load shared onboarding state: %w", err) + } // Set default models directory if not set if settings.Models == "" { @@ -427,7 +423,45 @@ func (s *Store) SetSettings(settings Settings) error { return err } - return s.db.setSettings(settings) + if err := s.db.setSettings(settings); err != nil { + return err + } + if settings.OnboardingVersion >= CurrentOnboardingVersion { + return s.syncOnboarding(&settings) + } + return nil +} + +func (s *Store) onboardingState() onboarding.State { + if s.DBPath != "" { + return onboarding.State{Dir: filepath.Dir(s.DBPath)} + } + return onboarding.State{} +} + +func (s *Store) syncOnboarding(settings *Settings) error { + state := s.onboardingState() + if settings.OnboardingVersion >= CurrentOnboardingVersion { + // Migrate existing app completion so the CLI recognizes it too. + // SQLite is already saved; publishing the shared record is best-effort. + if err := state.Complete(); err != nil { + slog.Warn("could not share onboarding completion", "error", err) + } + return nil + } + completed, err := state.Completed() + if err != nil { + slog.Warn("could not read shared onboarding completion", "error", err) + return nil + } + if !completed { + return nil + } + if err := s.db.markOnboardingCompleted(); err != nil { + return err + } + settings.OnboardingVersion = CurrentOnboardingVersion + return nil } func (s *Store) MarkCodexDesktopUsed() error { diff --git a/app/store/store_test.go b/app/store/store_test.go index 2af1f4745..40da52fea 100644 --- a/app/store/store_test.go +++ b/app/store/store_test.go @@ -3,8 +3,13 @@ package store import ( + "fmt" + "os" "path/filepath" "testing" + + "github.com/ollama/ollama/cmd/config" + "github.com/ollama/ollama/internal/onboarding" ) func TestStore(t *testing.T) { @@ -253,6 +258,127 @@ func TestOnboardingVersionRoundTrip(t *testing.T) { } } +func setupPairedOnboarding(t *testing.T) *Store { + t.Helper() + s, cleanup := setupTestStore(t) + t.Cleanup(cleanup) + home := t.TempDir() + t.Setenv("HOME", home) + t.Setenv("USERPROFILE", home) + t.Setenv("LOCALAPPDATA", filepath.Join(home, "AppData", "Local")) + previous := defaultDBPath + defaultDBPath = onboarding.AppDatabasePath() + t.Cleanup(func() { defaultDBPath = previous }) + s.DBPath = "" + return s +} + +func TestSettingsSurviveInvalidOnboardingMarker(t *testing.T) { + s, cleanup := setupTestStore(t) + t.Cleanup(cleanup) + marker := filepath.Join(filepath.Dir(s.DBPath), "onboarding-v1.completed") + if err := os.MkdirAll(marker, 0o755); err != nil { + t.Fatal(err) + } + if err := s.SetSettings(Settings{SelectedModel: "saved-model"}); err != nil { + t.Fatal(err) + } + settings, err := s.Settings() + if err != nil || settings.SelectedModel != "saved-model" || settings.OnboardingVersion != 0 { + t.Fatalf("invalid marker must preserve readable database settings: %+v, %v", settings, err) + } +} + +func TestAppCompletionReachesCLI(t *testing.T) { + for _, source := range []string{"app", "existing database"} { + t.Run(source, func(t *testing.T) { + s := setupPairedOnboarding(t) + if err := s.ensureDB(); err != nil { + t.Fatal(err) + } + if needed, err := config.NeedsWelcome(); err != nil || !needed { + t.Fatalf("unfinished app skipped CLI onboarding: %v, %v", needed, err) + } + settings := Settings{OnboardingVersion: CurrentOnboardingVersion} + save := s.SetSettings + if source == "existing database" { + save = s.db.setSettings // Older app completed without publishing a marker. + } + if err := save(settings); err != nil { + t.Fatal(err) + } + if err := s.Close(); err != nil { + t.Fatal(err) + } + if needed, err := config.NeedsWelcome(); err != nil || needed { + t.Fatalf("app completion did not reach CLI: %v, %v", needed, err) + } + }) + } +} + +func TestLegacyAppCompletionReachesCLI(t *testing.T) { + for _, schema := range []int{1, 16, 17, 0} { + t.Run(fmt.Sprint(schema), func(t *testing.T) { + s := setupPairedOnboarding(t) + if err := s.ensureDB(); err != nil { + t.Fatal(err) + } + if _, err := s.db.conn.Exec("ALTER TABLE settings DROP COLUMN onboarding_version"); err != nil { + t.Fatal(err) + } + if _, err := s.db.conn.Exec("UPDATE settings SET schema_version = ?", schema); err != nil { + t.Fatal(err) + } + wantWelcome := schema < 1 || schema > 16 + if needed, err := config.NeedsWelcome(); err != nil || needed != wantWelcome { + t.Fatalf("welcome needed=%v, err=%v; want %v", needed, err, wantWelcome) + } + var unchanged int + if err := s.db.conn.QueryRow("SELECT schema_version FROM settings WHERE id = 1").Scan(&unchanged); err != nil || unchanged != schema { + t.Fatalf("CLI changed the app schema: %d, %v", unchanged, err) + } + var columns int + if err := s.db.conn.QueryRow("SELECT count(*) FROM pragma_table_info('settings') WHERE name = 'onboarding_version'").Scan(&columns); err != nil || columns != 0 { + t.Fatalf("CLI migrated the app database: columns=%d, err=%v", columns, err) + } + }) + } +} + +func TestCLICompletionReachesApp(t *testing.T) { + for _, installed := range []bool{false, true} { + s := setupPairedOnboarding(t) + stale := Settings{SelectedModel: "saved-model"} + if installed { + if err := s.SetSettings(stale); err != nil { + t.Fatal(err) + } + } + if err := config.CompleteWelcome(); err != nil { + t.Fatal(err) + } + if !installed { + if _, err := os.Stat(defaultDBPath); !os.IsNotExist(err) { + t.Fatal("CLI completion must not create the app database") + } + } + settings, err := s.Settings() + if err != nil || settings.OnboardingVersion != CurrentOnboardingVersion { + t.Fatalf("app did not import CLI completion: %+v, %v", settings, err) + } + if installed && settings.SelectedModel != stale.SelectedModel { + t.Fatal("completion changed app settings") + } + if err := s.SetSettings(stale); err != nil { + t.Fatal(err) + } + if saved, err := s.Settings(); err != nil || saved.OnboardingVersion != CurrentOnboardingVersion { + t.Fatalf("stale settings reset completion: %+v, %v", saved, err) + } + } +} + func TestClaudeDesktopUsedRoundTrip(t *testing.T) { s, cleanup := setupTestStore(t) defer cleanup() diff --git a/app/ui/app/src/components/OnboardingRoute.test.tsx b/app/ui/app/src/components/OnboardingRoute.test.tsx index 2ced15ec4..e382d60d0 100644 --- a/app/ui/app/src/components/OnboardingRoute.test.tsx +++ b/app/ui/app/src/components/OnboardingRoute.test.tsx @@ -41,10 +41,11 @@ async function renderOnboarding(authenticated: boolean) { vi.stubGlobal("navigator", { platform: "MacIntel" }); vi.stubGlobal("window", { OLLAMA_PLATFORM: "darwin", + location: { search: "" }, setOnboardingWindow: vi.fn(), }); - const settingsResponse = { settings: new Settings({ OnboardingVersion: 0 }) }; - vi.spyOn(api, "getSettings").mockResolvedValue(settingsResponse); + let settingsResponse = { settings: new Settings({ OnboardingVersion: 0 }) }; + vi.spyOn(api, "getSettings").mockImplementation(async () => settingsResponse); const client = new QueryClient({ defaultOptions: { queries: { retry: false, gcTime: Infinity }, @@ -88,6 +89,17 @@ async function renderOnboarding(authenticated: boolean) { renderer.update(element()); }); }, + async receiveCompletion() { + settingsResponse = { + settings: new Settings({ + OnboardingVersion: CURRENT_ONBOARDING_VERSION, + }), + }; + await act(async () => { + client.setQueryData(["settings"], { ...settingsResponse }); + await new Promise((resolve) => setTimeout(resolve, 0)); + }); + }, async unmount() { await act(async () => renderer.unmount()); client.clear(); @@ -102,6 +114,42 @@ async function flushQueryNotifications() { } describe("Onboarding completion", () => { + it("leaves onboarding when CLI completion arrives", async () => { + const save = vi.spyOn(api, "updateSettings"); + const onboarding = await renderOnboarding(true); + try { + expect(mocks.navigate).not.toHaveBeenCalled(); + await onboarding.receiveCompletion(); + expect(mocks.navigate).toHaveBeenCalledExactlyOnceWith({ to: "/" }); + expect(save).not.toHaveBeenCalled(); + } finally { + await onboarding.unmount(); + } + }); + + it("keeps the app's own local completion screen visible", async () => { + const save = vi + .spyOn(api, "updateSettings") + .mockImplementation(async (settings) => ({ settings })); + const onboarding = await renderOnboarding(false); + try { + await onboarding.continue(); + await act(async () => { + onboarding.root.findByType(WelcomeScreen).props.onLocal(); + }); + expect(save).toHaveBeenCalledOnce(); + expect(save).toHaveBeenCalledWith( + expect.objectContaining({ + OnboardingVersion: CURRENT_ONBOARDING_VERSION, + }), + ); + await onboarding.receiveCompletion(); + expect(mocks.navigate).not.toHaveBeenCalled(); + } finally { + await onboarding.unmount(); + } + }); + it.each([true, false])( "saves once before opening Apps directly (already signed in: %s)", async (authenticated) => { diff --git a/app/ui/app/src/hooks/useSettings.ts b/app/ui/app/src/hooks/useSettings.ts index f544b1581..bfed164e9 100644 --- a/app/ui/app/src/hooks/useSettings.ts +++ b/app/ui/app/src/hooks/useSettings.ts @@ -27,13 +27,16 @@ type SettingsUpdate = Partial<{ OnboardingVersion: number; }>; -export function useSettings() { +export function useSettings({ + refetchInterval, +}: { refetchInterval?: number } = {}) { const queryClient = useQueryClient(); // Fetch settings with useQuery const { data: settingsData, error } = useQuery({ queryKey: ["settings"], queryFn: getSettings, + refetchInterval, }); // Update settings with useMutation diff --git a/app/ui/app/src/routes/index.tsx b/app/ui/app/src/routes/index.tsx index e22f3c25a..e4edf550f 100644 --- a/app/ui/app/src/routes/index.tsx +++ b/app/ui/app/src/routes/index.tsx @@ -4,9 +4,10 @@ import { CURRENT_ONBOARDING_VERSION, homeChatId } from "@/lib/onboarding"; export const Route = createFileRoute("/")({ beforeLoad: async ({ context }) => { - const settingsData = await context.queryClient.ensureQueryData({ + const settingsData = await context.queryClient.fetchQuery({ queryKey: ["settings"], queryFn: getSettings, + staleTime: 0, }); if (settingsData.settings.OnboardingVersion < CURRENT_ONBOARDING_VERSION) { throw redirect({ to: "/onboarding" }); diff --git a/app/ui/app/src/routes/onboarding.tsx b/app/ui/app/src/routes/onboarding.tsx index 0c3313f54..5c714a327 100644 --- a/app/ui/app/src/routes/onboarding.tsx +++ b/app/ui/app/src/routes/onboarding.tsx @@ -23,9 +23,10 @@ export const Route = createFileRoute("/onboarding")({ return; } - const settingsData = await context.queryClient.ensureQueryData({ + const settingsData = await context.queryClient.fetchQuery({ queryKey: ["settings"], queryFn: getSettings, + staleTime: 0, }); if (settingsData.settings.OnboardingVersion >= CURRENT_ONBOARDING_VERSION) { @@ -42,12 +43,28 @@ export const Route = createFileRoute("/onboarding")({ function OnboardingRoute() { const navigate = useNavigate(); - const { settingsData, setSettings } = useSettings(); + const { settingsData, setSettings } = useSettings({ refetchInterval: 2000 }); const { fetchConnectUrl, refetchUser, isAuthenticated } = useUser(); const [isAwaitingAuth, setIsAwaitingAuth] = useState(false); const [signInError, setSignInError] = useState(null); const [completionError, setCompletionError] = useState(null); const authAttemptRef = useRef(0); + const completedHereRef = useRef(false); + + // The CLI can complete welcome while this window is open. Leave onboarding + // when its shared state changes, while preserving this window's own finish flow. + useEffect(() => { + const isPreview = + import.meta.env.DEV && + new URLSearchParams(window.location.search).get("preview") === "1"; + if ( + !isPreview && + !completedHereRef.current && + (settingsData?.OnboardingVersion ?? 0) >= CURRENT_ONBOARDING_VERSION + ) { + void navigate({ to: "/" }); + } + }, [navigate, settingsData?.OnboardingVersion]); const completeOnboarding = useCallback(async (): Promise => { setCompletionError(null); @@ -57,11 +74,13 @@ function OnboardingRoute() { throw new Error("Settings are not loaded"); } + completedHereRef.current = true; await setSettings({ OnboardingVersion: CURRENT_ONBOARDING_VERSION, }); return true; } catch (error) { + completedHereRef.current = false; console.error("Failed to save onboarding state:", error); setCompletionError("Unable to save setup. Please try again."); return false; diff --git a/app/ui/ui.go b/app/ui/ui.go index 0ca109103..4be03c532 100644 --- a/app/ui/ui.go +++ b/app/ui/ui.go @@ -1565,6 +1565,7 @@ func (s *Server) settings(w http.ResponseWriter, r *http.Request) error { if err != nil { return fmt.Errorf("failed to load saved settings: %w", err) } + settings.OnboardingVersion = saved.OnboardingVersion settings.CodexDesktopUsed = saved.CodexDesktopUsed // Handle auto-update toggle changes diff --git a/cmd/cmd.go b/cmd/cmd.go index 7eb645afb..cbc3834be 100644 --- a/cmd/cmd.go +++ b/cmd/cmd.go @@ -2396,6 +2396,12 @@ func NewCLI() *cobra.Command { return } + if err := runWelcome(cmd.Context()); err != nil { + if !errors.Is(err, launch.ErrCancelled) { + fmt.Fprintf(os.Stderr, "Error: %v\n", err) + } + return + } runInteractiveTUI(cmd) }, } diff --git a/cmd/config/config.go b/cmd/config/config.go index 5282de237..44b0e5042 100644 --- a/cmd/config/config.go +++ b/cmd/config/config.go @@ -6,11 +6,14 @@ import ( "encoding/json" "errors" "fmt" + "log/slog" "os" "path/filepath" + "runtime" "strings" "github.com/ollama/ollama/cmd/internal/fileutil" + "github.com/ollama/ollama/internal/onboarding" ) type integration struct { @@ -24,9 +27,57 @@ type integration struct { type IntegrationConfig = integration type config struct { - Integrations map[string]*integration `json:"integrations"` - LastModel string `json:"last_model,omitempty"` - LastSelection string `json:"last_selection,omitempty"` // "run" or integration name + Integrations map[string]*integration `json:"integrations"` + LastModel string `json:"last_model,omitempty"` + LastSelection string `json:"last_selection,omitempty"` // "run" or integration name + OnboardingVersion int `json:"onboarding_version,omitempty"` +} + +func NeedsWelcome() (bool, error) { + if runtime.GOOS == "linux" { + return needsWelcomeInConfig() + } + state := onboarding.State{} + completed, err := state.Completed() + if completed { + return false, err + } + // Older apps recorded completion only in SQLite. + if onboarding.CompletedInApp() { + if err := state.Complete(); err != nil { + slog.Warn("could not share onboarding completion", "error", err) + } + return false, nil + } + return true, err +} + +func CompleteWelcome() error { + if runtime.GOOS == "linux" { + return completeWelcomeInConfig() + } + return (onboarding.State{}).Complete() +} + +// Linux keeps completion with CLI preferences; desktop platforms share the marker. +func needsWelcomeInConfig() (bool, error) { + cfg, err := load() + if err != nil { + return false, err + } + return cfg.OnboardingVersion < onboarding.CurrentVersion, nil +} + +func completeWelcomeInConfig() error { + cfg, err := load() + if err != nil { + return err + } + if cfg.OnboardingVersion >= onboarding.CurrentVersion { + return nil + } + cfg.OnboardingVersion = onboarding.CurrentVersion + return save(cfg) } func configPath() (string, error) { diff --git a/cmd/config/config_test.go b/cmd/config/config_test.go index f95a30c2e..ca8adc744 100644 --- a/cmd/config/config_test.go +++ b/cmd/config/config_test.go @@ -3,8 +3,11 @@ package config import ( "os" "path/filepath" + "runtime" "strings" "testing" + + "github.com/ollama/ollama/internal/onboarding" ) // setTestHome sets both HOME (Unix) and USERPROFILE (Windows) for cross-platform tests @@ -14,6 +17,87 @@ func setTestHome(t *testing.T, dir string) { t.Setenv("USERPROFILE", dir) } +func TestWelcomeStorage(t *testing.T) { + setTestHome(t, t.TempDir()) + if err := CompleteWelcome(); err != nil { + t.Fatal(err) + } + cfg, err := load() + if err != nil { + t.Fatal(err) + } + linux := runtime.GOOS == "linux" + if (cfg.OnboardingVersion == onboarding.CurrentVersion) != linux { + t.Fatalf("unexpected config completion on %s: %d", runtime.GOOS, cfg.OnboardingVersion) + } + if marked, err := (onboarding.State{}).Completed(); err != nil || marked == linux { + t.Fatalf("unexpected marker on %s: completed=%v err=%v", runtime.GOOS, marked, err) + } +} + +func TestWelcomeInConfig(t *testing.T) { + for _, tc := range []struct { + name string + version int + }{ + {name: "unfinished"}, + {name: "completed", version: onboarding.CurrentVersion}, + {name: "newer version", version: onboarding.CurrentVersion + 1}, + } { + t.Run(tc.name, func(t *testing.T) { + setTestHome(t, t.TempDir()) + if needed, err := needsWelcomeInConfig(); err != nil || !needed { + t.Fatalf("fresh config: needed=%v err=%v", needed, err) + } + if err := save(&config{LastModel: "saved-model", OnboardingVersion: tc.version}); err != nil { + t.Fatal(err) + } + if needed, err := needsWelcomeInConfig(); err != nil || needed != (tc.version == 0) { + t.Fatalf("existing config: needed=%v err=%v", needed, err) + } + if err := completeWelcomeInConfig(); err != nil { + t.Fatal(err) + } + if got, err := load(); err != nil || got.LastModel != "saved-model" || got.OnboardingVersion != max(tc.version, onboarding.CurrentVersion) { + t.Fatalf("completion changed preferences: %+v, %v", got, err) + } + if err := SetLastModel("new-model"); err != nil { + t.Fatal(err) + } + if needed, err := needsWelcomeInConfig(); err != nil || needed { + t.Fatalf("completion was lost: needed=%v err=%v", needed, err) + } + if marked, err := (onboarding.State{}).Completed(); err != nil || marked { + t.Fatalf("config completion changed marker: completed=%v err=%v", marked, err) + } + }) + } +} + +func TestWelcomeInConfigPreservesCorruptFile(t *testing.T) { + setTestHome(t, t.TempDir()) + path, err := configPath() + if err != nil { + t.Fatal(err) + } + if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil { + t.Fatal(err) + } + const contents = `{corrupt` + if err := os.WriteFile(path, []byte(contents), 0o600); err != nil { + t.Fatal(err) + } + if _, err := needsWelcomeInConfig(); err == nil { + t.Fatal("expected config read error") + } + if err := completeWelcomeInConfig(); err == nil { + t.Fatal("expected completion to fail without overwriting config") + } + if got, err := os.ReadFile(path); err != nil || string(got) != contents { + t.Fatalf("corrupt config was overwritten: %q, %v", got, err) + } +} + func TestIntegrationConfig(t *testing.T) { tmpDir := t.TempDir() setTestHome(t, tmpDir) diff --git a/cmd/tui/tui.go b/cmd/tui/tui.go index 0e16a0a3b..fffd3b5cb 100644 --- a/cmd/tui/tui.go +++ b/cmd/tui/tui.go @@ -2,6 +2,7 @@ package tui import ( "fmt" + "slices" tea "github.com/charmbracelet/bubbletea" "github.com/charmbracelet/lipgloss" @@ -41,26 +42,22 @@ type menuItem struct { title string description string integration string - isRunModel bool isOthers bool } -var runModelMenuItem = menuItem{ - title: "Chat with a model", - description: "Start an interactive chat with a model", - isRunModel: true, -} - var othersMenuItem = menuItem{ title: "More...", description: "Show additional integrations", isOthers: true, } -// launcherMenuIntegrations defines the integrations pinned to the root menu. -// Additional visible integrations are available through More in registry order. +// launcherMenuIntegrations defines the default priority before the additional +// integrations in registry order. Installed apps are promoted above missing +// apps, with only the first five shown before More. var launcherMenuIntegrations = []string{"claude", "opencode", "hermes", "openclaw"} +const launcherMenuLimit = 5 + type model struct { state *launch.LauncherState items []menuItem @@ -73,6 +70,9 @@ type model struct { } func newModel(state *launch.LauncherState) model { + if state == nil { + state = &launch.LauncherState{} + } m := model{ state: state, } @@ -86,8 +86,8 @@ func shouldExpandOthers(state *launch.LauncherState) bool { if state == nil { return false } - for _, item := range otherIntegrationItems(state) { - if item.integration == state.LastSelection { + for i, item := range orderedIntegrationItems(state) { + if i >= launcherMenuLimit && item.integration == state.LastSelection { return true } } @@ -95,18 +95,11 @@ func shouldExpandOthers(state *launch.LauncherState) bool { } func buildMenuItems(state *launch.LauncherState, showOthers bool) []menuItem { - items := []menuItem{runModelMenuItem} - items = append(items, launcherIntegrationItems(state)...) - - otherItems := otherIntegrationItems(state) - switch { - case showOthers: - items = append(items, otherItems...) - case len(otherItems) > 0: - items = append(items, othersMenuItem) + items := orderedIntegrationItems(state) + if showOthers || len(items) <= launcherMenuLimit { + return items } - - return items + return append(items[:launcherMenuLimit], othersMenuItem) } func integrationMenuItem(state launch.LauncherIntegrationState) menuItem { @@ -121,12 +114,12 @@ func integrationMenuItem(state launch.LauncherIntegrationState) menuItem { } } -func launcherIntegrationItems(state *launch.LauncherState) []menuItem { +func orderedIntegrationItems(state *launch.LauncherState) []menuItem { if state == nil { return nil } - items := make([]menuItem, 0, len(launcherMenuIntegrations)) + var items []menuItem for _, name := range launcherMenuIntegrations { integrationState, ok := state.Integrations[name] if !ok { @@ -134,22 +127,8 @@ func launcherIntegrationItems(state *launch.LauncherState) []menuItem { } items = append(items, integrationMenuItem(integrationState)) } - return items -} - -func otherIntegrationItems(state *launch.LauncherState) []menuItem { - if state == nil { - return nil - } - - pinned := make(map[string]bool, len(launcherMenuIntegrations)) - for _, name := range launcherMenuIntegrations { - pinned[name] = true - } - - items := make([]menuItem, 0, len(state.Integrations)) for _, info := range launch.ListIntegrationInfos() { - if pinned[info.Name] { + if slices.Contains(launcherMenuIntegrations, info.Name) { continue } integrationState, ok := state.Integrations[info.Name] @@ -158,11 +137,16 @@ func otherIntegrationItems(state *launch.LauncherState) []menuItem { } items = append(items, integrationMenuItem(integrationState)) } - return items -} -func primaryMenuItemCount(state *launch.LauncherState) int { - return 1 + len(launcherIntegrationItems(state)) + var installed, missing []menuItem + for _, item := range items { + if state.Integrations[item.integration].Installed { + installed = append(installed, item) + } else { + missing = append(missing, item) + } + } + return append(installed, missing...) } func initialCursor(state *launch.LauncherState, items []menuItem) int { @@ -170,9 +154,6 @@ func initialCursor(state *launch.LauncherState, items []menuItem) int { return 0 } for i, item := range items { - if state.LastSelection == "run" && item.isRunModel { - return i - } if item.integration == state.LastSelection { return i } @@ -200,7 +181,7 @@ func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { if m.cursor > 0 { m.cursor-- } - if m.showOthers && m.cursor < primaryMenuItemCount(m.state) { + if m.showOthers && m.cursor < launcherMenuLimit { m.showOthers = false m.items = buildMenuItems(m.state, false) m.cursor = min(m.cursor, len(m.items)-1) @@ -218,6 +199,9 @@ func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { return m, nil case "enter", " ": + if len(m.items) == 0 { + return m, nil + } if m.selectableItem(m.items[m.cursor]) { m.selected = true m.action = actionForMenuItem(m.items[m.cursor], false) @@ -227,8 +211,11 @@ func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { return m, nil case "right", "l": + if len(m.items) == 0 { + return m, nil + } item := m.items[m.cursor] - if item.isRunModel || m.changeableItem(item) { + if m.changeableItem(item) { m.selected = true m.action = actionForMenuItem(item, true) m.quitting = true @@ -242,10 +229,7 @@ func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { } func (m model) selectableItem(item menuItem) bool { - if item.isRunModel { - return true - } - if item.integration == "" { + if item.integration == "" || m.state == nil { return false } state, ok := m.state.Integrations[item.integration] @@ -253,7 +237,7 @@ func (m model) selectableItem(item menuItem) bool { } func (m model) changeableItem(item menuItem) bool { - if item.integration == "" { + if item.integration == "" || m.state == nil { return false } state, ok := m.state.Integrations[item.integration] @@ -270,6 +254,9 @@ func (m model) View() string { for i, item := range m.items { s += m.renderMenuItem(i, item) } + if len(m.items) == 0 { + s += "No apps available.\n" + } s += "\n" + selectorHelpStyle.Render("↑/↓ navigate • enter launch • → configure • esc quit") @@ -290,14 +277,7 @@ func (m model) renderMenuItem(index int, item menuItem) string { cursor = "▸ " } - if item.isRunModel { - if m.cursor == index && m.state.RunModel != "" { - modelSuffix = " " + modelStyle.Render("("+m.state.RunModel+")") - } - if m.cursor == index { - style = menuSelectedItemStyle - } - } else if item.isOthers { + if item.isOthers { // More immediately expands when reached, so it always uses the default style. } else { integrationState := m.state.Integrations[item.integration] @@ -374,8 +354,6 @@ func (a TUIAction) IntegrationLaunchRequest() launch.IntegrationLaunchRequest { func actionForMenuItem(item menuItem, forceConfigure bool) TUIAction { switch { - case item.isRunModel: - return TUIAction{Kind: TUIActionRunModel, ForceConfigure: forceConfigure} case item.integration != "": return TUIAction{Kind: TUIActionLaunchIntegration, Integration: item.integration, ForceConfigure: forceConfigure} default: diff --git a/cmd/tui/tui_test.go b/cmd/tui/tui_test.go index 7063297f5..b9887c9fa 100644 --- a/cmd/tui/tui_test.go +++ b/cmd/tui/tui_test.go @@ -89,8 +89,6 @@ func integrationSequence(items []menuItem) []string { sequence := make([]string, 0, len(items)) for _, item := range items { switch { - case item.isRunModel: - sequence = append(sequence, "run") case item.isOthers: sequence = append(sequence, "more") case item.integration != "": @@ -104,59 +102,115 @@ func compareStrings(got, want []string) string { return cmp.Diff(want, got) } -func TestMenuRendersRootLaunchChoices(t *testing.T) { - state := launcherTestState() - menu := newModel(state) - want := []string{"run", "claude", "opencode", "hermes", "openclaw", "more"} - if diff := compareStrings(integrationSequence(menu.items), want); diff != "" { - t.Fatalf("unexpected root launch choices: %s", diff) - } - - view := menu.View() - for _, want := range []string{ - "Chat with a model", - "Start an interactive chat with a model", - "Launch Claude Code", - "Launch OpenCode", - "Launch Hermes Agent", - "Launch OpenClaw", - "More...", +func TestMenuPromotesInstalledAppsInDefaultPriorityOrder(t *testing.T) { + for _, tc := range []struct { + name string + installed []string + want []string + wantOverflow []string + }{ + {"none installed", nil, []string{"claude", "opencode", "hermes", "openclaw", "codex", "more"}, []string{"droid", "pi"}}, + {"only OpenClaw", []string{"openclaw"}, []string{"openclaw", "claude", "opencode", "hermes", "codex", "more"}, []string{"droid", "pi"}}, + {"installed primary and additional apps", []string{"pi", "codex", "openclaw"}, []string{"openclaw", "codex", "pi", "claude", "opencode", "more"}, []string{"hermes", "droid"}}, + {"more than five installed", []string{"pi", "droid", "codex", "openclaw", "hermes", "opencode"}, []string{"opencode", "hermes", "openclaw", "codex", "droid", "more"}, []string{"pi", "claude"}}, } { - if !strings.Contains(view, want) { - t.Fatalf("expected menu view to contain %q\n%s", want, view) - } + t.Run(tc.name, func(t *testing.T) { + state := launcherTestState() + // Keep these ordering fixtures independent of GUI platform support. + delete(state.Integrations, "chatgpt") + for _, name := range tc.installed { + app := state.Integrations[name] + app.Installed = true + state.Integrations[name] = app + } + menu := newModel(state) + if diff := compareStrings(integrationSequence(menu.items), tc.want); diff != "" { + t.Fatalf("wrong installed-first order: %s", diff) + } + expanded := buildMenuItems(state, true) + if diff := compareStrings(integrationSequence(expanded[launcherMenuLimit:]), tc.wantOverflow); diff != "" { + t.Fatalf("remaining apps should stay under More in priority order: %s", diff) + } + }) } - for _, hidden := range []string{"Launch ChatGPT", "Launch Codex", "Launch Droid", "Launch Pi"} { - if strings.Contains(view, hidden) { - t.Fatalf("expected root menu to omit %q\n%s", hidden, view) +} + +func TestMenuRemembersPromotedAppWithoutOpeningMore(t *testing.T) { + state := launcherTestState() + app := state.Integrations["codex"] + app.Installed = true + state.Integrations["codex"] = app + state.LastSelection = "codex" + menu := newModel(state) + if menu.showOthers || menu.cursor != 0 || menu.items[menu.cursor].integration != "codex" { + t.Fatal("previously selected installed app should be recalled in its promoted position") + } +} + +func TestMenuEmptyStateDoesNotPanic(t *testing.T) { + menu := newModel(nil) + if len(menu.items) != 0 { + t.Fatal("empty state must not create selectable menu items") + } + _ = menu.View() // Rendering an empty menu must not panic, regardless of its copy. + for _, key := range []tea.KeyType{tea.KeyEnter, tea.KeyRight, tea.KeyUp, tea.KeyDown} { + updated, cmd := menu.Update(tea.KeyMsg{Type: key}) + menu = updated.(model) + if menu.selected || menu.action.Kind != TUIActionNone || menu.cursor != 0 || cmd != nil { + t.Fatal("empty menu must not select an action") + } + _ = menu.View() + } + if updated, quit := menu.Update(tea.KeyMsg{Type: tea.KeyEsc}); !updated.(model).quitting || quit == nil { + t.Fatal("Escape must still exit an empty menu") + } +} + +func TestMenuUpToFiveAppsDoesNotNeedMore(t *testing.T) { + for _, names := range [][]string{ + {"claude", "opencode", "hermes"}, + {"claude", "opencode", "hermes", "openclaw", "codex"}, + } { + state := launcherTestState() + apps := make(map[string]launch.LauncherIntegrationState) + for _, name := range names { + apps[name] = state.Integrations[name] + } + state.Integrations = apps + menu := newModel(state) + if diff := compareStrings(integrationSequence(menu.items), names); diff != "" { + t.Fatalf("up to five apps should appear directly without More: %s", diff) } } } -func TestMenuExpandsMoreOnDownNavigation(t *testing.T) { +func TestMenuExpandsAndCollapsesMoreAfterPromotion(t *testing.T) { state := launcherTestState() + app := state.Integrations["codex"] + app.Installed = true + state.Integrations["codex"] = app menu := newModel(state) - menu.cursor = findMenuCursorByIntegration(menu.items, "openclaw") - if menu.cursor == -1 { - t.Fatal("expected openclaw menu item") - } + menu.cursor = launcherMenuLimit - 1 + root := integrationSequence(menu.items) updated, _ := menu.Update(tea.KeyMsg{Type: tea.KeyDown}) - got := updated.(model) - if !got.showOthers { - t.Fatal("expected navigating down onto More to expand additional integrations") + expanded := updated.(model) + if !expanded.showOthers || expanded.cursor != launcherMenuLimit || expanded.items[expanded.cursor].integration == "" { + t.Fatal("Down must expand More and select the first overflow app") } - if got.items[got.cursor].integration == "" { - t.Fatalf("expected cursor to land on the first additional integration, got %#v", got.items[got.cursor]) + updated, _ = expanded.Update(tea.KeyMsg{Type: tea.KeyUp}) + collapsed := updated.(model) + if collapsed.showOthers || collapsed.cursor != launcherMenuLimit-1 { + t.Fatal("Up must return to the last primary app") } - if strings.Contains(got.View(), "More...") { - t.Fatalf("expected expanded integrations to replace More\n%s", got.View()) + if diff := compareStrings(integrationSequence(collapsed.items), root); diff != "" { + t.Fatalf("collapsing More changed the promoted root menu: %s", diff) } } func TestMenuStartsExpandedForPreviousOverflowSelection(t *testing.T) { state := launcherTestState() - overflow := otherIntegrationItems(state) + overflow := buildMenuItems(state, true)[launcherMenuLimit:] if len(overflow) < 2 { t.Fatal("expected at least two additional integrations") } @@ -169,28 +223,19 @@ func TestMenuStartsExpandedForPreviousOverflowSelection(t *testing.T) { if got := menu.items[menu.cursor].integration; got != state.LastSelection { t.Fatalf("initial cursor integration = %q, want %q", got, state.LastSelection) } - if strings.Contains(menu.View(), "More...") { - t.Fatalf("expected expanded menu to omit More\n%s", menu.View()) + for _, item := range menu.items { + if item.isOthers { + t.Fatal("expanded menu must contain apps instead of More") + } } } -func TestMenuEnterOnRunSelectsRun(t *testing.T) { - menu := newModel(launcherTestState()) - updated, _ := menu.Update(tea.KeyMsg{Type: tea.KeyEnter}) - got := updated.(model) - want := TUIAction{Kind: TUIActionRunModel} - if !got.selected || got.action != want { - t.Fatalf("expected enter on run to select run action, got selected=%v action=%v", got.selected, got.action) - } -} - -func TestMenuRightOnRunSelectsChangeRun(t *testing.T) { - menu := newModel(launcherTestState()) - updated, _ := menu.Update(tea.KeyMsg{Type: tea.KeyRight}) - got := updated.(model) - want := TUIAction{Kind: TUIActionRunModel, ForceConfigure: true} - if !got.selected || got.action != want { - t.Fatalf("expected right on run to select change-run action, got selected=%v action=%v", got.selected, got.action) +func TestMenuPreviousRunSelectionFallsBackToFirstApp(t *testing.T) { + state := launcherTestState() + state.LastSelection = "run" + menu := newModel(state) + if menu.cursor != 0 || menu.items[menu.cursor].integration != "claude" { + t.Fatal("previous chat selection must fall back to the first app") } } @@ -246,20 +291,18 @@ func TestMenuIgnoresDisabledActions(t *testing.T) { } } -func TestMenuShowsCurrentModelSuffixes(t *testing.T) { - menu := newModel(launcherTestState()) - runView := menu.View() - if !strings.Contains(runView, "(qwen3:8b)") { - t.Fatalf("expected run row to show current model suffix\n%s", runView) - } - - menu.cursor = findMenuCursorByIntegration(menu.items, "claude") - if menu.cursor == -1 { - t.Fatal("expected claude menu item") - } - integrationView := menu.View() - if !strings.Contains(integrationView, "(glm-5:cloud)") { - t.Fatalf("expected integration row to show current model suffix\n%s", integrationView) +func TestMenuShowsOnlySelectedAppsCurrentModel(t *testing.T) { + state := launcherTestState() + menu := newModel(state) + for i, item := range menu.items { + menu.cursor = i + view := menu.View() + if strings.Contains(view, state.RunModel) { + t.Fatalf("removed chat model must not appear at cursor %d", i) + } + if got, want := strings.Contains(view, state.Integrations["claude"].CurrentModel), item.integration == "claude"; got != want { + t.Fatalf("current model visibility at cursor %d = %v, want %v", i, got, want) + } } } diff --git a/cmd/tui/welcome.go b/cmd/tui/welcome.go new file mode 100644 index 000000000..b08e5447a --- /dev/null +++ b/cmd/tui/welcome.go @@ -0,0 +1,246 @@ +package tui + +import ( + "fmt" + "net/url" + "strings" + "time" + + tea "github.com/charmbracelet/bubbletea" + "github.com/charmbracelet/lipgloss" +) + +type WelcomeAccount struct { + CloudDisabled bool + SignedIn bool + SigninURL string + Err error +} + +type WelcomeOptions struct { + CheckAccount func() WelcomeAccount + OpenBrowser func(string) + IsCompleted func() bool +} + +type welcomeStep int + +const ( + welcomeIntro welcomeStep = iota + welcomeAccount + welcomeSignIn +) + +type welcomeModel struct { + options WelcomeOptions + step welcomeStep + checking bool + account WelcomeAccount + signIn signInModel + cursor int + continued bool + cancelled bool + width int +} + +type ( + welcomeCompletedMsg bool + welcomeAccountMsg WelcomeAccount +) + +func (m welcomeModel) Init() tea.Cmd { + if m.options.IsCompleted == nil { + return nil + } + return tea.Tick(time.Second, func(time.Time) tea.Msg { + return welcomeCompletedMsg(m.options.IsCompleted()) + }) +} + +func (m welcomeModel) checkAccount() (tea.Model, tea.Cmd) { + if m.checking { + return m, nil + } + m.checking = true + return m, func() tea.Msg { + if m.options.CheckAccount == nil { + return welcomeAccountMsg{Err: fmt.Errorf("account check unavailable")} + } + return welcomeAccountMsg(m.options.CheckAccount()) + } +} + +func (m welcomeModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { + if m.continued || m.cancelled { + return m, nil + } + switch msg := msg.(type) { + case welcomeCompletedMsg: + if msg { + m.continued = true + return m, tea.Quit + } + return m, m.Init() + case welcomeAccountMsg: + m.account, m.checking = WelcomeAccount(msg), false + m.cursor = 0 + if m.account.SignedIn || m.account.CloudDisabled { + m.continued = true + return m, tea.Quit + } + m.step = welcomeAccount + case tea.WindowSizeMsg: + m.width = msg.Width + m.signIn.width = msg.Width + case tea.KeyMsg: + if msg.Type == tea.KeyCtrlC || (msg.Type == tea.KeyEsc && m.step != welcomeSignIn) { + m.cancelled = true + return m, tea.Quit + } + } + + if m.step == welcomeSignIn { + updated, cmd := m.signIn.Update(msg) + m.signIn = updated.(signInModel) + if m.signIn.cancelled { + m.step = welcomeAccount + return m, nil // Esc returns to the account choices, not out of onboarding. + } + if m.signIn.userName != "" { + m.continued = true + return m, tea.Quit + } + return m, cmd + } + + if key, ok := msg.(tea.KeyMsg); ok { + switch key.String() { + case "up", "k": + if m.step == welcomeAccount && !m.checking { + m.cursor = max(0, m.cursor-1) + } + case "down", "j": + if m.step == welcomeAccount && !m.checking { + m.cursor = min(len(m.accountChoices())-1, m.cursor+1) + } + case "enter": + if m.step == welcomeIntro { + return m.checkAccount() + } + if m.checking { + return m, nil + } + choices := m.accountChoices() + if m.cursor == len(choices)-1 { + m.continued = true + return m, tea.Quit + } + if m.account.Err != nil || m.account.SigninURL == "" { + return m.checkAccount() + } + signInURL := m.account.SigninURL + u, err := url.Parse(signInURL) + if err != nil || u.Host == "" || (u.Scheme != "https" && u.Scheme != "http") || m.options.OpenBrowser == nil { + m.account.Err = fmt.Errorf("sign-in link unavailable") + m.cursor = 0 + return m, nil + } + query := u.Query() + query.Del("launch") + query.Del("signup") + u.RawQuery = query.Encode() + signInURL = u.String() + m.step = welcomeSignIn + m.signIn = signInModel{modelName: "Ollama Cloud", signInURL: signInURL, width: m.width} + return m, tea.Batch( + func() tea.Msg { m.options.OpenBrowser(signInURL); return nil }, + m.signIn.Init(), + ) + } + } + return m, nil +} + +func (m welcomeModel) View() string { + if m.continued || m.cancelled { + return "" + } + content := m.introView() + if m.step == welcomeAccount || m.step == welcomeSignIn { + content = m.accountView() + } + style := lipgloss.NewStyle().Padding(1, 2) + if m.width > 0 { + style = style.Width(min(m.width, 80)) + } + return style.Render(content) +} + +func (m welcomeModel) introView() string { + var s strings.Builder + s.WriteString(selectorTitleStyle.Render("Welcome to Ollama!")) + s.WriteString("\n\nRun open models with your coding agents so you can spend less\nwhile keeping your data private.\n\n") + s.WriteString(selectorTitleStyle.Render("Connect your apps")) + s.WriteString("\nPower your existing coding apps with open models\n\n") + s.WriteString(selectorTitleStyle.Render("Easily switch models")) + s.WriteString("\nSwap between frontier models in one click.\n\n") + s.WriteString(selectorTitleStyle.Render("Your data stays yours")) + s.WriteString("\nYour prompt data is never logged or trained on.\n\n") + if m.checking { + s.WriteString(selectorDescStyle.Render("Checking your account…")) + } else { + s.WriteString(selectorTitleStyle.Render("Press Enter to continue")) + } + return s.String() +} + +func (m welcomeModel) accountChoices() []string { + if m.account.Err != nil || m.account.SigninURL == "" { + return []string{"Try again", "No thanks, I'll use Ollama locally"} + } + return []string{"Sign up / sign in", "No thanks, I'll use Ollama locally"} +} + +func (m welcomeModel) accountView() string { + var s strings.Builder + s.WriteString(selectorTitleStyle.Render("Create an account")) + s.WriteString("\n\nCreate your account for access to faster, larger open models.\n") + s.WriteString("Your data is never logged or trained on.\n\n") + if m.step == welcomeSignIn { + s.WriteString(selectorDescStyle.Render("Finish in your browser…")) + s.WriteString("\n\n" + m.signIn.signInURL) + s.WriteString("\n\n" + selectorHelpStyle.Render("esc back")) + return s.String() + } + if m.checking { + s.WriteString("Checking your account…\n\n") + s.WriteString(selectorHelpStyle.Render("esc quit")) + return s.String() + } + if m.account.Err != nil || m.account.SigninURL == "" { + s.WriteString("Unable to check your account. Please try again.\n\n") + } + for i, choice := range m.accountChoices() { + if i == m.cursor { + s.WriteString(menuSelectedItemStyle.Render("▸ " + choice)) + } else { + s.WriteString(" " + choice) + } + s.WriteString("\n") + } + s.WriteString("\n\n" + selectorHelpStyle.Render("↑/↓ navigate • enter select • esc quit")) + return s.String() +} + +// RunWelcome introduces Ollama, then offers account setup if needed. Returning +// successfully leads to the regular launcher (or an explicitly requested app). +func RunWelcome(options WelcomeOptions) error { + finalModel, err := tea.NewProgram(welcomeModel{options: options}).Run() + if err != nil { + return fmt.Errorf("show welcome: %w", err) + } + if !finalModel.(welcomeModel).continued { + return ErrCancelled + } + return nil +} diff --git a/cmd/tui/welcome_test.go b/cmd/tui/welcome_test.go new file mode 100644 index 000000000..38e3fdce5 --- /dev/null +++ b/cmd/tui/welcome_test.go @@ -0,0 +1,124 @@ +package tui + +import ( + "errors" + "net/url" + "testing" + + tea "github.com/charmbracelet/bubbletea" +) + +func updateWelcome(m *welcomeModel, msg tea.Msg) tea.Cmd { + updated, cmd := m.Update(msg) + *m = updated.(welcomeModel) + return cmd +} + +func TestWelcomeAccountRouting(t *testing.T) { + for _, account := range []WelcomeAccount{ + {SignedIn: true}, + {CloudDisabled: true}, + {SigninURL: "https://ollama.com/connect?key=test"}, + {Err: errors.New("offline")}, + } { + checks := 0 + m := welcomeModel{options: WelcomeOptions{ + CheckAccount: func() WelcomeAccount { checks++; return account }, + OpenBrowser: func(string) { t.Fatal("Continue must not open the browser") }, + }} + check := updateWelcome(&m, tea.KeyMsg{Type: tea.KeyEnter}) + if check == nil || m.step != welcomeIntro || m.continued { + t.Fatal("Continue must check the account before advancing") + } + if updateWelcome(&m, tea.KeyMsg{Type: tea.KeyEnter}) != nil { + t.Fatal("repeated Enter started another check") + } + updateWelcome(&m, check()) + if checks != 1 || m.continued != (account.SignedIn || account.CloudDisabled) { + t.Fatalf("incorrect account routing: %+v", m) + } + if !account.SignedIn && !account.CloudDisabled { + if m.step != welcomeAccount { + t.Fatal("signed-out users must reach account choices") + } + for range len(m.accountChoices()) - 1 { + updateWelcome(&m, tea.KeyMsg{Type: tea.KeyDown}) + } + updateWelcome(&m, tea.KeyMsg{Type: tea.KeyEnter}) + if !m.continued { + t.Fatal("continuing without an account must finish onboarding, even offline") + } + } + } +} + +func TestWelcomeWaitsForSignIn(t *testing.T) { + opened := "" + m := welcomeModel{ + step: welcomeAccount, account: WelcomeAccount{SigninURL: "https://ollama.com/connect?key=test"}, + options: WelcomeOptions{OpenBrowser: func(raw string) { opened = raw }}, + } + start := updateWelcome(&m, tea.KeyMsg{Type: tea.KeyEnter}) + start().(tea.BatchMsg)[0]() // Open the browser without starting real account polling. + updateWelcome(&m, signInCheckMsg{}) + if opened != m.signIn.signInURL || opened == "" || m.step != welcomeSignIn || m.continued { + t.Fatal("opening the browser alone must not complete onboarding") + } + updateWelcome(&m, signInCheckMsg{signedIn: true, userName: "test-user"}) + if !m.continued { + t.Fatal("confirmed sign-in must complete onboarding") + } +} + +func TestWelcomeAccountDestination(t *testing.T) { + opened := "" + m := welcomeModel{ + step: welcomeAccount, + account: WelcomeAccount{SigninURL: "https://ollama.com/connect?key=test&launch=claude&signup=true"}, + options: WelcomeOptions{OpenBrowser: func(raw string) { opened = raw }}, + } + start := updateWelcome(&m, tea.KeyMsg{Type: tea.KeyEnter}) + start().(tea.BatchMsg)[0]() + u, err := url.Parse(opened) + if err != nil { + t.Fatal(err) + } + if u.Path != "/connect" || u.Query().Has("signup") || u.Query().Has("launch") || u.Query().Get("key") != "test" { + t.Fatalf("incorrect account destination: %s", opened) + } +} + +func TestWelcomeCancellation(t *testing.T) { + for _, step := range []welcomeStep{welcomeIntro, welcomeAccount, welcomeSignIn} { + for _, key := range []tea.KeyType{tea.KeyEsc, tea.KeyCtrlC} { + m := welcomeModel{step: step} + quit := updateWelcome(&m, tea.KeyMsg{Type: key}) + back := step == welcomeSignIn && key == tea.KeyEsc + if m.continued || m.cancelled == back || (quit == nil) != back || (back && m.step != welcomeAccount) { + t.Fatalf("cancellation incorrectly completed onboarding: %+v", m) + } + } + } +} + +func TestWelcomeInvalidSignInLink(t *testing.T) { + m := welcomeModel{ + step: welcomeAccount, account: WelcomeAccount{SigninURL: "file:///tmp/connect"}, + options: WelcomeOptions{OpenBrowser: func(string) { t.Fatal("invalid sign-in link opened") }}, + } + if cmd := updateWelcome(&m, tea.KeyMsg{Type: tea.KeyEnter}); cmd != nil || m.account.Err == nil || m.continued { + t.Fatal("invalid sign-in link must leave onboarding pending") + } +} + +func TestWelcomeCompletedInApp(t *testing.T) { + for _, step := range []welcomeStep{welcomeIntro, welcomeAccount, welcomeSignIn} { + m := welcomeModel{step: step, options: WelcomeOptions{IsCompleted: func() bool { return true }}} + if m.Init() == nil { + t.Fatal("welcome must watch shared completion") + } + if quit := updateWelcome(&m, welcomeCompletedMsg(true)); !m.continued || quit == nil { + t.Fatal("app completion must dismiss CLI onboarding") + } + } +} diff --git a/cmd/welcome.go b/cmd/welcome.go new file mode 100644 index 000000000..4c015de38 --- /dev/null +++ b/cmd/welcome.go @@ -0,0 +1,77 @@ +package cmd + +import ( + "context" + "errors" + "fmt" + "net/http" + "os" + "strings" + "time" + + "github.com/ollama/ollama/api" + "github.com/ollama/ollama/cmd/config" + "github.com/ollama/ollama/cmd/launch" + "github.com/ollama/ollama/cmd/tui" + "github.com/spf13/cobra" + "golang.org/x/term" +) + +func runWelcome(ctx context.Context) error { + if !term.IsTerminal(int(os.Stdin.Fd())) || !term.IsTerminal(int(os.Stdout.Fd())) { + return nil + } + return ensureWelcome(func() error { + return tui.RunWelcome(tui.WelcomeOptions{ + CheckAccount: func() tui.WelcomeAccount { return checkWelcomeAccount(ctx) }, + OpenBrowser: launch.OpenBrowser, + IsCompleted: func() bool { + needed, err := config.NeedsWelcome() + return err == nil && !needed + }, + }) + }) +} + +func checkWelcomeAccount(ctx context.Context) tui.WelcomeAccount { + ctx, cancel := context.WithTimeout(ctx, 5*time.Second) + defer cancel() + cmd := &cobra.Command{} + cmd.SetContext(ctx) + if err := checkServerHeartbeat(cmd, nil); err != nil { + return tui.WelcomeAccount{Err: err} + } + client, err := api.ClientFromEnvironment() + if err != nil { + return tui.WelcomeAccount{Err: err} + } + if status, err := client.CloudStatusExperimental(ctx); err == nil && status.Cloud.Disabled { + return tui.WelcomeAccount{CloudDisabled: true} + } + user, err := client.Whoami(ctx) + if err != nil { + var authErr api.AuthorizationError + if errors.As(err, &authErr) && authErr.StatusCode == http.StatusUnauthorized && authErr.SigninURL != "" { + return tui.WelcomeAccount{SigninURL: authErr.SigninURL} + } + return tui.WelcomeAccount{Err: err} + } + if user != nil && strings.TrimSpace(user.Name) != "" { + return tui.WelcomeAccount{SignedIn: true} + } + return tui.WelcomeAccount{Err: fmt.Errorf("could not verify the Ollama account")} +} + +func ensureWelcome(show func() error) error { + needed, err := config.NeedsWelcome() + if err != nil { + return err + } + if !needed { + return nil + } + if err := show(); err != nil { + return err + } + return config.CompleteWelcome() +} diff --git a/cmd/welcome_test.go b/cmd/welcome_test.go new file mode 100644 index 000000000..641d58526 --- /dev/null +++ b/cmd/welcome_test.go @@ -0,0 +1,79 @@ +package cmd + +import ( + "context" + "errors" + "fmt" + "net/http" + "net/http/httptest" + "testing" + + "github.com/ollama/ollama/cmd/config" + "github.com/ollama/ollama/cmd/launch" +) + +func TestWelcomeCloudStatus(t *testing.T) { + for _, status := range []string{`{"cloud":{"disabled":true}}`, `{"cloud":{"disabled":false}}`, "unavailable"} { + t.Run(status, func(t *testing.T) { + accountRequests := 0 + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + switch r.URL.Path { + case "/": + case "/api/status": + if status == "unavailable" { + w.WriteHeader(http.StatusNotFound) + return + } + fmt.Fprint(w, status) + case "/api/me": + accountRequests++ + fmt.Fprint(w, `{"name":"test-user"}`) + default: + t.Errorf("unexpected request: %s", r.URL.Path) + w.WriteHeader(http.StatusNotFound) + } + })) + defer server.Close() + t.Setenv("OLLAMA_HOST", server.URL) + account := checkWelcomeAccount(context.Background()) + disabled := status == `{"cloud":{"disabled":true}}` + if account.Err != nil || account.CloudDisabled != disabled || account.SignedIn == disabled { + t.Fatalf("unexpected account state: %+v", account) + } + wantRequests := 1 + if disabled { + wantRequests = 0 + } + if accountRequests != wantRequests { + t.Fatalf("account requests = %d, want %d", accountRequests, wantRequests) + } + }) + } +} + +func TestWelcomeOnceAfterCompletion(t *testing.T) { + setCmdTestHome(t, t.TempDir()) + t.Setenv("LOCALAPPDATA", t.TempDir()) + if err := config.SaveIntegration("claude", []string{"saved-model"}); err != nil { + t.Fatal(err) + } + shows := 0 + cancelled := true + show := func() error { + shows++ + if cancelled { + return launch.ErrCancelled + } + return nil + } + if err := ensureWelcome(show); !errors.Is(err, launch.ErrCancelled) || shows != 1 { + t.Fatalf("cancelled welcome: shows=%d err=%v", shows, err) + } + cancelled = false + if err := ensureWelcome(show); err != nil || shows != 2 { + t.Fatalf("unfinished welcome must reappear: shows=%d err=%v", shows, err) + } + if err := ensureWelcome(show); err != nil || shows != 2 { + t.Fatalf("completed welcome must not repeat: shows=%d err=%v", shows, err) + } +} diff --git a/internal/onboarding/app_state.go b/internal/onboarding/app_state.go new file mode 100644 index 000000000..faf21dc94 --- /dev/null +++ b/internal/onboarding/app_state.go @@ -0,0 +1,72 @@ +package onboarding + +import ( + "context" + "database/sql" + "log/slog" + "net/url" + "os" + "path/filepath" + "runtime" + "strings" + "time" + + _ "github.com/mattn/go-sqlite3" +) + +// CompletedInApp checks the existing desktop database without starting the app +// or creating, migrating, or updating its database. An unavailable database is +// not proof of completion and must not prevent the CLI from offering welcome. +func CompletedInApp() bool { + return readAppCompletion(AppDatabasePath()) +} + +// AppDatabasePath is the production desktop database location shared by the +// app store and the CLI's read-only lookup. Empty means no desktop database. +func AppDatabasePath() string { + home, _ := os.UserHomeDir() + localAppData := os.Getenv("LOCALAPPDATA") + switch { + case runtime.GOOS == "darwin" && home != "": + return filepath.Join(home, "Library", "Application Support", "Ollama", "db.sqlite") + case runtime.GOOS == "windows" && localAppData != "": + return filepath.Join(localAppData, "Ollama", "db.sqlite") + default: + return "" + } +} + +func readAppCompletion(path string) bool { + if path == "" { + return false + } + info, err := os.Stat(path) + if err != nil || !info.Mode().IsRegular() { + return false + } + + uriPath := filepath.ToSlash(path) + if !strings.HasPrefix(uriPath, "/") { + uriPath = "/" + uriPath // Windows drive-letter paths need file:///C:/... + } + u := url.URL{Scheme: "file", Path: uriPath, RawQuery: "mode=ro&_query_only=1&_busy_timeout=200"} + db, err := sql.Open("sqlite3", u.String()) + if err != nil { + return false + } + defer db.Close() + + ctx, cancel := context.WithTimeout(context.Background(), 250*time.Millisecond) + defer cancel() + var version int + if err := db.QueryRowContext(ctx, "SELECT onboarding_version FROM settings WHERE id = 1").Scan(&version); err != nil { + // Migration 16 -> 17 marks existing users completed at onboarding version 1. + var schema int + if err := db.QueryRowContext(ctx, "SELECT schema_version FROM settings WHERE id = 1").Scan(&schema); err == nil && schema >= 1 && schema <= 16 { + return CurrentVersion <= 1 + } + slog.Debug("could not read app onboarding completion", "error", err) + return false + } + return version >= CurrentVersion +} diff --git a/internal/onboarding/state.go b/internal/onboarding/state.go new file mode 100644 index 000000000..5f76e0cee --- /dev/null +++ b/internal/onboarding/state.go @@ -0,0 +1,75 @@ +// Package onboarding stores the welcome completion shared by the app and CLI. +// It is local to the OS user and independent of account authentication. +package onboarding + +import ( + "errors" + "fmt" + "os" + "path/filepath" +) + +const CurrentVersion = 1 + +// State uses ~/.ollama by default. Dir isolates custom app stores and tests. +type State struct { + Dir string +} + +func (s State) path() (string, error) { + dir := s.Dir + if dir == "" { + home, err := os.UserHomeDir() + if err != nil { + return "", err + } + dir = filepath.Join(home, ".ollama") + } + return filepath.Join(dir, fmt.Sprintf("onboarding-v%d.completed", CurrentVersion)), nil +} + +func (s State) Completed() (bool, error) { + path, err := s.path() + if err != nil { + return false, err + } + info, err := os.Stat(path) + if err == nil { + if !info.Mode().IsRegular() { + return false, fmt.Errorf("onboarding completion is not a regular file: %s", path) + } + return true, nil + } + if !errors.Is(err, os.ErrNotExist) { + return false, err + } + + return false, nil +} + +// Complete creates an immutable marker. Concurrent app and CLI completion +// cannot overwrite each other's model settings or undo completed onboarding. +func (s State) Complete() error { + path, err := s.path() + if err != nil { + return err + } + if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil { + return err + } + file, err := os.OpenFile(path, os.O_WRONLY|os.O_CREATE|os.O_EXCL, 0o600) + if errors.Is(err, os.ErrExist) { + info, err := os.Stat(path) + if err != nil { + return err + } + if !info.Mode().IsRegular() { + return fmt.Errorf("onboarding completion is not a regular file: %s", path) + } + return nil + } + if err != nil { + return err + } + return file.Close() +}