diff --git a/packages/coding-agent/CHANGELOG.md b/packages/coding-agent/CHANGELOG.md index ca8d55928..f24e827e5 100644 --- a/packages/coding-agent/CHANGELOG.md +++ b/packages/coding-agent/CHANGELOG.md @@ -2,6 +2,10 @@ ## [Unreleased] +### Added + +- Added an experimental first-time setup flow behind `PI_EXPERIMENTAL=1` that asks for a dark/light theme choice (preselecting the detected appearance) and opt-in analytics data sharing on first launch with the default agent directory; opting in stores a `trackingId` in `settings.json`. + ## [0.79.1] - 2026-06-09 ### New Features diff --git a/packages/coding-agent/docs/settings.md b/packages/coding-agent/docs/settings.md index 3c4e9e6f0..2cf843d11 100644 --- a/packages/coding-agent/docs/settings.md +++ b/packages/coding-agent/docs/settings.md @@ -55,6 +55,8 @@ Use `/trust` in interactive mode to save a project trust decision for future ses | `defaultProjectTrust` | string | `"ask"` | Fallback project trust behavior: `"ask"`, `"always"`, or `"never"`. Global setting only | | `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 | +| `enableAnalytics` | boolean | `false` | Opt-in analytics data sharing. Currently only asked for during the experimental first-time setup (`PI_EXPERIMENTAL=1`) | +| `trackingId` | string | - | Analytics tracking identifier, generated when `enableAnalytics` is turned on | | `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) | diff --git a/packages/coding-agent/src/cli/startup-ui.ts b/packages/coding-agent/src/cli/startup-ui.ts index 1f17013c3..5a9de2036 100644 --- a/packages/coding-agent/src/cli/startup-ui.ts +++ b/packages/coding-agent/src/cli/startup-ui.ts @@ -1,9 +1,16 @@ import { ProcessTerminal, setKeybindings, TUI } from "@earendil-works/pi-tui"; +import { existsSync } from "fs"; +import { ENV_AGENT_DIR, getSettingsPath } from "../config.ts"; +import { areExperimentalFeaturesEnabled } from "../core/experimental.ts"; import { KeybindingsManager } from "../core/keybindings.ts"; import type { SettingsManager } from "../core/settings-manager.ts"; import { ExtensionInputComponent } from "../modes/interactive/components/extension-input.ts"; import { ExtensionSelectorComponent } from "../modes/interactive/components/extension-selector.ts"; -import { initTheme } from "../modes/interactive/theme/theme.ts"; +import { + FirstTimeSetupComponent, + type FirstTimeSetupResult, +} from "../modes/interactive/components/first-time-setup.ts"; +import { detectTerminalBackground, initTheme, setTheme } from "../modes/interactive/theme/theme.ts"; function createStartupTui(settingsManager: SettingsManager): TUI { initTheme(settingsManager.getTheme()); @@ -19,6 +26,22 @@ async function clearStartupTui(ui: TUI): Promise { await new Promise((resolve) => setTimeout(resolve, 25)); } +/** + * First-time setup runs when all of these hold: + * - experimental features are enabled (PI_EXPERIMENTAL=1) + * - the default agent directory is used (no custom agent dir override) + * - setup was not completed before (settings.json does not exist) + */ +export function shouldRunFirstTimeSetup(settingsPath: string = getSettingsPath()): boolean { + if (!areExperimentalFeaturesEnabled()) { + return false; + } + if (process.env[ENV_AGENT_DIR]) { + return false; + } + return !existsSync(settingsPath); +} + export async function showStartupSelector( settingsManager: SettingsManager, title: string, @@ -51,6 +74,43 @@ export async function showStartupSelector( }); } +/** Show the first-time setup dialog and persist the result */ +export async function showFirstTimeSetup(settingsManager: SettingsManager): Promise { + return new Promise((resolve) => { + const ui = createStartupTui(settingsManager); + + let settled = false; + const finish = async (result: FirstTimeSetupResult | undefined) => { + if (settled) { + return; + } + settled = true; + if (result) { + settingsManager.setTheme(result.theme); + settingsManager.setEnableAnalytics(result.shareAnalytics); + await settingsManager.flush(); + } + await clearStartupTui(ui); + ui.stop(); + resolve(); + }; + + const component = new FirstTimeSetupComponent({ + detectedTheme: detectTerminalBackground().theme, + onThemePreview: (themeName) => { + setTheme(themeName); + ui.invalidate(); + ui.requestRender(); + }, + onSubmit: (result) => void finish(result), + onCancel: () => void finish(undefined), + }); + ui.addChild(component); + ui.setFocus(component); + ui.start(); + }); +} + export async function showStartupInput( settingsManager: SettingsManager, title: string, diff --git a/packages/coding-agent/src/core/settings-manager.ts b/packages/coding-agent/src/core/settings-manager.ts index 058e84e64..8acd59983 100644 --- a/packages/coding-agent/src/core/settings-manager.ts +++ b/packages/coding-agent/src/core/settings-manager.ts @@ -1,4 +1,5 @@ import type { Transport } from "@earendil-works/pi-ai"; +import { randomUUID } from "crypto"; import { existsSync, mkdirSync, readFileSync, writeFileSync } from "fs"; import { dirname, join } from "path"; import lockfile from "proper-lockfile"; @@ -96,6 +97,8 @@ export interface Settings { 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 + enableAnalytics?: boolean; // default: false - opt-in analytics data sharing + trackingId?: string; // analytics tracking identifier, generated when analytics is enabled 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 @@ -907,6 +910,25 @@ export class SettingsManager { this.save(); } + getEnableAnalytics(): boolean { + return this.settings.enableAnalytics ?? false; + } + + getTrackingId(): string | undefined { + return this.settings.trackingId; + } + + /** Set the analytics opt-in preference; generates a tracking identifier on first opt-in */ + setEnableAnalytics(enabled: boolean): void { + this.globalSettings.enableAnalytics = enabled; + this.markModified("enableAnalytics"); + if (enabled && !this.globalSettings.trackingId) { + this.globalSettings.trackingId = randomUUID(); + this.markModified("trackingId"); + } + this.save(); + } + getPackages(): PackageSource[] { return [...(this.settings.packages ?? [])]; } diff --git a/packages/coding-agent/src/main.ts b/packages/coding-agent/src/main.ts index d6b95b73e..0bc24685d 100644 --- a/packages/coding-agent/src/main.ts +++ b/packages/coding-agent/src/main.ts @@ -14,7 +14,7 @@ import { buildInitialMessage } from "./cli/initial-message.ts"; import { listModels } from "./cli/list-models.ts"; import { createProjectTrustContext } from "./cli/project-trust.ts"; import { selectSession } from "./cli/session-picker.ts"; -import { showStartupSelector } from "./cli/startup-ui.ts"; +import { shouldRunFirstTimeSetup, showFirstTimeSetup, showStartupSelector } from "./cli/startup-ui.ts"; import { ENV_SESSION_DIR, expandTildePath, getAgentDir, getPackageDir, VERSION } from "./config.ts"; import { type CreateAgentSessionRuntimeFactory, createAgentSessionRuntime } from "./core/agent-session-runtime.ts"; import { @@ -528,6 +528,13 @@ export async function main(args: string[], options?: MainOptions) { const startupSettingsManager = SettingsManager.create(cwd, agentDir); reportDiagnostics(collectSettingsDiagnostics(startupSettingsManager, "startup session lookup")); + // Experimental first-time setup: theme choice and analytics opt-in. + // Runs before any runtime services are created so the chosen settings apply everywhere. + if (appMode === "interactive" && !parsed.help && parsed.listModels === undefined && shouldRunFirstTimeSetup()) { + await showFirstTimeSetup(startupSettingsManager); + time("firstTimeSetup"); + } + // Decide the final runtime cwd before creating cwd-bound runtime services. // --session and --resume may select a session from another project, so project-local // settings, resources, provider registrations, and models must be resolved only after diff --git a/packages/coding-agent/src/modes/interactive/components/first-time-setup.ts b/packages/coding-agent/src/modes/interactive/components/first-time-setup.ts new file mode 100644 index 000000000..6ad8ddba4 --- /dev/null +++ b/packages/coding-agent/src/modes/interactive/components/first-time-setup.ts @@ -0,0 +1,145 @@ +import { Container, getKeybindings, Spacer, Text } from "@earendil-works/pi-tui"; +import { APP_NAME } from "../../../config.ts"; +import { type TerminalTheme, theme } from "../theme/theme.ts"; +import { DynamicBorder } from "./dynamic-border.ts"; +import { keyHint, rawKeyHint } from "./keybinding-hints.ts"; + +export interface FirstTimeSetupResult { + theme: TerminalTheme; + shareAnalytics: boolean; +} + +export interface FirstTimeSetupOptions { + detectedTheme: TerminalTheme; + onThemePreview: (themeName: TerminalTheme) => void; + onSubmit: (result: FirstTimeSetupResult) => void; + onCancel: () => void; +} + +const THEME_OPTIONS: Array<{ value: TerminalTheme; label: string }> = [ + { value: "dark", label: "Dark" }, + { value: "light", label: "Light" }, +]; + +const ANALYTICS_OPTIONS: Array<{ value: boolean; label: string }> = [ + { value: true, label: "Share anonymous usage data" }, + { value: false, label: "Don't share" }, +]; + +const SETUP_LOGO_LINES = ["██████", "██ ██", "████ ██", "██ ██"]; + +/** First-time setup dialog: theme choice and analytics opt-in. */ +export class FirstTimeSetupComponent extends Container { + private step: "theme" | "analytics" = "theme"; + private themeIndex: number; + private analyticsIndex = 0; + private readonly options: FirstTimeSetupOptions; + + constructor(options: FirstTimeSetupOptions) { + super(); + this.options = options; + this.themeIndex = Math.max( + 0, + THEME_OPTIONS.findIndex((option) => option.value === options.detectedTheme), + ); + this.update(); + } + + // Rebuild the whole dialog on every change so theme previews recolor all text. + private update(): void { + this.clear(); + this.addChild(new DynamicBorder()); + this.addChild(new Spacer(1)); + this.addChild(new Text(theme.fg("accent", SETUP_LOGO_LINES.join("\n")), 1, 0)); + this.addChild(new Spacer(1)); + this.addChild( + new Text(theme.fg("accent", theme.bold(`Welcome to ${APP_NAME}, the minimal coding agent.`)), 1, 0), + ); + this.addChild(new Spacer(1)); + + if (this.step === "theme") { + this.addChild(new Text(theme.fg("text", "Pick a theme."), 1, 0)); + this.addChild(new Text(theme.fg("muted", `Detected system appearance: ${this.options.detectedTheme}`), 1, 0)); + this.addChild(new Spacer(1)); + this.addOptionList( + THEME_OPTIONS.map((option) => option.label), + this.themeIndex, + ); + } else { + this.addChild(new Text(theme.fg("text", `Help improve ${APP_NAME} by sharing anonymous usage data?`), 1, 0)); + this.addChild( + new Text( + theme.fg( + "muted", + "Opting in stores a tracking identifier in settings.json and enables anonymous\nusage analytics. You can change this at any time in settings.json.", + ), + 1, + 0, + ), + ); + this.addChild(new Spacer(1)); + this.addOptionList( + ANALYTICS_OPTIONS.map((option) => option.label), + this.analyticsIndex, + ); + } + + this.addChild(new Spacer(1)); + this.addChild( + new Text( + rawKeyHint("↑↓", "navigate") + + " " + + keyHint("tui.select.confirm", this.step === "theme" ? "continue" : "finish") + + " " + + keyHint("tui.select.cancel", "skip setup"), + 1, + 0, + ), + ); + this.addChild(new Spacer(1)); + this.addChild(new DynamicBorder()); + } + + private addOptionList(labels: string[], selectedIndex: number): void { + for (let i = 0; i < labels.length; i++) { + const isSelected = i === selectedIndex; + const prefix = isSelected ? theme.fg("accent", "→ ") : " "; + const label = isSelected ? theme.fg("accent", labels[i]) : theme.fg("text", labels[i]); + this.addChild(new Text(`${prefix}${label}`, 1, 0)); + } + } + + private moveSelection(delta: number): void { + if (this.step === "theme") { + const next = Math.max(0, Math.min(THEME_OPTIONS.length - 1, this.themeIndex + delta)); + if (next !== this.themeIndex) { + this.themeIndex = next; + this.options.onThemePreview(THEME_OPTIONS[this.themeIndex].value); + } + } else { + this.analyticsIndex = Math.max(0, Math.min(ANALYTICS_OPTIONS.length - 1, this.analyticsIndex + delta)); + } + this.update(); + } + + handleInput(keyData: string): void { + const kb = getKeybindings(); + if (kb.matches(keyData, "tui.select.up") || keyData === "k") { + this.moveSelection(-1); + } else if (kb.matches(keyData, "tui.select.down") || keyData === "j") { + this.moveSelection(1); + } else if (kb.matches(keyData, "tui.select.confirm") || keyData === "\n") { + if (this.step === "theme") { + this.step = "analytics"; + this.update(); + } else { + this.options.onSubmit({ + theme: THEME_OPTIONS[this.themeIndex].value, + shareAnalytics: ANALYTICS_OPTIONS[this.analyticsIndex].value, + }); + } + } else if (kb.matches(keyData, "tui.select.cancel")) { + this.options.onCancel(); + } + } +} diff --git a/packages/coding-agent/src/modes/interactive/components/index.ts b/packages/coding-agent/src/modes/interactive/components/index.ts index fa47d642b..38c2b9879 100644 --- a/packages/coding-agent/src/modes/interactive/components/index.ts +++ b/packages/coding-agent/src/modes/interactive/components/index.ts @@ -13,6 +13,11 @@ export { DynamicBorder } from "./dynamic-border.ts"; export { ExtensionEditorComponent } from "./extension-editor.ts"; export { ExtensionInputComponent } from "./extension-input.ts"; export { ExtensionSelectorComponent } from "./extension-selector.ts"; +export { + FirstTimeSetupComponent, + type FirstTimeSetupOptions, + type FirstTimeSetupResult, +} from "./first-time-setup.ts"; export { FooterComponent } from "./footer.ts"; export { keyHint, keyText, rawKeyHint } from "./keybinding-hints.ts"; export { LoginDialogComponent } from "./login-dialog.ts"; diff --git a/packages/coding-agent/test/first-time-setup.test.ts b/packages/coding-agent/test/first-time-setup.test.ts new file mode 100644 index 000000000..c470e6dcc --- /dev/null +++ b/packages/coding-agent/test/first-time-setup.test.ts @@ -0,0 +1,95 @@ +import { mkdtempSync, rmSync, writeFileSync } from "fs"; +import { tmpdir } from "os"; +import { join } from "path"; +import { afterEach, beforeEach, describe, expect, it } from "vitest"; +import { shouldRunFirstTimeSetup } from "../src/cli/startup-ui.ts"; +import { ENV_AGENT_DIR } from "../src/config.ts"; +import { SettingsManager } from "../src/core/settings-manager.ts"; + +describe("shouldRunFirstTimeSetup", () => { + const originalPiExperimental = process.env.PI_EXPERIMENTAL; + const originalAgentDir = process.env[ENV_AGENT_DIR]; + let tempDir: string; + let settingsPath: string; + + beforeEach(() => { + tempDir = mkdtempSync(join(tmpdir(), "pi-first-time-setup-")); + settingsPath = join(tempDir, "settings.json"); + process.env.PI_EXPERIMENTAL = "1"; + delete process.env[ENV_AGENT_DIR]; + }); + + afterEach(() => { + rmSync(tempDir, { recursive: true, force: true }); + if (originalPiExperimental === undefined) { + delete process.env.PI_EXPERIMENTAL; + } else { + process.env.PI_EXPERIMENTAL = originalPiExperimental; + } + if (originalAgentDir === undefined) { + delete process.env[ENV_AGENT_DIR]; + } else { + process.env[ENV_AGENT_DIR] = originalAgentDir; + } + }); + + it("returns true when experimental, default agent dir, and no settings.json", () => { + expect(shouldRunFirstTimeSetup(settingsPath)).toBe(true); + }); + + it("returns false when experimental features are disabled", () => { + delete process.env.PI_EXPERIMENTAL; + + expect(shouldRunFirstTimeSetup(settingsPath)).toBe(false); + }); + + it("returns false when a custom agent dir is set", () => { + process.env[ENV_AGENT_DIR] = tempDir; + + expect(shouldRunFirstTimeSetup(settingsPath)).toBe(false); + }); + + it("returns false when settings.json already exists", () => { + writeFileSync(settingsPath, "{}", "utf-8"); + + expect(shouldRunFirstTimeSetup(settingsPath)).toBe(false); + }); +}); + +describe("analytics settings", () => { + it("defaults to disabled with no tracking identifier", () => { + const manager = SettingsManager.inMemory(); + + expect(manager.getEnableAnalytics()).toBe(false); + expect(manager.getTrackingId()).toBeUndefined(); + }); + + it("generates a tracking identifier on opt-in", () => { + const manager = SettingsManager.inMemory(); + + manager.setEnableAnalytics(true); + + expect(manager.getEnableAnalytics()).toBe(true); + expect(manager.getTrackingId()).toMatch(/^[0-9a-f-]{36}$/); + }); + + it("does not generate a tracking identifier on opt-out", () => { + const manager = SettingsManager.inMemory(); + + manager.setEnableAnalytics(false); + + expect(manager.getEnableAnalytics()).toBe(false); + expect(manager.getTrackingId()).toBeUndefined(); + }); + + it("keeps the tracking identifier when toggling analytics", () => { + const manager = SettingsManager.inMemory(); + + manager.setEnableAnalytics(true); + const trackingId = manager.getTrackingId(); + manager.setEnableAnalytics(false); + manager.setEnableAnalytics(true); + + expect(manager.getTrackingId()).toBe(trackingId); + }); +});