Skip to content

Commit 309de7b

Browse files
committed
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.
1 parent 59409ca commit 309de7b

6 files changed

Lines changed: 498 additions & 5 deletions

File tree

CHANGELOG.md

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,11 @@ parallel copies under `docs/` or `scripts/notes/`. At cut time: rename
1313

1414
## [Unreleased]
1515

16+
### Fixed
17+
18+
- First-run onboarding shows the welcome mountain and product line before
19+
provider setup again; already-onboarded users still open setup directly.
20+
1621
## [0.3.8] - 2026-08-28
1722

1823
### TUI

docs/IMPLEMENTATION.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -125,7 +125,7 @@ src/
125125
lsp-hint-plugin.ts TS/JS LSP setup hint on unavailable server
126126
tui/
127127
runner.ts Chat-mode agent setup; mounts the OpenTUI host
128-
onboarding.ts First-run provider setup entry
128+
onboarding.ts First-run welcome gate, then provider setup
129129
pick-session.ts Resume picker (via runListModal)
130130
turns-to-blocks.ts Stored turns → typed content blocks (resume hydration)
131131
tool-formatter.ts Human-readable tool args/results

src/tui/onboarding.test.ts

Lines changed: 109 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,21 +5,35 @@ import { join } from "node:path";
55

66
import type { Config, UnconfiguredConfig } from "../config/index.js";
77
import type { ProviderSetupConfig } from "./provider-setup.js";
8+
import type { WelcomeConfig } from "./welcome.js";
89
import { withMockedModule } from "../../tests/helpers/mock-module.js";
910

1011
let testHome = "";
1112
let setup: (config: ProviderSetupConfig) => Promise<void> = async () => {};
13+
let welcome: (config: WelcomeConfig) => Promise<boolean> = async () => true;
1214
let tuiConfig: Config | undefined;
15+
const callOrder: string[] = [];
1316

1417
await withMockedModule(import.meta.resolve("node:os"), (real: typeof import("node:os")) => ({
1518
...real,
1619
homedir: () => testHome,
1720
}));
21+
await withMockedModule(
22+
import.meta.resolve("./welcome.js"),
23+
(real: typeof import("./welcome.js")) => ({
24+
...real,
25+
runWelcome: async (config: WelcomeConfig = {}) => {
26+
callOrder.push("welcome");
27+
return welcome(config);
28+
},
29+
}),
30+
);
1831
await withMockedModule(
1932
import.meta.resolve("./provider-setup.js"),
2033
(real: typeof import("./provider-setup.js")) => ({
2134
...real,
2235
runProviderSetup: async (config: ProviderSetupConfig) => {
36+
callOrder.push("setup");
2337
await setup(config);
2438
return true;
2539
},
@@ -78,7 +92,102 @@ async function writeXAIAuthProfile(home: string, profile: string): Promise<void>
7892

7993
afterEach(() => {
8094
setup = async () => {};
95+
welcome = async () => true;
8196
tuiConfig = undefined;
97+
callOrder.length = 0;
98+
});
99+
100+
describe("runOnboarding welcome gate", () => {
101+
test("fresh user sees welcome before provider setup and marks onboarded", async () => {
102+
testHome = await mkdtemp(join(tmpdir(), "corbits-onboarding-welcome-home-"));
103+
const cwd = await mkdtemp(join(tmpdir(), "corbits-onboarding-welcome-cwd-"));
104+
const configPath = join(testHome, ".corbits", "settings.json");
105+
try {
106+
await mkdir(join(testHome, ".corbits"), { recursive: true });
107+
await writeFile(configPath, JSON.stringify({ providers: {} }));
108+
const config = await unconfiguredConfig(cwd, { programmaticConfigPath: configPath });
109+
110+
setup = async ({ onSubmit }) => {
111+
await onSubmit(
112+
{
113+
name: "custom",
114+
baseURL: "https://provider.example.com/v1",
115+
apiKey: "test-key",
116+
model: "test-model",
117+
oauthProfile: "",
118+
},
119+
() => {},
120+
{ skipValidation: true },
121+
);
122+
};
123+
124+
expect(await runOnboarding(config)).toBe(0);
125+
expect(callOrder).toEqual(["welcome", "setup"]);
126+
127+
const persisted = JSON.parse(await readFile(configPath, "utf8")) as {
128+
onboarded?: boolean;
129+
};
130+
expect(persisted.onboarded).toBe(true);
131+
} finally {
132+
await rm(testHome, { recursive: true, force: true });
133+
await rm(cwd, { recursive: true, force: true });
134+
}
135+
});
136+
137+
test("already-onboarded skips welcome and opens setup directly", async () => {
138+
testHome = await mkdtemp(join(tmpdir(), "corbits-onboarding-skip-home-"));
139+
const cwd = await mkdtemp(join(tmpdir(), "corbits-onboarding-skip-cwd-"));
140+
const configPath = join(testHome, ".corbits", "settings.json");
141+
try {
142+
await mkdir(join(testHome, ".corbits"), { recursive: true });
143+
await writeFile(configPath, JSON.stringify({ providers: {}, onboarded: true }));
144+
const config = await unconfiguredConfig(cwd, { programmaticConfigPath: configPath });
145+
146+
setup = async ({ onSubmit }) => {
147+
await onSubmit(
148+
{
149+
name: "custom",
150+
baseURL: "https://provider.example.com/v1",
151+
apiKey: "test-key",
152+
model: "test-model",
153+
oauthProfile: "",
154+
},
155+
() => {},
156+
{ skipValidation: true },
157+
);
158+
};
159+
160+
expect(await runOnboarding(config)).toBe(0);
161+
expect(callOrder).toEqual(["setup"]);
162+
} finally {
163+
await rm(testHome, { recursive: true, force: true });
164+
await rm(cwd, { recursive: true, force: true });
165+
}
166+
});
167+
168+
test("cancelled welcome does not mark onboarded or open setup", async () => {
169+
testHome = await mkdtemp(join(tmpdir(), "corbits-onboarding-cancel-home-"));
170+
const cwd = await mkdtemp(join(tmpdir(), "corbits-onboarding-cancel-cwd-"));
171+
const configPath = join(testHome, ".corbits", "settings.json");
172+
try {
173+
await mkdir(join(testHome, ".corbits"), { recursive: true });
174+
await writeFile(configPath, JSON.stringify({ providers: {} }));
175+
const config = await unconfiguredConfig(cwd, { programmaticConfigPath: configPath });
176+
177+
welcome = async () => false;
178+
179+
expect(await runOnboarding(config)).toBe(1);
180+
expect(callOrder).toEqual(["welcome"]);
181+
182+
const persisted = JSON.parse(await readFile(configPath, "utf8")) as {
183+
onboarded?: boolean;
184+
};
185+
expect(persisted.onboarded).toBeUndefined();
186+
} finally {
187+
await rm(testHome, { recursive: true, force: true });
188+
await rm(cwd, { recursive: true, force: true });
189+
}
190+
});
82191
});
83192

84193
describe("runOnboarding settings source", () => {

src/tui/onboarding.ts

Lines changed: 25 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,22 +1,43 @@
11
import { runTUI } from "./runner.js";
22
import { buildProviderSubmitHandler } from "./provider-setup-submit.js";
33
import { loadConfig, type UnconfiguredConfig } from "../config/index.js";
4-
import { globalSettingsPath, loadSettings, resolveLocalSettingsPath } from "../config/settings.js";
4+
import {
5+
globalSettingsPath,
6+
loadSettings,
7+
markOnboarded,
8+
resolveLocalSettingsPath,
9+
} from "../config/settings.js";
510
import { activateHeldTelemetry, telemetryFirstRunPending } from "../telemetry/first-run.js";
611
import { runProviderSetup } from "./provider-setup.js";
12+
import { runWelcome } from "./welcome.js";
713

814
export async function runOnboarding(config: UnconfiguredConfig): Promise<number> {
915
const settingsPath = config.globalSettingsPath;
10-
const existing = await loadSettings(settingsPath);
1116

1217
// Disclosure before any send: startup held telemetry because the notice
1318
// has never been shown, so render it here and treat a completed submit as
1419
// the affirmative action that activates telemetry (consent by proceeding).
1520
// Read from the TRUE global settings file — telemetry state never lives in
1621
// a --config override file.
17-
const trueGlobalSettings = await loadSettings(globalSettingsPath()).catch(() => null);
22+
const trueGlobalPath = globalSettingsPath();
23+
const trueGlobalSettings = await loadSettings(trueGlobalPath).catch(() => null);
1824
const showTelemetryNotice = telemetryFirstRunPending(trueGlobalSettings);
1925

26+
// Welcome is global first-run state (same TRUE global file as telemetry /
27+
// onboarded), independent of --config provider write targets. Already-
28+
// onboarded users who wiped providers jump straight to setup.
29+
if (trueGlobalSettings?.onboarded !== true) {
30+
const welcomed = await runWelcome();
31+
if (!welcomed) {
32+
return 1;
33+
}
34+
await markOnboarded(trueGlobalPath);
35+
}
36+
37+
// Load the provider write-target after welcome so a same-path markOnboarded
38+
// is preserved when setup merges the new provider into existing settings.
39+
const existing = await loadSettings(settingsPath);
40+
2041
const submitted = await runProviderSetup({
2142
showTelemetryNotice,
2243
existingProviderNames: Object.keys(existing?.providers ?? {}),
@@ -37,7 +58,7 @@ export async function runOnboarding(config: UnconfiguredConfig): Promise<number>
3758
// Completing setup with the disclosure on screen is the affirmative action
3859
// that unlocks telemetry and fires the held cli_start.
3960
if (showTelemetryNotice) {
40-
await activateHeldTelemetry(globalSettingsPath());
61+
await activateHeldTelemetry(trueGlobalPath);
4162
}
4263

4364
const argv: string[] = ["--cwd", config.cwd];

src/tui/welcome.test.ts

Lines changed: 76 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,76 @@
1+
import { describe, expect, test } from "bun:test";
2+
3+
import { PRODUCT_NAME } from "../branding.js";
4+
import { MARK_LARGE, MARK_MID, MARK_SMALL } from "./mark-shape.js";
5+
import { createHarness } from "./harness.js";
6+
import { resolveWelcomeMarkGrid, runWelcome, WELCOME_LINE } from "./welcome.js";
7+
8+
describe("WELCOME_LINE", () => {
9+
test("names the product as the local software factory", () => {
10+
expect(WELCOME_LINE).toBe(`${PRODUCT_NAME}, your local software factory`);
11+
expect(WELCOME_LINE).toContain("Corbits Code, your local software factory");
12+
});
13+
});
14+
15+
describe("resolveWelcomeMarkGrid", () => {
16+
test("picks the largest mark that fits the terminal", () => {
17+
expect(resolveWelcomeMarkGrid(24, 80)).toBe(MARK_LARGE);
18+
expect(resolveWelcomeMarkGrid(14, 80)).toBe(MARK_MID);
19+
expect(resolveWelcomeMarkGrid(10, 40)).toBe(MARK_SMALL);
20+
expect(resolveWelcomeMarkGrid(4, 80)).toBeNull();
21+
});
22+
});
23+
24+
describe("runWelcome", () => {
25+
test("paints the product line and continues on keypress", async () => {
26+
const harness = await createHarness({ width: 80, height: 30 });
27+
const done = runWelcome({
28+
createRenderer: async () => harness.renderer,
29+
autoAdvanceMs: 60_000,
30+
now: () => 2_000,
31+
});
32+
try {
33+
await harness.renderOnce();
34+
await harness.renderOnce();
35+
expect(harness.captureCharFrame()).toContain(WELCOME_LINE);
36+
37+
harness.pressKey("Enter");
38+
await expect(done).resolves.toBe(true);
39+
} finally {
40+
// If the assertion failed before Enter, cancel so timers cannot leak.
41+
harness.pressKey("Ctrl+C");
42+
await Promise.race([done, new Promise((r) => setTimeout(r, 50))]);
43+
harness.destroy();
44+
}
45+
});
46+
47+
test("cancels on Ctrl+C without continuing", async () => {
48+
const harness = await createHarness({ width: 80, height: 30 });
49+
const done = runWelcome({
50+
createRenderer: async () => harness.renderer,
51+
autoAdvanceMs: 60_000,
52+
now: () => 2_000,
53+
});
54+
try {
55+
await harness.renderOnce();
56+
harness.pressKey("Ctrl+C");
57+
await expect(done).resolves.toBe(false);
58+
} finally {
59+
harness.destroy();
60+
}
61+
});
62+
63+
test("auto-advances when the timer fires", async () => {
64+
const harness = await createHarness({ width: 80, height: 30 });
65+
const done = runWelcome({
66+
createRenderer: async () => harness.renderer,
67+
autoAdvanceMs: 20,
68+
now: () => 2_000,
69+
});
70+
try {
71+
await expect(done).resolves.toBe(true);
72+
} finally {
73+
harness.destroy();
74+
}
75+
});
76+
});

0 commit comments

Comments
 (0)