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)), + ); +}