diff --git a/packages/coding-agent/CHANGELOG.md b/packages/coding-agent/CHANGELOG.md index d28d8eed8..42b23a94b 100644 --- a/packages/coding-agent/CHANGELOG.md +++ b/packages/coding-agent/CHANGELOG.md @@ -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)). diff --git a/packages/coding-agent/README.md b/packages/coding-agent/README.md index 00fca81e3..ccf379509 100644 --- a/packages/coding-agent/README.md +++ b/packages/coding-agent/README.md @@ -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 ` | 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 | diff --git a/packages/coding-agent/docs/settings.md b/packages/coding-agent/docs/settings.md index 2e039c898..7c316034b 100644 --- a/packages/coding-agent/docs/settings.md +++ b/packages/coding-agent/docs/settings.md @@ -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 diff --git a/packages/coding-agent/docs/usage.md b/packages/coding-agent/docs/usage.md index ae69afaf9..87a3d2c61 100644 --- a/packages/coding-agent/docs/usage.md +++ b/packages/coding-agent/docs/usage.md @@ -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 ` | 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 | diff --git a/packages/coding-agent/src/cli/args.ts b/packages/coding-agent/src/cli/args.ts index 19053db56..d9c091a9b 100644 --- a/packages/coding-agent/src/cli/args.ts +++ b/packages/coding-agent/src/cli/args.ts @@ -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} --help Show help for install/remove/uninstall/update/list + ${APP_NAME} setup Run the interactive setup wizard + ${APP_NAME} --help Show help for install/remove/uninstall/update/list/setup ${chalk.bold("Options:")} --provider Provider name (default: google) @@ -250,6 +254,7 @@ ${chalk.bold("Options:")} --export 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:")} diff --git a/packages/coding-agent/src/cli/setup-command.ts b/packages/coding-agent/src/cli/setup-command.ts new file mode 100644 index 000000000..c89f00527 --- /dev/null +++ b/packages/coding-agent/src/cli/setup-command.ts @@ -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 { + 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; +} diff --git a/packages/coding-agent/src/core/settings-manager.ts b/packages/coding-agent/src/core/settings-manager.ts index 910b68aab..35f95de9b 100644 --- a/packages/coding-agent/src/core/settings-manager.ts +++ b/packages/coding-agent/src/core/settings-manager.ts @@ -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[] { diff --git a/packages/coding-agent/src/core/setup-state.ts b/packages/coding-agent/src/core/setup-state.ts new file mode 100644 index 000000000..c25b97cdd --- /dev/null +++ b/packages/coding-agent/src/core/setup-state.ts @@ -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; +} + +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; + const rawSteps = record.steps; + const steps: Record = {}; + 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): 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((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); +} diff --git a/packages/coding-agent/src/core/slash-commands.ts b/packages/coding-agent/src/core/slash-commands.ts index e52892c81..01ef99dba 100644 --- a/packages/coding-agent/src/core/slash-commands.ts +++ b/packages/coding-agent/src/core/slash-commands.ts @@ -17,6 +17,7 @@ export interface BuiltinSlashCommand { export const BUILTIN_SLASH_COMMANDS: ReadonlyArray = [ { 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)" }, diff --git a/packages/coding-agent/src/core/telemetry.ts b/packages/coding-agent/src/core/telemetry.ts index 25111c2b3..67dcce408 100644 --- a/packages/coding-agent/src/core/telemetry.ts +++ b/packages/coding-agent/src/core/telemetry.ts @@ -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(); } diff --git a/packages/coding-agent/src/index.ts b/packages/coding-agent/src/index.ts index 8627bdcdb..b5f1ddd8c 100644 --- a/packages/coding-agent/src/index.ts +++ b/packages/coding-agent/src/index.ts @@ -217,6 +217,7 @@ export { type PackageSource, type RetrySettings, SettingsManager, + type TelemetrySettings, } from "./core/settings-manager.ts"; // Skills export { diff --git a/packages/coding-agent/src/main.ts b/packages/coding-agent/src/main.ts index 7e69d933d..cc21ba0de 100644 --- a/packages/coding-agent/src/main.ts +++ b/packages/coding-agent/src/main.ts @@ -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(); diff --git a/packages/coding-agent/src/modes/interactive/components/oauth-selector.ts b/packages/coding-agent/src/modes/interactive/components/oauth-selector.ts index 5910e7009..b76ef1ec4 100644 --- a/packages/coding-agent/src/modes/interactive/components/oauth-selector.ts +++ b/packages/coding-agent/src/modes/interactive/components/oauth-selector.ts @@ -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(""); diff --git a/packages/coding-agent/src/modes/interactive/components/settings-selector.ts b/packages/coding-agent/src/modes/interactive/components/settings-selector.ts index 7d210028a..f908cae63 100644 --- a/packages/coding-agent/src/modes/interactive/components/settings-selector.ts +++ b/packages/coding-agent/src/modes/interactive/components/settings-selector.ts @@ -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"], }, diff --git a/packages/coding-agent/src/modes/interactive/interactive-mode.ts b/packages/coding-agent/src/modes/interactive/interactive-mode.ts index 3068c89cf..62c97d4bb 100644 --- a/packages/coding-agent/src/modes/interactive/interactive-mode.ts +++ b/packages/coding-agent/src/modes/interactive/interactive-mode.ts @@ -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 { + 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 { + 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 { + 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 { + 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 { 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 { + 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 { diff --git a/packages/coding-agent/src/modes/interactive/setup-wizard.ts b/packages/coding-agent/src/modes/interactive/setup-wizard.ts new file mode 100644 index 000000000..7a94cb6d4 --- /dev/null +++ b/packages/coding-agent/src/modes/interactive/setup-wizard.ts @@ -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 { + 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 { + return (await queryTerminalBackground(tui)) ?? detectTerminalBackground(); +} + +async function runThemeSetupStep( + options: SetupWizardOptions, + initialDetection?: TerminalThemeDetection, +): Promise { + 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 { + 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 { + 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 { + 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 { + 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 { + 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 }; +}