Add first-run setup wizard

This commit is contained in:
Vegard Stikbakke
2026-05-28 12:08:22 +02:00
Unverified
parent b85bf65678
commit ac8b26b8f6
16 changed files with 1027 additions and 75 deletions
+4
View File
@@ -2,6 +2,10 @@
## [Unreleased]
### Added
- Added a first-run setup wizard with automatic theme detection, analytics/crash-reporting consent, and optional provider login.
### Fixed
- Fixed fenced `diff` code blocks and other highlight.js scopes to keep theme-aware syntax colors after the `cli-highlight` replacement ([#5092](https://github.com/earendil-works/pi/issues/5092)).
+6 -5
View File
@@ -174,10 +174,11 @@ Type `/` in the editor to trigger commands. [Extensions](#extensions) can regist
| Command | Description |
|---------|-------------|
| `/login`, `/logout` | OAuth authentication |
| `/login`, `/logout` | Manage OAuth or API-key credentials |
| `/model` | Switch models |
| `/scoped-models` | Enable/disable models for Ctrl+P cycling |
| `/settings` | Thinking level, theme, message delivery, transport |
| `/setup` | Review setup: theme, telemetry, and provider login |
| `/resume` | Pick from previous sessions |
| `/new` | Start a new session |
| `/name <name>` | Set session display name |
@@ -289,9 +290,9 @@ See [docs/settings.md](docs/settings.md) for all options.
Pi has two separate startup features:
- **Update check:** fetches `https://pi.dev/api/latest-version` to check whether a newer Pi version exists. Disable it with `PI_SKIP_VERSION_CHECK=1`. Disabling update checks only turns off this check.
- **Install/update telemetry:** after first install or a changelog-detected update, sends an anonymous version ping to `https://pi.dev/api/report-install`. Opt out by setting `enableInstallTelemetry` to `false` in `settings.json`, or by setting `PI_TELEMETRY=0`. This does not disable update checks; Pi may still contact `pi.dev` for the latest version unless update checks are disabled or offline mode is enabled.
- **Telemetry:** when enabled during setup, Pi can send anonymous diagnostics, including install/update version pings to `https://pi.dev/api/report-install` and crash reports when available. Change it with `pi setup`, `/setup`, `telemetry.enabled` in `settings.json`, or `PI_TELEMETRY`. This does not disable update checks; Pi may still contact `pi.dev` for the latest version unless update checks are disabled or offline mode is enabled.
Use `--offline` or `PI_OFFLINE=1` to disable all startup network operations described here, including update checks, package update checks, and install/update telemetry.
Use `--offline` or `PI_OFFLINE=1` to disable all startup network operations described here, including update checks, package update checks, and telemetry.
---
@@ -630,9 +631,9 @@ pi --thinking high "Solve this complex problem"
| `PI_CODING_AGENT_DIR` | Override config directory (default: `~/.pi/agent`) |
| `PI_CODING_AGENT_SESSION_DIR` | Override session storage directory (overridden by `--session-dir`) |
| `PI_PACKAGE_DIR` | Override package directory (useful for Nix/Guix where store paths tokenize poorly) |
| `PI_OFFLINE` | Disable startup network operations, including update checks, package update checks, and install/update telemetry |
| `PI_OFFLINE` | Disable startup network operations, including update checks, package update checks, and telemetry |
| `PI_SKIP_VERSION_CHECK` | Skip the Pi version update check at startup. This prevents the `pi.dev` latest-version request |
| `PI_TELEMETRY` | Override install/update telemetry. Use `1`/`true`/`yes` to enable or `0`/`false`/`no` to disable. This does not disable update checks |
| `PI_TELEMETRY` | Override telemetry. Use `1`/`true`/`yes` to enable or `0`/`false`/`no` to disable. This does not disable update checks |
| `PI_CACHE_RETENTION` | Set to `long` for extended prompt cache (Anthropic: 1h, OpenAI: 24h) |
| `VISUAL`, `EDITOR` | External editor for Ctrl+G |
+4 -3
View File
@@ -41,7 +41,8 @@ Edit directly or use `/settings` for common options.
| `theme` | string | `"dark"` | Theme name (`"dark"`, `"light"`, or custom) |
| `quietStartup` | boolean | `false` | Hide startup header |
| `collapseChangelog` | boolean | `false` | Show condensed changelog after updates |
| `enableInstallTelemetry` | boolean | `true` | Send an anonymous install/update version ping after first install or changelog-detected updates. This does not control update checks |
| `telemetry.enabled` | boolean | `false` | Allow anonymous diagnostics, including install/update version pings and crash reports when available. This does not control update checks |
| `enableInstallTelemetry` | boolean | - | Legacy alias for install/update telemetry; ignored when `telemetry.enabled` is set |
| `doubleEscapeAction` | string | `"tree"` | Action for double-escape: `"tree"`, `"fork"`, or `"none"` |
| `treeFilterMode` | string | `"default"` | Default filter for `/tree`: `"default"`, `"no-tools"`, `"user-only"`, `"labeled-only"`, `"all"` |
| `editorPaddingX` | number | `0` | Horizontal padding for input editor (0-3) |
@@ -50,9 +51,9 @@ Edit directly or use `/settings` for common options.
### Telemetry and update checks
`enableInstallTelemetry` only controls the anonymous install/update ping to `https://pi.dev/api/report-install`. Opting out of telemetry does not disable update checks; Pi can still fetch `https://pi.dev/api/latest-version` to look for the latest version.
`telemetry.enabled` controls anonymous diagnostics, including the install/update version ping to `https://pi.dev/api/report-install` and crash reports when crash reporting is available. Opting out of telemetry does not disable update checks; Pi can still fetch `https://pi.dev/api/latest-version` to look for the latest version.
Set `PI_SKIP_VERSION_CHECK=1` to disable the Pi version update check. Use `--offline` or `PI_OFFLINE=1` to disable all startup network operations described here, including update checks, package update checks, and install/update telemetry.
Set `PI_SKIP_VERSION_CHECK=1` to disable the Pi version update check. Use `--offline` or `PI_OFFLINE=1` to disable all startup network operations described here, including update checks, package update checks, and telemetry.
### Warnings
+3 -2
View File
@@ -39,6 +39,7 @@ Type `/` in the editor to open command completion. Extensions can register custo
| `/model` | Switch models |
| `/scoped-models` | Enable/disable models for Ctrl+P cycling |
| `/settings` | Thinking level, theme, message delivery, transport |
| `/setup` | Review setup: theme, telemetry, and provider login |
| `/resume` | Pick from previous sessions |
| `/new` | Start a new session |
| `/name <name>` | Set session display name |
@@ -264,9 +265,9 @@ pi --tools read,grep,find,ls -p "Review the code"
| `PI_CODING_AGENT_DIR` | Override config directory; default is `~/.pi/agent` |
| `PI_CODING_AGENT_SESSION_DIR` | Override session storage directory; overridden by `--session-dir` |
| `PI_PACKAGE_DIR` | Override package directory, useful for Nix/Guix store paths |
| `PI_OFFLINE` | Disable startup network operations, including update checks, package update checks, and install/update telemetry |
| `PI_OFFLINE` | Disable startup network operations, including update checks, package update checks, and telemetry |
| `PI_SKIP_VERSION_CHECK` | Skip the Pi version update check at startup. This prevents the `pi.dev` latest-version request |
| `PI_TELEMETRY` | Override install/update telemetry: `1`/`true`/`yes` or `0`/`false`/`no`. This does not disable update checks |
| `PI_TELEMETRY` | Override telemetry: `1`/`true`/`yes` or `0`/`false`/`no`. This does not disable update checks |
| `PI_CACHE_RETENTION` | Set to `long` for extended prompt cache where supported |
| `VISUAL`, `EDITOR` | External editor for Ctrl+G |
+7 -2
View File
@@ -44,6 +44,7 @@ export interface Args {
listModels?: string | true;
offline?: boolean;
verbose?: boolean;
noSetup?: boolean;
messages: string[];
fileArgs: string[];
/** Unknown flags (potentially extension flags) - map of flag name to value */
@@ -163,6 +164,8 @@ export function parseArgs(args: string[]): Args {
}
} else if (arg === "--verbose") {
result.verbose = true;
} else if (arg === "--no-setup") {
result.noSetup = true;
} else if (arg === "--offline") {
result.offline = true;
} else if (arg.startsWith("@")) {
@@ -214,7 +217,8 @@ ${chalk.bold("Commands:")}
${APP_NAME} update [source|self|pi] Update pi and installed extensions
${APP_NAME} list List installed extensions from settings
${APP_NAME} config Open TUI to enable/disable package resources
${APP_NAME} <command> --help Show help for install/remove/uninstall/update/list
${APP_NAME} setup Run the interactive setup wizard
${APP_NAME} <command> --help Show help for install/remove/uninstall/update/list/setup
${chalk.bold("Options:")}
--provider <name> Provider name (default: google)
@@ -250,6 +254,7 @@ ${chalk.bold("Options:")}
--export <file> Export session file to HTML and exit
--list-models [search] List available models (with optional fuzzy search)
--verbose Force verbose startup (overrides quietStartup setting)
--no-setup Skip proactive first-run setup
--offline Disable startup network operations (same as PI_OFFLINE=1)
--help, -h Show this help
--version, -v Show version number
@@ -343,7 +348,7 @@ ${chalk.bold("Environment Variables:")}
${ENV_SESSION_DIR.padEnd(32)} - Session storage directory (overridden by --session-dir)
PI_PACKAGE_DIR - Override package directory (for Nix/Guix store paths)
PI_OFFLINE - Disable startup network operations when set to 1/true/yes
PI_TELEMETRY - Override install telemetry when set to 1/true/yes or 0/false/no
PI_TELEMETRY - Override telemetry when set to 1/true/yes or 0/false/no
PI_SHARE_VIEWER_URL - Base URL for /share command (default: https://pi.dev/session/)
${chalk.bold("Built-in Tool Names:")}
@@ -0,0 +1,104 @@
import { Container, ProcessTerminal, setKeybindings, Text, TUI } from "@earendil-works/pi-tui";
import chalk from "chalk";
import { APP_NAME, getAgentDir, VERSION } from "../config.ts";
import { KeybindingsManager } from "../core/keybindings.ts";
import { DefaultResourceLoader } from "../core/resource-loader.ts";
import { SettingsManager } from "../core/settings-manager.ts";
import { runSetupWizard } from "../modes/interactive/setup-wizard.ts";
import { initTheme, setRegisteredThemes, stopThemeWatcher, theme } from "../modes/interactive/theme/theme.ts";
function printSetupHelp(): void {
console.log(`${chalk.bold("Usage:")}
${APP_NAME} setup
Run the interactive setup wizard.
`);
}
function reportSettingsErrors(settingsManager: SettingsManager): void {
for (const { scope, error } of settingsManager.drainErrors()) {
console.error(chalk.yellow(`Warning (setup command, ${scope} settings): ${error.message}`));
}
}
export async function handleSetupCommand(args: string[]): Promise<boolean> {
if (args[0] !== "setup") {
return false;
}
const rest = args.slice(1);
if (rest.includes("--help") || rest.includes("-h")) {
printSetupHelp();
return true;
}
const unexpected = rest.find((arg) => arg !== "--help" && arg !== "-h");
if (unexpected) {
console.error(chalk.red(`Unexpected argument ${unexpected}.`));
console.error(chalk.dim(`Usage: ${APP_NAME} setup`));
process.exitCode = 1;
return true;
}
if (process.stdin.isTTY !== true || process.stdout.isTTY !== true) {
console.error(chalk.red(`${APP_NAME} setup requires an interactive terminal.`));
process.exitCode = 1;
return true;
}
const cwd = process.cwd();
const agentDir = getAgentDir();
const settingsManager = SettingsManager.create(cwd, agentDir);
reportSettingsErrors(settingsManager);
const resourceLoader = new DefaultResourceLoader({
cwd,
agentDir,
settingsManager,
noExtensions: true,
noSkills: true,
noPromptTemplates: true,
noContextFiles: true,
});
await resourceLoader.reload();
setRegisteredThemes(resourceLoader.getThemes().themes);
for (const diagnostic of resourceLoader.getThemes().diagnostics) {
const prefix = diagnostic.path ? `${diagnostic.path}: ` : "";
console.error(chalk.yellow(`Warning (setup command, theme): ${prefix}${diagnostic.message}`));
}
setKeybindings(KeybindingsManager.create());
initTheme(settingsManager.getTheme(), true);
const tui = new TUI(new ProcessTerminal(), settingsManager.getShowHardwareCursor());
tui.setClearOnShrink(settingsManager.getClearOnShrink());
const headerContainer = new Container();
headerContainer.addChild(
new Text(`${theme.bold(theme.fg("accent", APP_NAME))} ${theme.fg("dim", `v${VERSION}`)}\n`, 1, 0),
);
const setupContainer = new Container();
tui.addChild(headerContainer);
tui.addChild(setupContainer);
tui.start();
let completed = false;
let loginRequest: "oauth" | "api_key" | undefined;
try {
const result = await runSetupWizard({
tui,
settingsManager,
agentDir,
mode: "manual",
container: setupContainer,
});
completed = result.completed;
loginRequest = result.loginRequest;
} finally {
tui.stop();
stopThemeWatcher();
}
console.log(completed ? chalk.green("Setup complete.") : chalk.dim("Setup cancelled."));
if (loginRequest) {
const method = loginRequest === "oauth" ? "subscription login" : "API key login";
console.log(chalk.dim(`Start interactive Pi and run /login to continue ${method}.`));
}
return true;
}
@@ -57,6 +57,10 @@ export interface WarningSettings {
anthropicExtraUsage?: boolean; // default: true
}
export interface TelemetrySettings {
enabled?: boolean; // default: true unless disabled during setup
}
export type TransportSetting = Transport;
/**
@@ -92,7 +96,8 @@ export interface Settings {
shellCommandPrefix?: string; // Prefix prepended to every bash command (e.g., "shopt -s expand_aliases" for alias support)
npmCommand?: string[]; // Command used for npm package lookup/install operations, argv-style (e.g., ["mise", "exec", "node@20", "--", "npm"])
collapseChangelog?: boolean; // Show condensed changelog after update (use /changelog for full)
enableInstallTelemetry?: boolean; // default: true - anonymous version/update ping after changelog-detected updates
telemetry?: TelemetrySettings; // Anonymous analytics and crash-reporting consent
enableInstallTelemetry?: boolean; // Legacy setting; telemetry.enabled takes precedence
packages?: PackageSource[]; // Array of npm/git package sources (string or object with filtering)
extensions?: string[]; // Array of local extension file paths or directories
skills?: string[]; // Array of local skill file paths or directories
@@ -818,14 +823,25 @@ export class SettingsManager {
this.save();
}
getTelemetryEnabled(): boolean {
return this.settings.telemetry?.enabled ?? this.settings.enableInstallTelemetry ?? false;
}
setTelemetryEnabled(enabled: boolean): void {
if (!this.globalSettings.telemetry) {
this.globalSettings.telemetry = {};
}
this.globalSettings.telemetry.enabled = enabled;
this.markModified("telemetry", "enabled");
this.save();
}
getEnableInstallTelemetry(): boolean {
return this.settings.enableInstallTelemetry ?? true;
return this.getTelemetryEnabled();
}
setEnableInstallTelemetry(enabled: boolean): void {
this.globalSettings.enableInstallTelemetry = enabled;
this.markModified("enableInstallTelemetry");
this.save();
this.setTelemetryEnabled(enabled);
}
getPackages(): PackageSource[] {
@@ -0,0 +1,172 @@
import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
import { dirname, join } from "node:path";
import { getAgentDir } from "../config.ts";
export const SETUP_STEPS = [
{ id: "theme", introducedIn: 1 },
{ id: "telemetry", introducedIn: 1 },
{ id: "login", introducedIn: 1 },
] as const;
export const CURRENT_SETUP_VERSION = SETUP_STEPS.reduce(
(maxVersion, step) => Math.max(maxVersion, step.introducedIn),
0,
);
export type SetupStepId = (typeof SETUP_STEPS)[number]["id"];
export interface SetupStepState {
completedAt: string;
setupVersion: number;
}
export interface SetupState {
schemaVersion: 1;
completedVersion: number;
completedAt?: string;
steps: Record<string, SetupStepState>;
}
function createEmptySetupState(): SetupState {
return {
schemaVersion: 1,
completedVersion: 0,
steps: {},
};
}
function isSetupStepState(value: unknown): value is SetupStepState {
return (
typeof value === "object" &&
value !== null &&
"completedAt" in value &&
typeof value.completedAt === "string" &&
"setupVersion" in value &&
typeof value.setupVersion === "number" &&
Number.isFinite(value.setupVersion)
);
}
function normalizeSetupState(value: unknown): SetupState | undefined {
if (typeof value !== "object" || value === null) {
return undefined;
}
const record = value as Record<string, unknown>;
const rawSteps = record.steps;
const steps: Record<string, SetupStepState> = {};
if (typeof rawSteps === "object" && rawSteps !== null) {
for (const [stepId, stepState] of Object.entries(rawSteps)) {
if (isSetupStepState(stepState)) {
steps[stepId] = {
completedAt: stepState.completedAt,
setupVersion: Math.floor(stepState.setupVersion),
};
}
}
}
const completedVersion =
typeof record.completedVersion === "number" && Number.isFinite(record.completedVersion)
? Math.max(0, Math.floor(record.completedVersion))
: computeCompletedVersion(steps);
const state: SetupState = {
schemaVersion: 1,
completedVersion,
steps,
};
if (typeof record.completedAt === "string") {
state.completedAt = record.completedAt;
}
return state;
}
function computeCompletedVersion(steps: Record<string, SetupStepState>): number {
const versions = Array.from(new Set(SETUP_STEPS.map((step) => step.introducedIn))).sort((a, b) => a - b);
let completedVersion = 0;
for (const version of versions) {
const completeThroughVersion = SETUP_STEPS.every(
(step) => step.introducedIn > version || steps[step.id] !== undefined,
);
if (!completeThroughVersion) {
break;
}
completedVersion = version;
}
return completedVersion;
}
function finalizeSetupState(state: SetupState): SetupState {
const completedVersion = computeCompletedVersion(state.steps);
const next: SetupState = {
...state,
schemaVersion: 1,
completedVersion,
steps: { ...state.steps },
};
if (SETUP_STEPS.every((step) => next.steps[step.id] !== undefined)) {
next.completedAt = SETUP_STEPS.reduce<string | undefined>((latest, step) => {
const completedAt = next.steps[step.id]?.completedAt;
if (!completedAt) {
return latest;
}
return latest === undefined || completedAt > latest ? completedAt : latest;
}, next.completedAt);
} else {
delete next.completedAt;
}
return next;
}
export function getSetupStatePath(agentDir: string = getAgentDir()): string {
return join(agentDir, "setup.json");
}
export function readSetupState(agentDir: string = getAgentDir()): SetupState | undefined {
const setupPath = getSetupStatePath(agentDir);
if (!existsSync(setupPath)) {
return undefined;
}
try {
return normalizeSetupState(JSON.parse(readFileSync(setupPath, "utf-8")));
} catch {
return undefined;
}
}
export function writeSetupState(state: SetupState, agentDir: string = getAgentDir()): void {
const setupPath = getSetupStatePath(agentDir);
mkdirSync(dirname(setupPath), { recursive: true });
writeFileSync(setupPath, `${JSON.stringify(finalizeSetupState(state), null, 2)}\n`, "utf-8");
}
export function getAllSetupStepIds(): SetupStepId[] {
return SETUP_STEPS.map((step) => step.id);
}
export function getPendingSetupStepIds(agentDir: string = getAgentDir()): SetupStepId[] {
const state = readSetupState(agentDir) ?? createEmptySetupState();
return SETUP_STEPS.filter((step) => state.steps[step.id] === undefined).map((step) => step.id);
}
export function hasPendingSetupSteps(agentDir: string = getAgentDir()): boolean {
return getPendingSetupStepIds(agentDir).length > 0;
}
export function markSetupStepComplete(
stepId: SetupStepId,
agentDir: string = getAgentDir(),
completedAt: Date = new Date(),
): void {
const state = readSetupState(agentDir) ?? createEmptySetupState();
const step = SETUP_STEPS.find((candidate) => candidate.id === stepId);
if (!step) {
return;
}
state.steps[stepId] = {
completedAt: completedAt.toISOString(),
setupVersion: step.introducedIn,
};
writeSetupState(state, agentDir);
}
@@ -17,6 +17,7 @@ export interface BuiltinSlashCommand {
export const BUILTIN_SLASH_COMMANDS: ReadonlyArray<BuiltinSlashCommand> = [
{ name: "settings", description: "Open settings menu" },
{ name: "setup", description: "Run setup wizard" },
{ name: "model", description: "Select model (opens selector UI)" },
{ name: "scoped-models", description: "Enable/disable models for Ctrl+P cycling" },
{ name: "export", description: "Export session (HTML default, or specify path: .html/.jsonl)" },
+1 -1
View File
@@ -9,5 +9,5 @@ export function isInstallTelemetryEnabled(
settingsManager: SettingsManager,
telemetryEnv: string | undefined = process.env.PI_TELEMETRY,
): boolean {
return telemetryEnv !== undefined ? isTruthyEnvFlag(telemetryEnv) : settingsManager.getEnableInstallTelemetry();
return telemetryEnv !== undefined ? isTruthyEnvFlag(telemetryEnv) : settingsManager.getTelemetryEnabled();
}
+1
View File
@@ -217,6 +217,7 @@ export {
type PackageSource,
type RetrySettings,
SettingsManager,
type TelemetrySettings,
} from "./core/settings-manager.ts";
// Skills
export {
+6
View File
@@ -14,6 +14,7 @@ import { processFileArguments } from "./cli/file-processor.ts";
import { buildInitialMessage } from "./cli/initial-message.ts";
import { listModels } from "./cli/list-models.ts";
import { selectSession } from "./cli/session-picker.ts";
import { handleSetupCommand } from "./cli/setup-command.ts";
import { ENV_SESSION_DIR, expandTildePath, getAgentDir, getPackageDir, VERSION } from "./config.ts";
import { type CreateAgentSessionRuntimeFactory, createAgentSessionRuntime } from "./core/agent-session-runtime.ts";
import {
@@ -483,6 +484,10 @@ export async function main(args: string[], options?: MainOptions) {
cleanupWindowsSelfUpdateQuarantine(getPackageDir());
}
if (await handleSetupCommand(args)) {
return;
}
if (await handlePackageCommand(args)) {
return;
}
@@ -737,6 +742,7 @@ export async function main(args: string[], options?: MainOptions) {
initialImages,
initialMessages: parsed.messages,
verbose: parsed.verbose,
runSetup: !startupBenchmark && !parsed.noSetup,
});
if (startupBenchmark) {
await interactiveMode.init();
@@ -17,6 +17,11 @@ export type AuthSelectorProvider = {
authType: "oauth" | "api_key";
};
export interface OAuthSelectorOptions {
logoLines?: readonly string[];
border?: boolean;
}
/**
* Component that renders an auth provider selector
*/
@@ -50,6 +55,7 @@ export class OAuthSelectorComponent extends Container implements Focusable {
onSelect: (providerId: string) => void,
onCancel: () => void,
getAuthStatus?: (providerId: string) => AuthStatus,
options: OAuthSelectorOptions = {},
) {
super();
@@ -61,9 +67,19 @@ export class OAuthSelectorComponent extends Container implements Focusable {
this.onSelectCallback = onSelect;
this.onCancelCallback = onCancel;
// Add top border
this.addChild(new DynamicBorder());
this.addChild(new Spacer(1));
const showBorder = options.border ?? true;
if (showBorder) {
this.addChild(new DynamicBorder());
this.addChild(new Spacer(1));
}
for (const line of options.logoLines ?? []) {
this.addChild(new TruncatedText(theme.fg("accent", ` ${line}`), 1, 0));
}
if (options.logoLines && options.logoLines.length > 0) {
this.addChild(new Spacer(1));
}
// Add title
const title = mode === "login" ? "Select provider to configure:" : "Select provider to logout:";
@@ -86,8 +102,9 @@ export class OAuthSelectorComponent extends Container implements Focusable {
this.addChild(new Spacer(1));
// Add bottom border
this.addChild(new DynamicBorder());
if (showBorder) {
this.addChild(new DynamicBorder());
}
// Initial render
this.filterProviders("");
@@ -272,8 +272,9 @@ export class SettingsSelectorComponent extends Container {
},
{
id: "install-telemetry",
label: "Install telemetry",
description: "Send an anonymous version/update ping after changelog-detected updates",
label: "Crash reporting and analytics",
description:
"Allow anonymous diagnostics, including version/update analytics and crash reports when available",
currentValue: config.enableInstallTelemetry ? "true" : "false",
values: ["true", "false"],
},
@@ -80,6 +80,7 @@ import { BUILT_IN_PROVIDER_DISPLAY_NAMES } from "../../core/provider-display-nam
import type { ResourceDiagnostic } from "../../core/resource-loader.ts";
import { formatMissingSessionCwdPrompt, MissingSessionCwdError } from "../../core/session-cwd.ts";
import { type SessionContext, SessionManager } from "../../core/session-manager.ts";
import { hasPendingSetupSteps } from "../../core/setup-state.ts";
import { BUILTIN_SLASH_COMMANDS } from "../../core/slash-commands.ts";
import type { SourceInfo } from "../../core/source-info.ts";
import { isInstallTelemetryEnabled } from "../../core/telemetry.ts";
@@ -121,6 +122,7 @@ import { ToolExecutionComponent } from "./components/tool-execution.ts";
import { TreeSelectorComponent } from "./components/tree-selector.ts";
import { UserMessageComponent } from "./components/user-message.ts";
import { UserMessageSelectorComponent } from "./components/user-message-selector.ts";
import { runSetupWizard, SETUP_LOGO_LINES, type SetupWizardResult } from "./setup-wizard.ts";
import {
getAvailableThemes,
getAvailableThemesWithPaths,
@@ -232,6 +234,8 @@ export interface InteractiveModeOptions {
initialMessages?: string[];
/** Force verbose startup (overrides quietStartup setting) */
verbose?: boolean;
/** Run first-time setup when needed */
runSetup?: boolean;
}
export class InteractiveMode {
@@ -240,6 +244,7 @@ export class InteractiveMode {
private chatContainer: Container;
private pendingMessagesContainer: Container;
private statusContainer: Container;
private setupContainer: Container;
private defaultEditor: CustomEditor;
private editor: EditorComponent;
private editorComponentFactory: EditorFactory | undefined;
@@ -372,6 +377,7 @@ export class InteractiveMode {
this.chatContainer = new Container();
this.pendingMessagesContainer = new Container();
this.statusContainer = new Container();
this.setupContainer = new Container();
this.widgetContainerAbove = new Container();
this.widgetContainerBelow = new Container();
this.keybindings = KeybindingsManager.create();
@@ -570,14 +576,6 @@ export class InteractiveMode {
this.registerSignalHandlers();
// Load changelog (only show new entries, skip for resumed sessions)
this.changelogMarkdown = this.getChangelogForDisplay();
// Ensure fd and rg are available (downloads if missing, adds to PATH via getBinDir)
// Both are needed: fd for autocomplete, rg for grep tool and bash commands
const [fdPath] = await Promise.all([ensureTool("fd"), ensureTool("rg")]);
this.fdPath = fdPath;
if (this.session.scopedModels.length > 0 && (this.options.verbose || !this.settingsManager.getQuietStartup())) {
const modelList = this.session.scopedModels
.map((sm) => {
@@ -670,23 +668,33 @@ export class InteractiveMode {
this.setupKeyHandlers();
this.setupEditorSubmitHandler();
// Start the UI before initializing extensions so session_start handlers can use interactive dialogs
// Start the UI before setup and extension initialization so both can use interactive dialogs.
this.ui.start();
this.isInitialized = true;
// Set up theme file watcher before setup so theme previews invalidate consistently.
onThemeChange(() => {
this.ui.invalidate();
this.updateEditorBorderColor();
this.ui.requestRender();
});
await this.runInitialSetupIfNeeded();
// Load changelog after setup so install telemetry respects setup consent.
this.changelogMarkdown = this.getChangelogForDisplay();
// Ensure fd and rg are available (downloads if missing, adds to PATH via getBinDir)
// Both are needed: fd for autocomplete, rg for grep tool and bash commands
const [fdPath] = await Promise.all([ensureTool("fd"), ensureTool("rg")]);
this.fdPath = fdPath;
// Initialize extensions first so resources are shown before messages
await this.rebindCurrentSession();
// Render initial messages AFTER showing loaded resources
this.renderInitialMessages();
// Set up theme file watcher
onThemeChange(() => {
this.ui.invalidate();
this.updateEditorBorderColor();
this.ui.requestRender();
});
// Set up git branch watcher (uses provider instead of footer)
this.footerDataProvider.onBranchChange(() => {
this.ui.requestRender();
@@ -749,7 +757,9 @@ export class InteractiveMode {
this.showError(`models.json error: ${modelsJsonError}`);
}
if (modelFallbackMessage) {
const staleNoModelsWarning =
modelFallbackMessage?.startsWith("No models available.") === true && !isUnknownModel(this.session.model);
if (modelFallbackMessage && !staleNoModelsWarning) {
this.showWarning(modelFallbackMessage);
}
@@ -910,6 +920,92 @@ export class InteractiveMode {
};
}
private shouldRunInitialSetup(): boolean {
return (
this.options.runSetup !== false &&
process.stdin.isTTY === true &&
process.stdout.isTTY === true &&
hasPendingSetupSteps(getAgentDir())
);
}
private async runInitialSetupIfNeeded(): Promise<void> {
if (!this.shouldRunInitialSetup()) {
return;
}
await this.runSetup("automatic");
}
private hideInputAreaForSetup(): () => void {
const footerComponent = this.customFooter ?? this.footer;
const components: Component[] = [
this.widgetContainerAbove,
this.editorContainer,
this.widgetContainerBelow,
footerComponent,
];
for (const component of components) {
this.ui.removeChild(component);
}
this.ui.requestRender();
return () => {
for (const component of components) {
if (!this.ui.children.includes(component)) {
this.ui.addChild(component);
}
}
this.ui.requestRender();
};
}
private hideWidgetContainerAbove(): () => void {
if (!this.ui.children.includes(this.widgetContainerAbove)) {
return () => {};
}
this.ui.removeChild(this.widgetContainerAbove);
this.ui.requestRender();
return () => {
if (this.ui.children.includes(this.widgetContainerAbove)) {
return;
}
const editorIndex = this.ui.children.indexOf(this.editorContainer);
if (editorIndex === -1) {
this.ui.addChild(this.widgetContainerAbove);
} else {
this.ui.children.splice(editorIndex, 0, this.widgetContainerAbove);
}
this.ui.requestRender();
};
}
private async runSetup(mode: "automatic" | "manual"): Promise<void> {
const restoreInputArea = this.hideInputAreaForSetup();
let result: SetupWizardResult | undefined;
try {
result = await runSetupWizard({
tui: this.ui,
settingsManager: this.settingsManager,
agentDir: getAgentDir(),
mode,
container: this.setupContainer,
mount: {
parent: this.ui,
before: this.widgetContainerAbove,
},
focusAfter: this.editor as Component,
onThemeApplied: () => this.updateEditorBorderColor(),
});
} finally {
restoreInputArea();
}
if (result?.loginRequest) {
await this.showSetupLoginProviderSelector(result.loginRequest);
}
if (mode === "manual" && result) {
this.showStatus(result.cancelled ? "Setup cancelled" : "Setup complete");
}
}
// =========================================================================
// Extension System
// =========================================================================
@@ -1349,7 +1445,9 @@ export class InteractiveMode {
if (showListing) {
const contextFiles = this.session.resourceLoader.getAgentsFiles().agentsFiles;
if (contextFiles.length > 0) {
this.chatContainer.addChild(new Spacer(1));
if (this.chatContainer.children.length > 0) {
this.chatContainer.addChild(new Spacer(1));
}
const contextList = contextFiles
.map((f) => theme.fg("dim", ` ${this.formatDisplayPath(f.path)}`))
.join("\n");
@@ -2472,6 +2570,11 @@ export class InteractiveMode {
this.editor.setText("");
return;
}
if (text === "/setup") {
this.editor.setText("");
await this.runSetup("manual");
return;
}
if (text === "/scoped-models") {
this.editor.setText("");
await this.showModelsSelector();
@@ -4520,6 +4623,16 @@ export class InteractiveMode {
});
}
private async authenticateLoginProvider(providerOption: AuthSelectorProvider): Promise<void> {
if (providerOption.authType === "oauth") {
await this.showLoginDialog(providerOption.id, providerOption.name);
} else if (providerOption.id === BEDROCK_PROVIDER_ID) {
await this.showBedrockSetupDialog(providerOption.id, providerOption.name);
} else {
await this.showApiKeyLoginDialog(providerOption.id, providerOption.name);
}
}
private showLoginProviderSelector(authType: "oauth" | "api_key"): void {
const providerOptions = this.getLoginProviderOptions(authType);
if (providerOptions.length === 0) {
@@ -4534,7 +4647,7 @@ export class InteractiveMode {
"login",
this.session.modelRegistry.authStorage,
providerOptions,
async (providerId: string) => {
(providerId: string) => {
done();
const providerOption = providerOptions.find((provider) => provider.id === providerId);
@@ -4542,13 +4655,7 @@ export class InteractiveMode {
return;
}
if (providerOption.authType === "oauth") {
await this.showLoginDialog(providerOption.id, providerOption.name);
} else if (providerOption.id === BEDROCK_PROVIDER_ID) {
this.showBedrockSetupDialog(providerOption.id, providerOption.name);
} else {
await this.showApiKeyLoginDialog(providerOption.id, providerOption.name);
}
void this.authenticateLoginProvider(providerOption);
},
() => {
done();
@@ -4560,6 +4667,56 @@ export class InteractiveMode {
});
}
private showSetupLoginProviderSelector(authType: "oauth" | "api_key"): Promise<void> {
const providerOptions = this.getLoginProviderOptions(authType);
if (providerOptions.length === 0) {
this.showStatus(
authType === "oauth" ? "No subscription providers available." : "No API key providers available.",
);
return Promise.resolve();
}
const restoreWidgetContainerAbove = this.hideWidgetContainerAbove();
return new Promise((resolve) => {
let settled = false;
const finish = () => {
if (settled) {
return;
}
settled = true;
restoreWidgetContainerAbove();
resolve();
};
this.showSelector((done) => {
const selector = new OAuthSelectorComponent(
"login",
this.session.modelRegistry.authStorage,
providerOptions,
(providerId: string) => {
done();
const providerOption = providerOptions.find((provider) => provider.id === providerId);
if (!providerOption) {
finish();
return;
}
void this.authenticateLoginProvider(providerOption).finally(finish);
},
() => {
done();
this.ui.requestRender();
finish();
},
(providerId) => this.session.modelRegistry.getProviderAuthStatus(providerId),
{ logoLines: SETUP_LOGO_LINES, border: false },
);
return { component: selector, focus: selector };
});
});
}
private async showOAuthSelector(mode: "login" | "logout"): Promise<void> {
if (mode === "login") {
this.showLoginAuthTypeSelector();
@@ -4662,32 +4819,35 @@ export class InteractiveMode {
}
}
private showBedrockSetupDialog(providerId: string, providerName: string): void {
const restoreEditor = () => {
private showBedrockSetupDialog(providerId: string, providerName: string): Promise<void> {
return new Promise((resolve) => {
const restoreEditor = () => {
this.editorContainer.clear();
this.editorContainer.addChild(this.editor);
this.ui.setFocus(this.editor);
this.ui.requestRender();
resolve();
};
const dialog = new LoginDialogComponent(
this.ui,
providerId,
() => restoreEditor(),
providerName,
"Amazon Bedrock setup",
);
dialog.showInfo([
theme.fg("text", "Amazon Bedrock uses AWS credentials instead of a single API key."),
theme.fg("text", "Configure an AWS profile, IAM keys, bearer token, or role-based credentials."),
theme.fg("muted", "See:"),
theme.fg("accent", ` ${path.join(getDocsPath(), "providers.md")}`),
]);
this.editorContainer.clear();
this.editorContainer.addChild(this.editor);
this.ui.setFocus(this.editor);
this.editorContainer.addChild(dialog);
this.ui.setFocus(dialog);
this.ui.requestRender();
};
const dialog = new LoginDialogComponent(
this.ui,
providerId,
() => restoreEditor(),
providerName,
"Amazon Bedrock setup",
);
dialog.showInfo([
theme.fg("text", "Amazon Bedrock uses AWS credentials instead of a single API key."),
theme.fg("text", "Configure an AWS profile, IAM keys, bearer token, or role-based credentials."),
theme.fg("muted", "See:"),
theme.fg("accent", ` ${path.join(getDocsPath(), "providers.md")}`),
]);
this.editorContainer.clear();
this.editorContainer.addChild(dialog);
this.ui.setFocus(dialog);
this.ui.requestRender();
});
}
private async showApiKeyLoginDialog(providerId: string, providerName: string): Promise<void> {
@@ -0,0 +1,462 @@
import {
type Component,
type Container,
type SelectItem,
SelectList,
type TUI,
truncateToWidth,
wrapTextWithAnsi,
} from "@earendil-works/pi-tui";
import type { SettingsManager } from "../../core/settings-manager.ts";
import {
getAllSetupStepIds,
getPendingSetupStepIds,
markSetupStepComplete,
type SetupStepId,
} from "../../core/setup-state.ts";
import {
detectTerminalBackground,
getSelectListTheme,
getThemeForRgbColor,
parseOsc11BackgroundColor,
setTheme,
type TerminalThemeDetection,
theme,
} from "./theme/theme.ts";
type SetupWizardMode = "automatic" | "manual";
type SetupLoginRequest = "oauth" | "api_key";
type SetupStepOutcome = "completed" | "cancelled" | { loginRequest: SetupLoginRequest };
export const SETUP_LOGO_LINES = ["██████", "██ ██", "████ ██", "██ ██"];
interface SetupWizardMountOptions {
parent: Container;
before: Component;
}
export interface SetupWizardOptions {
tui: TUI;
settingsManager: SettingsManager;
agentDir: string;
mode: SetupWizardMode;
steps?: readonly SetupStepId[];
container: Container;
mount?: SetupWizardMountOptions;
focusAfter?: Component;
onThemeApplied?: () => void;
}
export interface SetupWizardResult {
completed: boolean;
cancelled: boolean;
completedSteps: SetupStepId[];
loginRequest?: SetupLoginRequest;
}
function mountSetupContainer(options: SetupWizardOptions): void {
if (!options.mount || options.mount.parent.children.includes(options.container)) {
return;
}
const insertIndex = options.mount.parent.children.indexOf(options.mount.before);
if (insertIndex === -1) {
options.mount.parent.addChild(options.container);
return;
}
options.mount.parent.children.splice(insertIndex, 0, options.container);
}
function unmountSetupContainer(options: SetupWizardOptions): void {
if (options.mount) {
options.mount.parent.removeChild(options.container);
}
}
function showSetupComponent(options: SetupWizardOptions, component: Component): () => void {
mountSetupContainer(options);
options.container.clear();
options.container.addChild(component);
options.tui.setFocus(component);
options.tui.requestRender();
return () => {
options.container.clear();
unmountSetupContainer(options);
options.tui.setFocus(options.focusAfter ?? null);
options.tui.requestRender();
};
}
function pushSetupLogo(lines: string[], width: number): void {
for (const line of SETUP_LOGO_LINES) {
lines.push(truncateToWidth(` ${theme.fg("accent", line)}`, width, ""));
}
lines.push("");
}
class LoginSetupComponent implements Component {
private readonly selectList: SelectList;
constructor(onLogin: (request: SetupLoginRequest) => void, onSkip: () => void) {
const items: SelectItem[] = [
{
value: "oauth",
label: "Log in with subscription",
description: "Use Claude, ChatGPT, or GitHub Copilot",
},
{
value: "api_key",
label: "Add an API key",
description: "Store a provider API key",
},
{
value: "skip",
label: "Skip",
description: "Use /login later",
},
];
this.selectList = new SelectList(items, items.length, getSelectListTheme(), {
minPrimaryColumnWidth: 26,
maxPrimaryColumnWidth: 30,
});
this.selectList.onSelect = (item) => {
if (item.value === "oauth" || item.value === "api_key") {
onLogin(item.value);
return;
}
onSkip();
};
this.selectList.onCancel = onSkip;
}
render(width: number): string[] {
const lines: string[] = [];
const push = (line = "") => lines.push(truncateToWidth(line, width, ""));
const description = "Connect a subscription or API key now. You can skip this and run /login later.";
pushSetupLogo(lines, width);
push(` ${theme.fg("accent", theme.bold("Log in to a provider"))}`);
push();
for (const line of wrapTextWithAnsi(theme.fg("muted", description), Math.max(1, width - 4))) {
push(` ${line}`);
}
push();
lines.push(...this.selectList.render(width));
push();
push(` ${theme.fg("dim", "Enter to continue · Esc to skip")}`);
return lines;
}
handleInput(data: string): void {
this.selectList.handleInput(data);
}
invalidate(): void {
this.selectList.invalidate();
}
}
class TelemetryConsentComponent implements Component {
private readonly selectList: SelectList;
private readonly hint: string;
private readonly showWelcome: boolean;
constructor(
currentEnabled: boolean,
onSelect: (enabled: boolean) => void,
onCancel: () => void,
hint: string,
showWelcome: boolean,
) {
this.hint = hint;
this.showWelcome = showWelcome;
const items: SelectItem[] = [
{
value: "disabled",
label: "Do not send",
description: "Default. Disable crash reporting and analytics",
},
{
value: "enabled",
label: "Allow",
description: "Send anonymous diagnostics to help improve Pi",
},
];
this.selectList = new SelectList(items, items.length, getSelectListTheme(), {
minPrimaryColumnWidth: 18,
maxPrimaryColumnWidth: 24,
});
this.selectList.setSelectedIndex(currentEnabled ? 1 : 0);
this.selectList.onSelect = (item) => {
onSelect(item.value === "enabled");
};
this.selectList.onCancel = onCancel;
}
render(width: number): string[] {
const lines: string[] = [];
const push = (line = "") => lines.push(truncateToWidth(line, width, ""));
const description =
"Allow Pi to send anonymous diagnostics to improve reliability. This includes version/update analytics and crash reports when crash reporting is available. Prompts, responses, file contents, and API keys are not sent.";
pushSetupLogo(lines, width);
if (this.showWelcome) {
push(` ${theme.fg("accent", theme.bold("Welcome to Pi."))}`);
push();
}
push(` ${theme.fg("accent", theme.bold("Crash reporting and analytics"))}`);
push();
for (const line of wrapTextWithAnsi(theme.fg("muted", description), Math.max(1, width - 4))) {
push(` ${line}`);
}
push();
lines.push(...this.selectList.render(width));
push();
push(` ${theme.fg("dim", this.hint)}`);
return lines;
}
handleInput(data: string): void {
this.selectList.handleInput(data);
}
invalidate(): void {
this.selectList.invalidate();
}
}
function previewTheme(themeName: string, tui: TUI, onThemeApplied: (() => void) | undefined): boolean {
const result = setTheme(themeName, true);
tui.invalidate();
onThemeApplied?.();
tui.requestRender();
return result.success;
}
function queryTerminalBackground(tui: TUI, timeoutMs = 200): Promise<TerminalThemeDetection | undefined> {
return new Promise((resolve) => {
let settled = false;
let cleanupTimer: NodeJS.Timeout | undefined;
let resolveTimer: NodeJS.Timeout | undefined;
let unsubscribe: (() => void) | undefined;
const cleanup = () => {
if (unsubscribe) {
unsubscribe();
unsubscribe = undefined;
}
if (cleanupTimer) {
clearTimeout(cleanupTimer);
cleanupTimer = undefined;
}
if (resolveTimer) {
clearTimeout(resolveTimer);
resolveTimer = undefined;
}
};
const finish = (detection: TerminalThemeDetection | undefined) => {
if (settled) {
return;
}
settled = true;
cleanup();
resolve(detection);
};
unsubscribe = tui.addInputListener((data) => {
const rgb = parseOsc11BackgroundColor(data);
if (!rgb) {
return undefined;
}
finish({
theme: getThemeForRgbColor(rgb),
source: "terminal background",
detail: `OSC 11 background rgb(${rgb.r}, ${rgb.g}, ${rgb.b})`,
confidence: "high",
});
return { consume: true };
});
resolveTimer = setTimeout(() => finish(undefined), timeoutMs);
cleanupTimer = setTimeout(cleanup, 2000);
tui.terminal.write("\x1b]11;?\x07");
});
}
async function detectSetupTheme(tui: TUI): Promise<TerminalThemeDetection> {
return (await queryTerminalBackground(tui)) ?? detectTerminalBackground();
}
async function runThemeSetupStep(
options: SetupWizardOptions,
initialDetection?: TerminalThemeDetection,
): Promise<SetupStepOutcome> {
const configuredTheme = options.settingsManager.getTheme();
if (!configuredTheme) {
const detection = initialDetection ?? (await detectSetupTheme(options.tui));
if (previewTheme(detection.theme, options.tui, options.onThemeApplied)) {
options.settingsManager.setTheme(detection.theme);
await options.settingsManager.flush();
}
}
markSetupStepComplete("theme", options.agentDir);
return "completed";
}
async function runTelemetrySetupStep(
options: SetupWizardOptions,
isFinalStep: boolean,
showWelcome: boolean,
): Promise<SetupStepOutcome> {
return new Promise((resolve) => {
let closeComponent: (() => void) | undefined;
let closed = false;
const finish = (outcome: SetupStepOutcome) => {
if (closed) {
return;
}
closed = true;
closeComponent?.();
options.tui.requestRender();
resolve(outcome);
};
const saveTelemetry = (enabled: boolean) => {
void (async () => {
options.settingsManager.setTelemetryEnabled(enabled);
await options.settingsManager.flush();
markSetupStepComplete("telemetry", options.agentDir);
finish("completed");
})();
};
const cancel = () => {
finish("cancelled");
};
const automaticHint = isFinalStep ? "Enter to save and finish" : "Enter to save and continue";
const manualHint = isFinalStep ? "Enter to save · Esc to cancel" : "Enter to save and continue · Esc to cancel";
const consent = new TelemetryConsentComponent(
options.settingsManager.getTelemetryEnabled(),
saveTelemetry,
cancel,
options.mode === "automatic" ? automaticHint : manualHint,
showWelcome,
);
closeComponent = showSetupComponent(options, consent);
});
}
async function runLoginSetupStep(options: SetupWizardOptions): Promise<SetupStepOutcome> {
return new Promise((resolve) => {
let closeComponent: (() => void) | undefined;
let closed = false;
const finish = (outcome: SetupStepOutcome) => {
if (closed) {
return;
}
closed = true;
markSetupStepComplete("login", options.agentDir);
closeComponent?.();
options.tui.requestRender();
resolve(outcome);
};
const login = new LoginSetupComponent(
(loginRequest) => finish({ loginRequest }),
() => finish("completed"),
);
closeComponent = showSetupComponent(options, login);
});
}
async function runSetupStep(
options: SetupWizardOptions,
step: SetupStepId,
initialDetection: TerminalThemeDetection | undefined,
isFinalStep: boolean,
): Promise<SetupStepOutcome> {
switch (step) {
case "theme":
return runThemeSetupStep(options, initialDetection);
case "telemetry":
return runTelemetrySetupStep(options, isFinalStep, options.mode === "automatic");
case "login":
return runLoginSetupStep(options);
}
}
async function completeAutomaticSetupWithDefaults(
options: SetupWizardOptions,
steps: SetupStepId[],
completedSteps: SetupStepId[],
initialDetection: TerminalThemeDetection | undefined,
): Promise<SetupWizardResult> {
const completedSet = new Set(completedSteps);
const completeStep = (step: SetupStepId) => {
if (completedSet.has(step)) {
return;
}
markSetupStepComplete(step, options.agentDir);
completedSet.add(step);
completedSteps.push(step);
};
if (steps.includes("theme") && !completedSet.has("theme")) {
const configuredTheme = options.settingsManager.getTheme();
if (!configuredTheme) {
const detection = initialDetection ?? (await detectSetupTheme(options.tui));
if (previewTheme(detection.theme, options.tui, options.onThemeApplied)) {
options.settingsManager.setTheme(detection.theme);
}
}
completeStep("theme");
}
if (steps.includes("telemetry") && !completedSet.has("telemetry")) {
options.settingsManager.setTelemetryEnabled(false);
completeStep("telemetry");
}
if (steps.includes("login") && !completedSet.has("login")) {
completeStep("login");
}
await options.settingsManager.flush();
return { completed: true, cancelled: false, completedSteps };
}
export async function runSetupWizard(options: SetupWizardOptions): Promise<SetupWizardResult> {
const steps = [
...(options.steps ??
(options.mode === "manual" ? getAllSetupStepIds() : getPendingSetupStepIds(options.agentDir))),
];
const completedSteps: SetupStepId[] = [];
let loginRequest: SetupLoginRequest | undefined;
let initialDetection: TerminalThemeDetection | undefined;
if (steps.includes("theme") && !options.settingsManager.getTheme()) {
initialDetection = await detectSetupTheme(options.tui);
previewTheme(initialDetection.theme, options.tui, options.onThemeApplied);
}
for (let index = 0; index < steps.length; index++) {
const step = steps[index];
const outcome = await runSetupStep(options, step, initialDetection, index === steps.length - 1);
if (outcome === "cancelled") {
if (options.mode === "automatic") {
return completeAutomaticSetupWithDefaults(options, steps, completedSteps, initialDetection);
}
return { completed: false, cancelled: true, completedSteps, loginRequest };
}
if (typeof outcome === "object") {
loginRequest = outcome.loginRequest;
}
completedSteps.push(step);
}
return { completed: true, cancelled: false, completedSteps, loginRequest };
}