Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
2 changes: 1 addition & 1 deletion docs/IMPLEMENTATION.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
109 changes: 109 additions & 0 deletions src/tui/onboarding.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<void> = async () => {};
let welcome: (config: WelcomeConfig) => Promise<boolean> = 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;
},
Expand Down Expand Up @@ -78,7 +92,102 @@ async function writeXAIAuthProfile(home: string, profile: string): Promise<void>

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", () => {
Expand Down
29 changes: 25 additions & 4 deletions src/tui/onboarding.ts
Original file line number Diff line number Diff line change
@@ -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<number> {
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 ?? {}),
Expand All @@ -37,7 +58,7 @@ export async function runOnboarding(config: UnconfiguredConfig): Promise<number>
// 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];
Expand Down
76 changes: 76 additions & 0 deletions src/tui/welcome.test.ts
Original file line number Diff line number Diff line change
@@ -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();
}
});
});
Loading
Loading