mirror of
https://github.com/earendil-works/pi.git
synced 2026-06-18 15:54:04 +08:00
Add first-run setup wizard
This commit is contained in:
@@ -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)).
|
||||
|
||||
@@ -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 |
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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 |
|
||||
|
||||
|
||||
@@ -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)" },
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
|
||||
@@ -217,6 +217,7 @@ export {
|
||||
type PackageSource,
|
||||
type RetrySettings,
|
||||
SettingsManager,
|
||||
type TelemetrySettings,
|
||||
} from "./core/settings-manager.ts";
|
||||
// Skills
|
||||
export {
|
||||
|
||||
@@ -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 };
|
||||
}
|
||||
Reference in New Issue
Block a user