pi-archimedes 2.7.3 → 2.9.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +5 -16
- package/package.json +16 -15
- package/src/config.ts +21 -7
- package/src/factory-lifecycle.test.ts +74 -25
- package/src/index.ts +36 -24
- package/src/onboarding/index.test.ts +391 -0
- package/src/onboarding/index.ts +80 -0
- package/src/onboarding/overlay.test.ts +503 -0
- package/src/onboarding/overlay.ts +255 -0
- package/src/plugins.test.ts +24 -10
- package/src/plugins.ts +5 -4
- package/src/settings.ts +25 -21
|
@@ -0,0 +1,391 @@
|
|
|
1
|
+
import { describe, it, expect, vi, beforeEach } from "vitest";
|
|
2
|
+
import type { ExtensionContext } from "@earendil-works/pi-coding-agent";
|
|
3
|
+
import type { OnboardingResult } from "./overlay.js";
|
|
4
|
+
import { runOnboarding } from "./index.js";
|
|
5
|
+
|
|
6
|
+
// ── Mock settings-io with an in-memory store ────────────────────────────
|
|
7
|
+
|
|
8
|
+
vi.mock("@pi-archimedes/core/settings-io", () => {
|
|
9
|
+
const store: Record<string, Record<string, unknown>> = {};
|
|
10
|
+
return {
|
|
11
|
+
loadConfig: vi.fn(
|
|
12
|
+
(ns: string, defaults: object) =>
|
|
13
|
+
({ ...defaults, ...(store[ns] ?? {}) } as object),
|
|
14
|
+
),
|
|
15
|
+
saveConfig: vi.fn((ns: string, config: object) => {
|
|
16
|
+
store[ns] = { ...config };
|
|
17
|
+
}),
|
|
18
|
+
removeConfig: vi.fn((ns: string) => {
|
|
19
|
+
delete store[ns];
|
|
20
|
+
}),
|
|
21
|
+
isConfigEnabled: vi.fn((ns: string) => {
|
|
22
|
+
const cfg = store[ns] ?? {};
|
|
23
|
+
return cfg.enabled !== false;
|
|
24
|
+
}),
|
|
25
|
+
setConfigEnabled: vi.fn((ns: string, enabled: boolean) => {
|
|
26
|
+
if (enabled) {
|
|
27
|
+
const cfg = { ...(store[ns] ?? {}) };
|
|
28
|
+
delete cfg.enabled;
|
|
29
|
+
if (Object.keys(cfg).length === 0) delete store[ns];
|
|
30
|
+
else store[ns] = cfg;
|
|
31
|
+
} else {
|
|
32
|
+
store[ns] = { ...(store[ns] ?? {}), enabled: false };
|
|
33
|
+
}
|
|
34
|
+
}),
|
|
35
|
+
// Concurrency-safe read-modify-write: apply the mutation to the merged
|
|
36
|
+
// current state, persist it, return the result (mirrors the real shape).
|
|
37
|
+
updateConfig: vi.fn(
|
|
38
|
+
(ns: string, defaults: object, mutate: (c: object) => object) => {
|
|
39
|
+
const current = { ...defaults, ...(store[ns] ?? {}) };
|
|
40
|
+
const next = mutate(current);
|
|
41
|
+
store[ns] = { ...next };
|
|
42
|
+
return next;
|
|
43
|
+
},
|
|
44
|
+
),
|
|
45
|
+
// Exposed for test setup/teardown only
|
|
46
|
+
__store: store,
|
|
47
|
+
};
|
|
48
|
+
});
|
|
49
|
+
|
|
50
|
+
const settingsIo = await import("@pi-archimedes/core/settings-io");
|
|
51
|
+
const mockStore = (settingsIo as unknown as { __store: Record<string, Record<string, unknown>> }).__store;
|
|
52
|
+
const { loadConfig, updateConfig } = settingsIo;
|
|
53
|
+
|
|
54
|
+
// ── Mock the ui config module (the pre-selection source) ─────────────────
|
|
55
|
+
|
|
56
|
+
vi.mock("@pi-archimedes/ui/config", () => ({
|
|
57
|
+
DEFAULT_UI_CONFIG: {
|
|
58
|
+
thinkingStyle: "Full",
|
|
59
|
+
toolStyle: "Native",
|
|
60
|
+
editorSpinStyle: "pendulum",
|
|
61
|
+
},
|
|
62
|
+
loadUIConfig: vi.fn(() => ({
|
|
63
|
+
thinkingStyle: "Full",
|
|
64
|
+
toolStyle: "Native",
|
|
65
|
+
editorSpinStyle: "pendulum",
|
|
66
|
+
})),
|
|
67
|
+
normalizeThinkingStyle: vi.fn((v: unknown) => (v === "Compact" ? "Compact" : "Full")),
|
|
68
|
+
normalizeToolStyle: vi.fn((v: unknown) => (v === "Native" ? "Native" : "Minimal")),
|
|
69
|
+
SPINNER_STYLES: ["typing", "pulse", "rain"],
|
|
70
|
+
}));
|
|
71
|
+
|
|
72
|
+
// ── Mock the plugin gate (per-plugin enabled flags) ──────────────────────
|
|
73
|
+
|
|
74
|
+
// Current on/off state the (mocked) isPluginEnabled reads; setPluginEnabled
|
|
75
|
+
// mutates it, so the changed-only diff is observable across calls.
|
|
76
|
+
const pluginState: Record<string, boolean> = {
|
|
77
|
+
ui: true,
|
|
78
|
+
footer: true,
|
|
79
|
+
todo: false,
|
|
80
|
+
};
|
|
81
|
+
|
|
82
|
+
vi.mock("../plugins.js", () => ({
|
|
83
|
+
PLUGINS: [
|
|
84
|
+
{ id: "ui", label: "UI", description: "", namespace: "archimedes.ui", load: () => Promise.resolve() },
|
|
85
|
+
{ id: "footer", label: "Footer", description: "", namespace: "archimedes.footer", load: () => Promise.resolve() },
|
|
86
|
+
{ id: "todo", label: "Todo", description: "", namespace: "archimedes.todo", load: () => Promise.resolve() },
|
|
87
|
+
],
|
|
88
|
+
isPluginEnabled: vi.fn((id: string) => pluginState[id] ?? true),
|
|
89
|
+
setPluginEnabled: vi.fn((id: string, enabled: boolean) => {
|
|
90
|
+
pluginState[id] = enabled;
|
|
91
|
+
return true;
|
|
92
|
+
}),
|
|
93
|
+
}));
|
|
94
|
+
|
|
95
|
+
const { setPluginEnabled } = await import("../plugins.js");
|
|
96
|
+
|
|
97
|
+
// ── Mock the overlay component (capture the onDone callback) ─────────────
|
|
98
|
+
|
|
99
|
+
const captured = vi.hoisted(() => ({
|
|
100
|
+
onDone: undefined as ((r: OnboardingResult) => void) | undefined,
|
|
101
|
+
thinkingDefault: undefined as string | undefined,
|
|
102
|
+
toolDefault: undefined as string | undefined,
|
|
103
|
+
}));
|
|
104
|
+
|
|
105
|
+
vi.mock("./overlay.js", () => ({
|
|
106
|
+
createOnboardingOverlay: vi.fn(
|
|
107
|
+
(opts: {
|
|
108
|
+
thinkingDefault?: string;
|
|
109
|
+
toolDefault?: string;
|
|
110
|
+
onDone: (r: OnboardingResult) => void;
|
|
111
|
+
}) => {
|
|
112
|
+
captured.onDone = opts.onDone;
|
|
113
|
+
captured.thinkingDefault = opts.thinkingDefault;
|
|
114
|
+
captured.toolDefault = opts.toolDefault;
|
|
115
|
+
return {
|
|
116
|
+
render: () => [] as string[],
|
|
117
|
+
handleInput: () => {},
|
|
118
|
+
invalidate: () => {},
|
|
119
|
+
dispose: () => {},
|
|
120
|
+
};
|
|
121
|
+
},
|
|
122
|
+
),
|
|
123
|
+
}));
|
|
124
|
+
|
|
125
|
+
// ── Mock ExtensionContext ─────────────────────────────────────────────────
|
|
126
|
+
|
|
127
|
+
/** Build a mock ctx whose `ui.custom` INVOKES the passed factory (so the
|
|
128
|
+
* mocked createOnboardingOverlay runs and captured.onDone is set). */
|
|
129
|
+
function makeCtx(mode: "tui" | "rpc") {
|
|
130
|
+
const custom = vi.fn(
|
|
131
|
+
(factory: (
|
|
132
|
+
tui: unknown,
|
|
133
|
+
theme: unknown,
|
|
134
|
+
keybindings: unknown,
|
|
135
|
+
done: (result: unknown) => void,
|
|
136
|
+
) => unknown) => {
|
|
137
|
+
factory(
|
|
138
|
+
{} as never,
|
|
139
|
+
{ fg: (_t: string, s: string) => s } as never,
|
|
140
|
+
{} as never,
|
|
141
|
+
vi.fn(),
|
|
142
|
+
);
|
|
143
|
+
return Promise.resolve(undefined);
|
|
144
|
+
},
|
|
145
|
+
);
|
|
146
|
+
const ctx = {
|
|
147
|
+
mode,
|
|
148
|
+
ui: { custom, notify: vi.fn() },
|
|
149
|
+
} as unknown as ExtensionContext;
|
|
150
|
+
return { ctx, custom };
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
beforeEach(() => {
|
|
154
|
+
for (const key of Object.keys(mockStore)) delete mockStore[key];
|
|
155
|
+
vi.clearAllMocks();
|
|
156
|
+
pluginState.ui = true;
|
|
157
|
+
pluginState.footer = true;
|
|
158
|
+
pluginState.todo = false;
|
|
159
|
+
captured.onDone = undefined;
|
|
160
|
+
captured.thinkingDefault = undefined;
|
|
161
|
+
captured.toolDefault = undefined;
|
|
162
|
+
});
|
|
163
|
+
|
|
164
|
+
// ── Gates ─────────────────────────────────────────────────────────────────
|
|
165
|
+
|
|
166
|
+
describe("runOnboarding gates", () => {
|
|
167
|
+
it("opens the onboarding overlay in TUI mode when the marker is unset", async () => {
|
|
168
|
+
const { ctx, custom } = makeCtx("tui");
|
|
169
|
+
await runOnboarding(ctx);
|
|
170
|
+
expect(custom).toHaveBeenCalledTimes(1);
|
|
171
|
+
expect(captured.onDone).toBeDefined();
|
|
172
|
+
});
|
|
173
|
+
|
|
174
|
+
it("passes the normalized thinking + tool defaults independently to the overlay", async () => {
|
|
175
|
+
// The mocked loadUIConfig returns thinkingStyle "Full" / toolStyle "Native";
|
|
176
|
+
// normalizeToolStyle maps "Native" → "Native" and anything else → "Minimal".
|
|
177
|
+
const { ctx } = makeCtx("tui");
|
|
178
|
+
await runOnboarding(ctx);
|
|
179
|
+
expect(captured.thinkingDefault).toBe("Full");
|
|
180
|
+
expect(captured.toolDefault).toBe("Native");
|
|
181
|
+
});
|
|
182
|
+
|
|
183
|
+
it("does not open the overlay in non-TUI modes (marker not consumed)", async () => {
|
|
184
|
+
const { ctx, custom } = makeCtx("rpc");
|
|
185
|
+
await runOnboarding(ctx);
|
|
186
|
+
expect(custom).not.toHaveBeenCalled();
|
|
187
|
+
// "marker not consumed" verified: the marker was NOT written — the store's
|
|
188
|
+
// archimedes.meta stays untouched (a later TUI session still gets the
|
|
189
|
+
// onboarding). No settings write of any kind happened either.
|
|
190
|
+
expect(mockStore["archimedes.meta"]).toBeUndefined();
|
|
191
|
+
expect(vi.mocked(updateConfig)).not.toHaveBeenCalled();
|
|
192
|
+
});
|
|
193
|
+
|
|
194
|
+
it("re-checks the marker after the defer (a concurrent session may have set it)", async () => {
|
|
195
|
+
// Simulate the marker being set BETWEEN the two loadConfig reads: the first
|
|
196
|
+
// read (gate 2) still sees it unset (as a side effect, the concurrent
|
|
197
|
+
// session sets it), so the post-defer re-check (the second read) sees it
|
|
198
|
+
// set and bails before opening the overlay.
|
|
199
|
+
vi.mocked(loadConfig)
|
|
200
|
+
.mockImplementationOnce(() => {
|
|
201
|
+
mockStore["archimedes.meta"] = { onboarded: true };
|
|
202
|
+
return { onboarded: false } as object;
|
|
203
|
+
})
|
|
204
|
+
.mockImplementationOnce(
|
|
205
|
+
((ns: string, defaults: object) =>
|
|
206
|
+
({ ...defaults, ...(mockStore[ns] ?? {}) } as object)) as typeof loadConfig,
|
|
207
|
+
);
|
|
208
|
+
const { ctx, custom } = makeCtx("tui");
|
|
209
|
+
await runOnboarding(ctx);
|
|
210
|
+
expect(custom).not.toHaveBeenCalled(); // the re-check caught it
|
|
211
|
+
expect(captured.onDone).toBeUndefined();
|
|
212
|
+
});
|
|
213
|
+
|
|
214
|
+
it("does not open the overlay when the marker is already set", async () => {
|
|
215
|
+
mockStore["archimedes.meta"] = { onboarded: true };
|
|
216
|
+
const { ctx, custom } = makeCtx("tui");
|
|
217
|
+
await runOnboarding(ctx);
|
|
218
|
+
expect(custom).not.toHaveBeenCalled();
|
|
219
|
+
});
|
|
220
|
+
});
|
|
221
|
+
|
|
222
|
+
// ── Write logic (finish) ──────────────────────────────────────────────────
|
|
223
|
+
|
|
224
|
+
describe("runOnboarding write logic (onDone)", () => {
|
|
225
|
+
it("writes answered-only settings and sets the marker last when all steps are answered", async () => {
|
|
226
|
+
const { ctx } = makeCtx("tui");
|
|
227
|
+
await runOnboarding(ctx);
|
|
228
|
+
const onDone = captured.onDone;
|
|
229
|
+
expect(onDone).toBeDefined();
|
|
230
|
+
onDone!({
|
|
231
|
+
thinkingAnswered: true,
|
|
232
|
+
thinkingValue: "Compact",
|
|
233
|
+
toolAnswered: true,
|
|
234
|
+
toolValue: "Minimal", // independent of thinkingValue
|
|
235
|
+
pluginsAnswered: true,
|
|
236
|
+
pluginSelections: { ui: true, footer: false, todo: true },
|
|
237
|
+
spinnerAnswered: true,
|
|
238
|
+
spinnerValue: "pulse",
|
|
239
|
+
});
|
|
240
|
+
|
|
241
|
+
// ui namespace: thinkingStyle and toolStyle are written INDEPENDENTLY
|
|
242
|
+
// (different values here prove there is no shared seeding), plus the
|
|
243
|
+
// spinner choice.
|
|
244
|
+
const ui = mockStore["archimedes.ui"] ?? {};
|
|
245
|
+
expect(ui.thinkingStyle).toBe("Compact");
|
|
246
|
+
expect(ui.toolStyle).toBe("Minimal");
|
|
247
|
+
expect(ui.editorSpinStyle).toBe("pulse");
|
|
248
|
+
|
|
249
|
+
// plugins: only CHANGED flags written (footer on→off, todo off→on; ui unchanged)
|
|
250
|
+
expect(vi.mocked(setPluginEnabled)).toHaveBeenCalledTimes(2);
|
|
251
|
+
expect(vi.mocked(setPluginEnabled)).toHaveBeenCalledWith("footer", false);
|
|
252
|
+
expect(vi.mocked(setPluginEnabled)).toHaveBeenCalledWith("todo", true);
|
|
253
|
+
expect(vi.mocked(setPluginEnabled)).not.toHaveBeenCalledWith("ui", true);
|
|
254
|
+
|
|
255
|
+
// marker written LAST and set
|
|
256
|
+
const namespaces = vi.mocked(updateConfig).mock.calls.map((c) => c[0] as string);
|
|
257
|
+
expect(namespaces[namespaces.length - 1]).toBe("archimedes.meta");
|
|
258
|
+
expect(mockStore["archimedes.meta"]?.onboarded).toBe(true);
|
|
259
|
+
});
|
|
260
|
+
|
|
261
|
+
it("leaves an UNANSWERED style untouched when only the other is answered (strict: pre-seeded, not default-merged)", async () => {
|
|
262
|
+
// Pre-seed the store with values that DIFFER from the mocked defaults
|
|
263
|
+
// (defaults: thinkingStyle "Full", toolStyle "Native", editorSpinStyle
|
|
264
|
+
// "pendulum"), so the assertions can distinguish "the user's existing
|
|
265
|
+
// value was left untouched" from "the default was written" — updateConfig
|
|
266
|
+
// merges the defaults into the stored state, so an empty store would
|
|
267
|
+
// materialize the defaults either way.
|
|
268
|
+
mockStore["archimedes.ui"] = {
|
|
269
|
+
thinkingStyle: "Compact", // non-default (default is "Full")
|
|
270
|
+
toolStyle: "Native",
|
|
271
|
+
editorSpinStyle: "pulse", // non-default
|
|
272
|
+
};
|
|
273
|
+
const { ctx } = makeCtx("tui");
|
|
274
|
+
await runOnboarding(ctx);
|
|
275
|
+
captured.onDone!({
|
|
276
|
+
thinkingAnswered: false,
|
|
277
|
+
thinkingValue: "Full", // differs from the pre-seeded "Compact" — must NOT be seeded in
|
|
278
|
+
toolAnswered: true,
|
|
279
|
+
toolValue: "Minimal", // differs from the pre-seeded "Native" — must be written
|
|
280
|
+
pluginsAnswered: false,
|
|
281
|
+
pluginSelections: {},
|
|
282
|
+
spinnerAnswered: false,
|
|
283
|
+
spinnerValue: "",
|
|
284
|
+
});
|
|
285
|
+
|
|
286
|
+
const ui = mockStore["archimedes.ui"] ?? {};
|
|
287
|
+
expect(ui.toolStyle).toBe("Minimal"); // the payload value — the ANSWERED step was written (updated, not left at the pre-seeded "Native")
|
|
288
|
+
expect(ui.thinkingStyle).toBe("Compact"); // the PRE-SEEDED value, untouched (not the default "Full", not the payload "Full")
|
|
289
|
+
expect(ui.editorSpinStyle).toBe("pulse"); // pre-seeded, untouched
|
|
290
|
+
});
|
|
291
|
+
|
|
292
|
+
it("writes the answered thinking style and leaves an UNANSWERED tool style untouched (mirror of the above)", async () => {
|
|
293
|
+
// Mirror of the test above: only the THINKING step is answered. Pre-seed
|
|
294
|
+
// with non-default values so an unanswered step is provably left untouched
|
|
295
|
+
// rather than clobbered with the default.
|
|
296
|
+
mockStore["archimedes.ui"] = {
|
|
297
|
+
thinkingStyle: "Full",
|
|
298
|
+
toolStyle: "Native", // non-default (default is "Minimal")
|
|
299
|
+
editorSpinStyle: "pulse", // non-default
|
|
300
|
+
};
|
|
301
|
+
const { ctx } = makeCtx("tui");
|
|
302
|
+
await runOnboarding(ctx);
|
|
303
|
+
captured.onDone!({
|
|
304
|
+
thinkingAnswered: true,
|
|
305
|
+
thinkingValue: "Compact", // differs from the pre-seeded "Full" — must be written
|
|
306
|
+
toolAnswered: false,
|
|
307
|
+
toolValue: "Native", // differs from the pre-seeded "Minimal" — must NOT be seeded in
|
|
308
|
+
pluginsAnswered: false,
|
|
309
|
+
pluginSelections: {},
|
|
310
|
+
spinnerAnswered: false,
|
|
311
|
+
spinnerValue: "",
|
|
312
|
+
});
|
|
313
|
+
|
|
314
|
+
const ui = mockStore["archimedes.ui"] ?? {};
|
|
315
|
+
expect(ui.thinkingStyle).toBe("Compact"); // the payload value — the ANSWERED step was written (updated, not left at the pre-seeded "Full")
|
|
316
|
+
expect(ui.toolStyle).toBe("Native"); // the PRE-SEEDED value, untouched (not the default "Minimal", not the payload "Native")
|
|
317
|
+
expect(ui.editorSpinStyle).toBe("pulse"); // pre-seeded, untouched
|
|
318
|
+
});
|
|
319
|
+
|
|
320
|
+
it("skips all settings/plugin writes but still sets the marker when nothing is answered", async () => {
|
|
321
|
+
const { ctx } = makeCtx("tui");
|
|
322
|
+
await runOnboarding(ctx);
|
|
323
|
+
captured.onDone!({
|
|
324
|
+
thinkingAnswered: false,
|
|
325
|
+
thinkingValue: "Full",
|
|
326
|
+
toolAnswered: false,
|
|
327
|
+
toolValue: "Minimal",
|
|
328
|
+
pluginsAnswered: false,
|
|
329
|
+
pluginSelections: {},
|
|
330
|
+
spinnerAnswered: false,
|
|
331
|
+
spinnerValue: "",
|
|
332
|
+
});
|
|
333
|
+
|
|
334
|
+
expect(vi.mocked(updateConfig)).not.toHaveBeenCalledWith(
|
|
335
|
+
"archimedes.ui",
|
|
336
|
+
expect.anything(),
|
|
337
|
+
expect.anything(),
|
|
338
|
+
);
|
|
339
|
+
expect(vi.mocked(setPluginEnabled)).not.toHaveBeenCalled();
|
|
340
|
+
expect(mockStore["archimedes.meta"]?.onboarded).toBe(true);
|
|
341
|
+
});
|
|
342
|
+
|
|
343
|
+
it("leaves the marker unset when a settings write fails (self-heal)", async () => {
|
|
344
|
+
const consoleSpy = vi.spyOn(console, "error").mockImplementation(() => {});
|
|
345
|
+
// The first write in finish() is the ui write → make it throw.
|
|
346
|
+
vi.mocked(updateConfig).mockImplementationOnce(() => {
|
|
347
|
+
throw new Error("disk full");
|
|
348
|
+
});
|
|
349
|
+
const { ctx } = makeCtx("tui");
|
|
350
|
+
await runOnboarding(ctx);
|
|
351
|
+
captured.onDone!({
|
|
352
|
+
thinkingAnswered: true,
|
|
353
|
+
thinkingValue: "Compact",
|
|
354
|
+
toolAnswered: true,
|
|
355
|
+
toolValue: "Native",
|
|
356
|
+
pluginsAnswered: true,
|
|
357
|
+
pluginSelections: { ui: true, footer: true, todo: false },
|
|
358
|
+
spinnerAnswered: true,
|
|
359
|
+
spinnerValue: "pulse",
|
|
360
|
+
});
|
|
361
|
+
|
|
362
|
+
// The failed write aborts before the marker write → NOT set (a later
|
|
363
|
+
// session re-runs the onboarding and retries).
|
|
364
|
+
expect(mockStore["archimedes.meta"]?.onboarded).not.toBe(true);
|
|
365
|
+
consoleSpy.mockRestore();
|
|
366
|
+
});
|
|
367
|
+
|
|
368
|
+
it("runs the write at most once per invocation (onDone idempotence)", async () => {
|
|
369
|
+
const { ctx } = makeCtx("tui");
|
|
370
|
+
await runOnboarding(ctx);
|
|
371
|
+
const result: OnboardingResult = {
|
|
372
|
+
thinkingAnswered: true,
|
|
373
|
+
thinkingValue: "Compact",
|
|
374
|
+
toolAnswered: true,
|
|
375
|
+
toolValue: "Minimal",
|
|
376
|
+
pluginsAnswered: true,
|
|
377
|
+
pluginSelections: { ui: true, footer: true, todo: false },
|
|
378
|
+
spinnerAnswered: true,
|
|
379
|
+
spinnerValue: "pulse",
|
|
380
|
+
};
|
|
381
|
+
captured.onDone!(result);
|
|
382
|
+
captured.onDone!(result);
|
|
383
|
+
|
|
384
|
+
const uiCalls = vi.mocked(updateConfig).mock.calls.filter(
|
|
385
|
+
(c) => c[0] === "archimedes.ui",
|
|
386
|
+
);
|
|
387
|
+
expect(uiCalls.length).toBe(1);
|
|
388
|
+
// No plugin flags changed → no writes on either invocation.
|
|
389
|
+
expect(vi.mocked(setPluginEnabled)).not.toHaveBeenCalled();
|
|
390
|
+
});
|
|
391
|
+
});
|
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
import type { ExtensionContext } from "@earendil-works/pi-coding-agent";
|
|
2
|
+
import { loadConfig, updateConfig } from "@pi-archimedes/core/settings-io";
|
|
3
|
+
import { OVERLAY_CHROME } from "@pi-archimedes/core/overlay";
|
|
4
|
+
import {
|
|
5
|
+
DEFAULT_UI_CONFIG,
|
|
6
|
+
loadUIConfig,
|
|
7
|
+
normalizeThinkingStyle,
|
|
8
|
+
normalizeToolStyle,
|
|
9
|
+
SPINNER_STYLES,
|
|
10
|
+
} from "@pi-archimedes/ui/config";
|
|
11
|
+
import { isPluginEnabled, PLUGINS, setPluginEnabled } from "../plugins.js";
|
|
12
|
+
import { createOnboardingOverlay, type OnboardingResult } from "./overlay.js";
|
|
13
|
+
|
|
14
|
+
const META_NS = "archimedes.meta";
|
|
15
|
+
|
|
16
|
+
export async function runOnboarding(ctx: ExtensionContext): Promise<void> {
|
|
17
|
+
// Gate 1: TUI only (the marker is not consumed in non-TUI modes, so a later
|
|
18
|
+
// TUI session still gets the onboarding — same as offerKeybindingFix gate 2).
|
|
19
|
+
if (ctx.mode !== "tui") return;
|
|
20
|
+
// Gate 2: marker unset.
|
|
21
|
+
if (loadConfig(META_NS, { onboarded: false }).onboarded === true) return;
|
|
22
|
+
// Defer one tick so the TUI processes current state (mirrors the ask IPC relay).
|
|
23
|
+
await new Promise((resolve) => setImmediate(resolve));
|
|
24
|
+
// Re-check the marker after the defer (a concurrent session may have set it).
|
|
25
|
+
if (loadConfig(META_NS, { onboarded: false }).onboarded === true) return;
|
|
26
|
+
|
|
27
|
+
const ui = loadUIConfig();
|
|
28
|
+
const plugins = PLUGINS.map((p) => ({
|
|
29
|
+
id: p.id,
|
|
30
|
+
label: p.label,
|
|
31
|
+
description: p.description,
|
|
32
|
+
selected: isPluginEnabled(p.id),
|
|
33
|
+
}));
|
|
34
|
+
|
|
35
|
+
// Per-invocation idempotence: onDone runs the write at most once per runOnboarding.
|
|
36
|
+
// A failed write leaves the marker unset, so the NEXT session re-runs with a
|
|
37
|
+
// fresh `finished` and retries (self-heal).
|
|
38
|
+
let finished = false;
|
|
39
|
+
const finish = (result: OnboardingResult): void => {
|
|
40
|
+
if (finished) return;
|
|
41
|
+
finished = true;
|
|
42
|
+
const uiMutations: Partial<typeof DEFAULT_UI_CONFIG> = {};
|
|
43
|
+
if (result.thinkingAnswered) uiMutations.thinkingStyle = result.thinkingValue;
|
|
44
|
+
if (result.toolAnswered) uiMutations.toolStyle = result.toolValue;
|
|
45
|
+
if (result.spinnerAnswered) uiMutations.editorSpinStyle = result.spinnerValue as typeof DEFAULT_UI_CONFIG["editorSpinStyle"];
|
|
46
|
+
try {
|
|
47
|
+
if (Object.keys(uiMutations).length > 0) {
|
|
48
|
+
updateConfig("archimedes.ui", DEFAULT_UI_CONFIG, (c) => ({ ...c, ...uiMutations }));
|
|
49
|
+
}
|
|
50
|
+
if (result.pluginsAnswered) {
|
|
51
|
+
for (const p of PLUGINS) {
|
|
52
|
+
const selected = result.pluginSelections[p.id];
|
|
53
|
+
if (selected !== undefined && selected !== isPluginEnabled(p.id)) {
|
|
54
|
+
setPluginEnabled(p.id, selected);
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
updateConfig(META_NS, { onboarded: false }, (c) => ({ ...c, onboarded: true })); // marker last
|
|
59
|
+
} catch (err) {
|
|
60
|
+
console.error("[archimedes] onboarding save failed:", err); // marker NOT set → self-heals
|
|
61
|
+
}
|
|
62
|
+
};
|
|
63
|
+
|
|
64
|
+
await ctx.ui.custom((tui, theme, _keybindings, done) => {
|
|
65
|
+
return createOnboardingOverlay({
|
|
66
|
+
theme,
|
|
67
|
+
thinkingDefault: normalizeThinkingStyle(ui.thinkingStyle),
|
|
68
|
+
toolDefault: normalizeToolStyle(ui.toolStyle),
|
|
69
|
+
plugins,
|
|
70
|
+
spinners: SPINNER_STYLES,
|
|
71
|
+
spinnerDefault: ui.editorSpinStyle,
|
|
72
|
+
// The live spinner previews animate on a 40 ms tick (the TUI does not repaint on a global timer — the component owns the timer and asks for a repaint).
|
|
73
|
+
requestRender: () => tui.requestRender(),
|
|
74
|
+
onDone: (result: OnboardingResult) => {
|
|
75
|
+
finish(result);
|
|
76
|
+
done(undefined);
|
|
77
|
+
},
|
|
78
|
+
});
|
|
79
|
+
}, { overlay: true, overlayOptions: OVERLAY_CHROME });
|
|
80
|
+
}
|