cli: add first-run onboarding shared with the desktop app (#18495)

This commit is contained in:
Eva H
2026-09-17 11:49:24 -07:00
committed by GitHub
parent ec3cc23076
commit 7fa8a024b7
19 changed files with 1229 additions and 157 deletions
+6 -1
View File
@@ -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
+47 -13
View File
@@ -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 {
+126
View File
@@ -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()
@@ -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) => {
+4 -1
View File
@@ -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
+2 -1
View File
@@ -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" });
+21 -2
View File
@@ -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<string | null>(null);
const [completionError, setCompletionError] = useState<string | null>(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<boolean> => {
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;
+1
View File
@@ -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
+6
View File
@@ -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)
},
}
+54 -3
View File
@@ -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) {
+84
View File
@@ -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)
+41 -63
View File
@@ -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:
+114 -71
View File
@@ -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)
}
}
}
+246
View File
@@ -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
}
+124
View File
@@ -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")
}
}
}
+77
View File
@@ -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()
}
+79
View File
@@ -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)
}
}
+72
View File
@@ -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
}
+75
View File
@@ -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()
}