feat(coding-agent): add experimental first-time setup flow (#5587)
Behind PI_EXPERIMENTAL=1, show a first-time setup dialog on interactive startup when the default agent directory is used and settings.json does not exist. The dialog preselects the detected terminal appearance with an explicit dark/light choice (live preview) and asks for opt-in analytics data sharing. Submitting writes settings.json, which serves as the completion marker; opting in stores a generated trackingId. Escape at any point skips out of setup.
This commit is contained in:
@@ -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();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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";
|
||||
|
||||
Reference in New Issue
Block a user