mirror of
https://github.com/ollama/ollama.git
synced 2026-09-21 13:38:14 -05:00
cli: add first-run onboarding shared with the desktop app (#18495)
This commit is contained in:
@@ -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
@@ -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 {
|
||||
|
||||
@@ -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) => {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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" });
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
@@ -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) {
|
||||
|
||||
@@ -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
@@ -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
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
Reference in New Issue
Block a user