From 309de7bece715260cffda4b6f6cecb54062448c0 Mon Sep 17 00:00:00 2001 From: Sawyer Cutler Date: Fri, 28 Aug 2026 21:52:12 -0700 Subject: [PATCH] Restore first-run welcome before provider setup Unconfigured launches jumped straight into model setup and skipped the orange mountain plus product line. Gate setup on settings.onboarded, show the welcome surface first, and stamp onboarded only after it completes so returning users still open setup directly. --- CHANGELOG.md | 5 + docs/IMPLEMENTATION.md | 2 +- src/tui/onboarding.test.ts | 109 ++++++++++++++ src/tui/onboarding.ts | 29 +++- src/tui/welcome.test.ts | 76 ++++++++++ src/tui/welcome.ts | 282 +++++++++++++++++++++++++++++++++++++ 6 files changed, 498 insertions(+), 5 deletions(-) create mode 100644 src/tui/welcome.test.ts create mode 100644 src/tui/welcome.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index ab410f9b..d254e939 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -13,6 +13,11 @@ parallel copies under `docs/` or `scripts/notes/`. At cut time: rename ## [Unreleased] +### Fixed + +- First-run onboarding shows the welcome mountain and product line before + provider setup again; already-onboarded users still open setup directly. + ## [0.3.8] - 2026-08-28 ### TUI diff --git a/docs/IMPLEMENTATION.md b/docs/IMPLEMENTATION.md index 9b51e5ee..47b186ce 100644 --- a/docs/IMPLEMENTATION.md +++ b/docs/IMPLEMENTATION.md @@ -125,7 +125,7 @@ src/ lsp-hint-plugin.ts TS/JS LSP setup hint on unavailable server tui/ runner.ts Chat-mode agent setup; mounts the OpenTUI host - onboarding.ts First-run provider setup entry + onboarding.ts First-run welcome gate, then provider setup pick-session.ts Resume picker (via runListModal) turns-to-blocks.ts Stored turns → typed content blocks (resume hydration) tool-formatter.ts Human-readable tool args/results diff --git a/src/tui/onboarding.test.ts b/src/tui/onboarding.test.ts index e245af8f..2a9e00f9 100644 --- a/src/tui/onboarding.test.ts +++ b/src/tui/onboarding.test.ts @@ -5,21 +5,35 @@ import { join } from "node:path"; import type { Config, UnconfiguredConfig } from "../config/index.js"; import type { ProviderSetupConfig } from "./provider-setup.js"; +import type { WelcomeConfig } from "./welcome.js"; import { withMockedModule } from "../../tests/helpers/mock-module.js"; let testHome = ""; let setup: (config: ProviderSetupConfig) => Promise = async () => {}; +let welcome: (config: WelcomeConfig) => Promise = async () => true; let tuiConfig: Config | undefined; +const callOrder: string[] = []; await withMockedModule(import.meta.resolve("node:os"), (real: typeof import("node:os")) => ({ ...real, homedir: () => testHome, })); +await withMockedModule( + import.meta.resolve("./welcome.js"), + (real: typeof import("./welcome.js")) => ({ + ...real, + runWelcome: async (config: WelcomeConfig = {}) => { + callOrder.push("welcome"); + return welcome(config); + }, + }), +); await withMockedModule( import.meta.resolve("./provider-setup.js"), (real: typeof import("./provider-setup.js")) => ({ ...real, runProviderSetup: async (config: ProviderSetupConfig) => { + callOrder.push("setup"); await setup(config); return true; }, @@ -78,7 +92,102 @@ async function writeXAIAuthProfile(home: string, profile: string): Promise afterEach(() => { setup = async () => {}; + welcome = async () => true; tuiConfig = undefined; + callOrder.length = 0; +}); + +describe("runOnboarding welcome gate", () => { + test("fresh user sees welcome before provider setup and marks onboarded", async () => { + testHome = await mkdtemp(join(tmpdir(), "corbits-onboarding-welcome-home-")); + const cwd = await mkdtemp(join(tmpdir(), "corbits-onboarding-welcome-cwd-")); + const configPath = join(testHome, ".corbits", "settings.json"); + try { + await mkdir(join(testHome, ".corbits"), { recursive: true }); + await writeFile(configPath, JSON.stringify({ providers: {} })); + const config = await unconfiguredConfig(cwd, { programmaticConfigPath: configPath }); + + setup = async ({ onSubmit }) => { + await onSubmit( + { + name: "custom", + baseURL: "https://provider.example.com/v1", + apiKey: "test-key", + model: "test-model", + oauthProfile: "", + }, + () => {}, + { skipValidation: true }, + ); + }; + + expect(await runOnboarding(config)).toBe(0); + expect(callOrder).toEqual(["welcome", "setup"]); + + const persisted = JSON.parse(await readFile(configPath, "utf8")) as { + onboarded?: boolean; + }; + expect(persisted.onboarded).toBe(true); + } finally { + await rm(testHome, { recursive: true, force: true }); + await rm(cwd, { recursive: true, force: true }); + } + }); + + test("already-onboarded skips welcome and opens setup directly", async () => { + testHome = await mkdtemp(join(tmpdir(), "corbits-onboarding-skip-home-")); + const cwd = await mkdtemp(join(tmpdir(), "corbits-onboarding-skip-cwd-")); + const configPath = join(testHome, ".corbits", "settings.json"); + try { + await mkdir(join(testHome, ".corbits"), { recursive: true }); + await writeFile(configPath, JSON.stringify({ providers: {}, onboarded: true })); + const config = await unconfiguredConfig(cwd, { programmaticConfigPath: configPath }); + + setup = async ({ onSubmit }) => { + await onSubmit( + { + name: "custom", + baseURL: "https://provider.example.com/v1", + apiKey: "test-key", + model: "test-model", + oauthProfile: "", + }, + () => {}, + { skipValidation: true }, + ); + }; + + expect(await runOnboarding(config)).toBe(0); + expect(callOrder).toEqual(["setup"]); + } finally { + await rm(testHome, { recursive: true, force: true }); + await rm(cwd, { recursive: true, force: true }); + } + }); + + test("cancelled welcome does not mark onboarded or open setup", async () => { + testHome = await mkdtemp(join(tmpdir(), "corbits-onboarding-cancel-home-")); + const cwd = await mkdtemp(join(tmpdir(), "corbits-onboarding-cancel-cwd-")); + const configPath = join(testHome, ".corbits", "settings.json"); + try { + await mkdir(join(testHome, ".corbits"), { recursive: true }); + await writeFile(configPath, JSON.stringify({ providers: {} })); + const config = await unconfiguredConfig(cwd, { programmaticConfigPath: configPath }); + + welcome = async () => false; + + expect(await runOnboarding(config)).toBe(1); + expect(callOrder).toEqual(["welcome"]); + + const persisted = JSON.parse(await readFile(configPath, "utf8")) as { + onboarded?: boolean; + }; + expect(persisted.onboarded).toBeUndefined(); + } finally { + await rm(testHome, { recursive: true, force: true }); + await rm(cwd, { recursive: true, force: true }); + } + }); }); describe("runOnboarding settings source", () => { diff --git a/src/tui/onboarding.ts b/src/tui/onboarding.ts index 47b8d4aa..eb19dc55 100644 --- a/src/tui/onboarding.ts +++ b/src/tui/onboarding.ts @@ -1,22 +1,43 @@ import { runTUI } from "./runner.js"; import { buildProviderSubmitHandler } from "./provider-setup-submit.js"; import { loadConfig, type UnconfiguredConfig } from "../config/index.js"; -import { globalSettingsPath, loadSettings, resolveLocalSettingsPath } from "../config/settings.js"; +import { + globalSettingsPath, + loadSettings, + markOnboarded, + resolveLocalSettingsPath, +} from "../config/settings.js"; import { activateHeldTelemetry, telemetryFirstRunPending } from "../telemetry/first-run.js"; import { runProviderSetup } from "./provider-setup.js"; +import { runWelcome } from "./welcome.js"; export async function runOnboarding(config: UnconfiguredConfig): Promise { const settingsPath = config.globalSettingsPath; - const existing = await loadSettings(settingsPath); // Disclosure before any send: startup held telemetry because the notice // has never been shown, so render it here and treat a completed submit as // the affirmative action that activates telemetry (consent by proceeding). // Read from the TRUE global settings file — telemetry state never lives in // a --config override file. - const trueGlobalSettings = await loadSettings(globalSettingsPath()).catch(() => null); + const trueGlobalPath = globalSettingsPath(); + const trueGlobalSettings = await loadSettings(trueGlobalPath).catch(() => null); const showTelemetryNotice = telemetryFirstRunPending(trueGlobalSettings); + // Welcome is global first-run state (same TRUE global file as telemetry / + // onboarded), independent of --config provider write targets. Already- + // onboarded users who wiped providers jump straight to setup. + if (trueGlobalSettings?.onboarded !== true) { + const welcomed = await runWelcome(); + if (!welcomed) { + return 1; + } + await markOnboarded(trueGlobalPath); + } + + // Load the provider write-target after welcome so a same-path markOnboarded + // is preserved when setup merges the new provider into existing settings. + const existing = await loadSettings(settingsPath); + const submitted = await runProviderSetup({ showTelemetryNotice, existingProviderNames: Object.keys(existing?.providers ?? {}), @@ -37,7 +58,7 @@ export async function runOnboarding(config: UnconfiguredConfig): Promise // Completing setup with the disclosure on screen is the affirmative action // that unlocks telemetry and fires the held cli_start. if (showTelemetryNotice) { - await activateHeldTelemetry(globalSettingsPath()); + await activateHeldTelemetry(trueGlobalPath); } const argv: string[] = ["--cwd", config.cwd]; diff --git a/src/tui/welcome.test.ts b/src/tui/welcome.test.ts new file mode 100644 index 00000000..a133f99a --- /dev/null +++ b/src/tui/welcome.test.ts @@ -0,0 +1,76 @@ +import { describe, expect, test } from "bun:test"; + +import { PRODUCT_NAME } from "../branding.js"; +import { MARK_LARGE, MARK_MID, MARK_SMALL } from "./mark-shape.js"; +import { createHarness } from "./harness.js"; +import { resolveWelcomeMarkGrid, runWelcome, WELCOME_LINE } from "./welcome.js"; + +describe("WELCOME_LINE", () => { + test("names the product as the local software factory", () => { + expect(WELCOME_LINE).toBe(`${PRODUCT_NAME}, your local software factory`); + expect(WELCOME_LINE).toContain("Corbits Code, your local software factory"); + }); +}); + +describe("resolveWelcomeMarkGrid", () => { + test("picks the largest mark that fits the terminal", () => { + expect(resolveWelcomeMarkGrid(24, 80)).toBe(MARK_LARGE); + expect(resolveWelcomeMarkGrid(14, 80)).toBe(MARK_MID); + expect(resolveWelcomeMarkGrid(10, 40)).toBe(MARK_SMALL); + expect(resolveWelcomeMarkGrid(4, 80)).toBeNull(); + }); +}); + +describe("runWelcome", () => { + test("paints the product line and continues on keypress", async () => { + const harness = await createHarness({ width: 80, height: 30 }); + const done = runWelcome({ + createRenderer: async () => harness.renderer, + autoAdvanceMs: 60_000, + now: () => 2_000, + }); + try { + await harness.renderOnce(); + await harness.renderOnce(); + expect(harness.captureCharFrame()).toContain(WELCOME_LINE); + + harness.pressKey("Enter"); + await expect(done).resolves.toBe(true); + } finally { + // If the assertion failed before Enter, cancel so timers cannot leak. + harness.pressKey("Ctrl+C"); + await Promise.race([done, new Promise((r) => setTimeout(r, 50))]); + harness.destroy(); + } + }); + + test("cancels on Ctrl+C without continuing", async () => { + const harness = await createHarness({ width: 80, height: 30 }); + const done = runWelcome({ + createRenderer: async () => harness.renderer, + autoAdvanceMs: 60_000, + now: () => 2_000, + }); + try { + await harness.renderOnce(); + harness.pressKey("Ctrl+C"); + await expect(done).resolves.toBe(false); + } finally { + harness.destroy(); + } + }); + + test("auto-advances when the timer fires", async () => { + const harness = await createHarness({ width: 80, height: 30 }); + const done = runWelcome({ + createRenderer: async () => harness.renderer, + autoAdvanceMs: 20, + now: () => 2_000, + }); + try { + await expect(done).resolves.toBe(true); + } finally { + harness.destroy(); + } + }); +}); diff --git a/src/tui/welcome.ts b/src/tui/welcome.ts new file mode 100644 index 00000000..54611471 --- /dev/null +++ b/src/tui/welcome.ts @@ -0,0 +1,282 @@ +/** + * First-run welcome gate shown before provider setup. + * + * Fresh installs see the animated orange mountain and the product line, then + * continue into model/provider setup. Returning users who already completed + * this gate (`settings.onboarded`) skip straight to setup. + * + * The mark art is the same silhouette the idle landing uses (`renderMark` / + * mark grids); this surface only owns the standalone full-screen composition + * and the advance/cancel contract. + */ + +import { + BoxRenderable, + StyledText, + TextRenderable, + createCliRenderer, + fg as fgChunk, + type CliRenderer, + type KeyEvent, + type TextChunk, +} from "@opentui/core"; + +import { PRODUCT_NAME } from "../branding.js"; +import { MARK_PERIOD_SECONDS, renderMark } from "./mark-anim.js"; +import { MARK_LARGE, MARK_MID, MARK_SMALL, type MarkGrid } from "./mark-shape.js"; +import { resolveSideMargin } from "./geometry/margins.js"; +import { destroySubtree } from "./teardown.js"; +import { UI } from "./theme.js"; +import { stringWidth } from "./view/height.js"; + +/** Exact first-run line under the mountain. */ +export const WELCOME_LINE = `${PRODUCT_NAME}, your local software factory`; + +const MARK_TIERS: readonly MarkGrid[] = [MARK_LARGE, MARK_MID, MARK_SMALL]; + +/** Rows reserved under the mark for the product line, hint, and breathing room. */ +const BELOW_MARK_ROWS = 5; + +/** Hold after one full mark period before auto-advancing. */ +const HOLD_AFTER_PERIOD_MS = 900; + +/** Paint cadence while the mountain draws. */ +const PAINT_TICK_MS = 80; + +export interface WelcomeConfig { + /** Renderer factory override for headless mounting in tests. */ + readonly createRenderer?: () => Promise; + /** + * Auto-advance delay after mount. Defaults to one mark period plus a short + * hold so the silhouette finishes drawing before setup opens. + */ + readonly autoAdvanceMs?: number; + /** Injected clock for mark animation (and tests). */ + readonly now?: () => number; +} + +/** + * Largest mark that leaves room for the product line below it, or null when + * even the compact grid cannot seat. + */ +export function resolveWelcomeMarkGrid(rows: number, columns: number): MarkGrid | null { + const width = Math.max(0, columns); + const height = Math.max(0, rows); + for (const grid of MARK_TIERS) { + if (grid.rows + BELOW_MARK_ROWS > height) continue; + if (grid.cols > width) continue; + return grid; + } + return null; +} + +/** + * Mount the welcome surface. Resolves true once the operator continues (any + * key, or auto-advance), false when they cancel with Ctrl+C / Ctrl+D. + */ +export async function runWelcome(config: WelcomeConfig = {}): Promise { + const externalRenderer = config.createRenderer !== undefined; + const renderer = config.createRenderer + ? await config.createRenderer() + : await createCliRenderer({ + exitOnCtrlC: false, + targetFps: 30, + useMouse: false, + enableMouseMovement: false, + }); + + const now = config.now ?? Date.now; + const startedAt = now(); + const autoAdvanceMs = + config.autoAdvanceMs ?? Math.round(MARK_PERIOD_SECONDS * 1000) + HOLD_AFTER_PERIOD_MS; + + const margin = resolveSideMargin(renderer.width || 80); + + const root = new BoxRenderable(renderer, { + id: "welcome", + width: "100%", + height: "100%", + flexDirection: "column", + backgroundColor: UI.ground, + paddingLeft: margin, + paddingRight: margin, + }); + + const topPad = new BoxRenderable(renderer, { + id: "welcome-top-pad", + width: "100%", + flexGrow: 1, + flexShrink: 1, + backgroundColor: UI.ground, + }); + const bottomPad = new BoxRenderable(renderer, { + id: "welcome-bottom-pad", + width: "100%", + flexGrow: 1, + flexShrink: 1, + backgroundColor: UI.ground, + }); + + const markBox = new BoxRenderable(renderer, { + id: "welcome-mark", + flexDirection: "column", + flexShrink: 0, + backgroundColor: UI.ground, + }); + const markRows: TextRenderable[] = []; + for (let row = 0; row < MARK_LARGE.rows; row++) { + const markLine = new TextRenderable(renderer, { + id: `welcome-mark-${row}`, + height: 1, + content: "", + fg: UI.action, + flexShrink: 0, + }); + markRows.push(markLine); + markBox.add(markLine); + } + + const gap = new TextRenderable(renderer, { + id: "welcome-gap", + height: 1, + content: "", + fg: UI.ground, + flexShrink: 0, + }); + + const line = new TextRenderable(renderer, { + id: "welcome-line", + height: 1, + content: WELCOME_LINE, + fg: UI.text, + flexShrink: 0, + }); + + const hintGap = new TextRenderable(renderer, { + id: "welcome-hint-gap", + height: 1, + content: "", + fg: UI.ground, + flexShrink: 0, + }); + + const hint = new TextRenderable(renderer, { + id: "welcome-hint", + height: 1, + content: "press any key to continue", + fg: UI.textFaint, + flexShrink: 0, + }); + + root.add(topPad); + root.add(markBox); + root.add(gap); + root.add(line); + root.add(hintGap); + root.add(hint); + root.add(bottomPad); + renderer.root.add(root); + + let settled = false; + let grid: MarkGrid | null = null; + + const fit = (): void => { + if (settled) return; + const columns = Math.max(1, (renderer.width || 80) - margin * 2); + const rows = renderer.height || 24; + grid = resolveWelcomeMarkGrid(rows, columns); + markBox.visible = grid !== null; + markBox.width = grid?.cols ?? 0; + markBox.height = grid?.rows ?? 0; + const offset = grid === null ? MARK_LARGE.rows : MARK_LARGE.rows - grid.rows; + markRows.forEach((row, index) => { + row.visible = grid !== null && index >= offset; + }); + line.content = + stringWidth(WELCOME_LINE) > columns + ? WELCOME_LINE.slice(0, Math.max(0, columns - 1)) + : WELCOME_LINE; + }; + + const paint = (): void => { + if (settled || grid === null) return; + try { + const chunks = markChunks(grid, now() - startedAt, false); + const offset = MARK_LARGE.rows - grid.rows; + markRows.forEach((row, index) => { + if (!row.visible) return; + const cells = chunks[index - offset]; + if (cells !== undefined) row.content = new StyledText([...cells]); + }); + } catch { + // Renderer or text buffers already torn down. + } + }; + + fit(); + paint(); + + let resolveDone: (value: boolean) => void = () => {}; + const done = new Promise((resolve) => { + resolveDone = resolve; + }); + + let paintTimer: ReturnType | null = null; + let advanceTimer: ReturnType | null = null; + + const teardown = (): void => { + if (paintTimer !== null) { + clearInterval(paintTimer); + paintTimer = null; + } + if (advanceTimer !== null) { + clearTimeout(advanceTimer); + advanceTimer = null; + } + renderer.keyInput.off("keypress", onKey); + try { + renderer.root.remove(root); + destroySubtree(root); + } catch { + // already unmounted + } + if (!externalRenderer) { + try { + renderer.destroy(); + } catch { + // already destroyed + } + } + }; + + const settle = (continued: boolean): void => { + if (settled) return; + settled = true; + teardown(); + resolveDone(continued); + }; + + function onKey(key: KeyEvent): void { + if (settled) return; + const cancel = key.ctrl === true && (key.name === "c" || key.name === "d"); + if (cancel) { + key.preventDefault(); + settle(false); + return; + } + key.preventDefault(); + settle(true); + } + + renderer.keyInput.on("keypress", onKey); + paintTimer = setInterval(paint, PAINT_TICK_MS); + advanceTimer = setTimeout(() => settle(true), autoAdvanceMs); + + return done; +} + +function markChunks(grid: MarkGrid, nowMs: number, still: boolean): readonly TextChunk[][] { + return renderMark({ nowMs, still, grid }).map((row) => + row.map((cell) => fgChunk(cell.fg)(cell.char)), + ); +}