openclacky 0.9.2 → 0.9.4

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.
@@ -1,80 +1,74 @@
1
- // onboard.js — First-run onboarding flow
1
+ // onboard.js — First-run setup flow
2
2
  //
3
- // Phase 1 (key_setup): User picks a provider, enters API key, tests & saves.
4
- // Phase 2 (soul_setup): Open a dedicated session and invoke the /onboard skill,
5
- // which uses interactive cards to collect preferences and
6
- // write SOUL.md + USER.md.
3
+ // Two distinct phases, now cleanly separated:
7
4
  //
8
- // Pattern: same as Tasks.createInSession() create session → select (subscribe)
9
- // send slash command. No custom pending state needed.
5
+ // key_setup → Show the full-screen setup-panel (language + API key).
6
+ // Hard block: nothing works without an API key.
7
+ // On success, automatically launches the /onboard session.
8
+ //
9
+ // soul_setup → API key is already configured, SOUL.md is missing.
10
+ // Automatically creates an /onboard session and boots the UI —
11
+ // no blocking panel shown, user lands directly in the session.
12
+ //
13
+ // The old onboard-panel (with phase-lang / phase-key / phase-soul) is gone.
14
+ // setup-panel handles the mandatory first-run setup.
15
+ // /onboard skill handles the optional personalisation inside a chat session.
10
16
 
11
17
  const Onboard = (() => {
12
- let _providers = [];
13
- let _phase = null; // "key_setup" | "soul_setup"
14
- let _selectedLang = I18n.lang(); // language chosen in phase-lang
18
+ let _providers = [];
19
+ let _selectedLang = I18n.lang(); // language chosen during setup
15
20
 
16
21
  // ── Public API ──────────────────────────────────────────────────────────────
17
22
 
18
- // Check onboard status and show panel if needed.
19
- // Returns { needsOnboard, phase } so the caller can decide whether to block
20
- // normal UI boot (only "key_setup" is a hard block; "soul_setup" is soft).
21
23
  async function check() {
22
24
  try {
23
25
  const res = await fetch("/api/onboard/status");
24
26
  const data = await res.json();
25
27
  if (!data.needs_onboard) return { needsOnboard: false, phase: null };
26
28
 
27
- _phase = data.phase;
29
+ const phase = data.phase;
28
30
 
29
- // If soul_setup is needed but the user already has a session in progress
30
- // (hash contains a session id), skip the onboard panel entirely and let
31
- // normal UI boot restore that session — prevents the language page from
32
- // flashing on refresh during an active onboard session.
33
- if (_phase === "soul_setup" && window.location.hash.includes("session/")) {
34
- return { needsOnboard: false, phase: null };
31
+ if (phase === "key_setup") {
32
+ // Mandatory: show full-screen setup panel, block boot.
33
+ _showSetup();
34
+ return { needsOnboard: true, phase };
35
35
  }
36
36
 
37
- await _show(_phase);
38
- return { needsOnboard: true, phase: data.phase };
39
- } catch (e) {
40
- // If the status check fails, proceed with normal boot
37
+ if (phase === "soul_setup") {
38
+ // Skip any blocking panel — just auto-launch the /onboard session.
39
+ // If the user already has an onboard session in progress (hash has a
40
+ // session id), restore it instead of creating a duplicate.
41
+ if (window.location.hash.includes("session/")) {
42
+ return { needsOnboard: false, phase: null };
43
+ }
44
+ await _launchOnboardSession();
45
+ return { needsOnboard: true, phase };
46
+ }
47
+
48
+ return { needsOnboard: false, phase: null };
49
+ } catch (_) {
41
50
  return { needsOnboard: false, phase: null };
42
51
  }
43
52
  }
44
53
 
45
- // ── Internal ────────────────────────────────────────────────────────────────
54
+ // ── Setup panel (key_setup) ─────────────────────────────────────────────────
46
55
 
47
- async function _show(phase) {
48
- // Show onboard panel, hide everything else
49
- Router.navigate("onboard");
50
-
51
- // Render the empty session list placeholder immediately (WS is not connected yet
52
- // during onboarding, so renderList() would never be called otherwise).
56
+ function _showSetup() {
57
+ document.body.classList.add("setup-mode");
58
+ Router.navigate("setup");
53
59
  Sessions.renderList();
54
60
 
55
- // Always start with language selection (phase-lang) first
56
- _showPhase("lang");
57
- _bindLangPhase(phase);
58
- }
59
-
60
- function _showPhase(which) {
61
- $("onboard-phase-lang").style.display = which === "lang" ? "" : "none";
62
- $("onboard-phase-key").style.display = which === "key" ? "" : "none";
63
- $("onboard-phase-soul").style.display = which === "soul" ? "" : "none";
64
- $("onboard-steps").style.display = which === "lang" ? "none" : "";
65
- $("step-dot-1").className = "onboard-step" + (which === "key" ? " active" : " done");
66
- $("step-dot-2").className = "onboard-step" + (which === "soul" ? " active" : "");
61
+ _selectedLang = I18n.lang();
62
+ _bindLangStep();
67
63
  }
68
64
 
69
- // ── Phase 0: Language selection ────────────────────────────────────────────
70
-
71
- function _bindLangPhase(nextPhase) {
72
- const btnEn = $("onboard-btn-lang-en");
73
- const btnZh = $("onboard-btn-lang-zh");
74
- const btnNext = $("onboard-btn-lang-next");
65
+ // Step 1 language selection
66
+ function _bindLangStep() {
67
+ const btnEn = $("setup-btn-lang-en");
68
+ const btnZh = $("setup-btn-lang-zh");
69
+ const btnNext = $("setup-btn-lang-next");
75
70
 
76
- // Reflect current language
77
- _updateLangBtns(I18n.lang());
71
+ _updateLangBtns(_selectedLang);
78
72
 
79
73
  btnEn.addEventListener("click", () => {
80
74
  _selectedLang = "en";
@@ -89,174 +83,183 @@ const Onboard = (() => {
89
83
  });
90
84
 
91
85
  btnNext.addEventListener("click", async () => {
92
- if (nextPhase === "key_setup") {
93
- _showPhase("key");
94
- await _loadProviders();
95
- _bindKeyPhase();
96
- } else {
97
- // Key is already configured — skip the soul confirmation page and
98
- // jump straight into the onboard session.
99
- await _startSoulSession();
100
- }
86
+ _showSetupStep("key");
87
+ await _loadProviders();
88
+ _bindKeyStep();
101
89
  });
102
90
  }
103
91
 
104
92
  function _updateLangBtns(lang) {
105
- const btnEn = $("onboard-btn-lang-en");
106
- const btnZh = $("onboard-btn-lang-zh");
93
+ const btnEn = $("setup-btn-lang-en");
94
+ const btnZh = $("setup-btn-lang-zh");
95
+ const btnNext = $("setup-btn-lang-next");
107
96
  if (!btnEn || !btnZh) return;
108
97
  btnEn.classList.toggle("active", lang === "en");
109
98
  btnZh.classList.toggle("active", lang === "zh");
110
- // Update the Continue button label after language switch
111
- const btnNext = $("onboard-btn-lang-next");
112
99
  if (btnNext) btnNext.textContent = lang === "zh" ? "继续 →" : "Continue →";
113
100
  }
114
101
 
115
- // ── Phase 1: Key setup ──────────────────────────────────────────────────────
102
+ function _showSetupStep(step) {
103
+ $("setup-phase-lang").style.display = step === "lang" ? "" : "none";
104
+ $("setup-phase-key").style.display = step === "key" ? "" : "none";
105
+ $("setup-dot-1").className = "setup-step" + (step === "lang" ? " active" : " done");
106
+ $("setup-dot-2").className = "setup-step" + (step === "key" ? " active" : "");
107
+ }
108
+
109
+ // Step 2 — API key setup
110
+ // Guard: providers are loaded only once; dropdown is bound only once.
111
+ let _providersLoaded = false;
112
+ let _dropdownBound = false;
116
113
 
117
114
  async function _loadProviders() {
118
- try {
119
- const res = await fetch("/api/providers");
120
- const data = await res.json();
121
- _providers = data.providers || [];
122
-
123
- const dropdown = $("onboard-provider-dropdown");
124
- _providers.forEach(p => {
125
- const option = document.createElement("div");
126
- option.className = "custom-select-option";
127
- option.dataset.value = p.id;
128
- option.textContent = p.name;
129
- dropdown.appendChild(option);
130
- });
131
-
132
- // Bind custom dropdown events
133
- _bindCustomDropdown();
134
- } catch (_) { /* ignore */ }
115
+ // Fetch providers only once; on Back→Next, re-render from cache.
116
+ if (!_providersLoaded) {
117
+ try {
118
+ const res = await fetch("/api/providers");
119
+ const data = await res.json();
120
+ _providers = data.providers || [];
121
+ _providersLoaded = true;
122
+ } catch (_) { /* ignore */ }
123
+ }
124
+
125
+ // Always re-render options (dropdown is cleared on each visit to Step 2)
126
+ _renderProviderOptions();
127
+ // Bind event listeners only once (delegation-based, safe to skip on re-entry)
128
+ _bindCustomDropdown();
129
+ }
130
+
131
+ function _renderProviderOptions() {
132
+ const dropdown = $("setup-provider-dropdown");
133
+ // Clear any previously rendered options before re-rendering
134
+ dropdown.innerHTML = "";
135
+
136
+ _providers.forEach(p => {
137
+ const opt = document.createElement("div");
138
+ opt.className = "custom-select-option";
139
+ opt.dataset.value = p.id;
140
+ opt.textContent = p.name;
141
+ dropdown.appendChild(opt);
142
+ });
143
+
144
+ // Always append "Custom" as the last option
145
+ const custom = document.createElement("div");
146
+ custom.className = "custom-select-option";
147
+ custom.dataset.value = "__custom__";
148
+ custom.dataset.i18n = "onboard.provider.custom";
149
+ custom.textContent = I18n.t("onboard.provider.custom");
150
+ dropdown.appendChild(custom);
135
151
  }
136
152
 
137
153
  function _bindCustomDropdown() {
138
- const wrapper = $("onboard-provider-wrapper");
139
- const trigger = wrapper.querySelector(".custom-select-trigger");
140
- const dropdown = wrapper.querySelector(".custom-select-dropdown");
154
+ if (_dropdownBound) return; // listeners already attached
155
+ _dropdownBound = true;
156
+
157
+ const wrapper = $("setup-provider-wrapper");
158
+ const trigger = wrapper.querySelector(".custom-select-trigger");
159
+ const dropdown = wrapper.querySelector(".custom-select-dropdown");
141
160
  const valueSpan = trigger.querySelector(".custom-select-value");
142
- const options = dropdown.querySelectorAll(".custom-select-option");
143
161
 
144
- // Toggle dropdown
145
- trigger.addEventListener("click", (e) => {
162
+ trigger.addEventListener("click", e => {
146
163
  e.stopPropagation();
147
- const isOpen = dropdown.classList.contains("open");
148
- if (isOpen) {
149
- dropdown.classList.remove("open");
150
- trigger.classList.remove("open");
151
- } else {
152
- dropdown.classList.add("open");
153
- trigger.classList.add("open");
154
- }
164
+ const open = dropdown.classList.toggle("open");
165
+ trigger.classList.toggle("open", open);
155
166
  });
156
167
 
157
- // Select option
158
- options.forEach(option => {
159
- option.addEventListener("click", (e) => {
160
- e.stopPropagation();
161
- const value = option.dataset.value;
162
- const text = option.textContent;
163
-
164
- // Update UI
165
- valueSpan.textContent = text;
166
- if (value) {
167
- valueSpan.classList.remove("placeholder");
168
- } else {
169
- valueSpan.classList.add("placeholder");
170
- }
171
-
172
- // Update selected state
173
- options.forEach(opt => opt.classList.remove("selected"));
174
- option.classList.add("selected");
175
-
176
- // Close dropdown
177
- dropdown.classList.remove("open");
178
- trigger.classList.remove("open");
179
-
180
- // Auto-fill model & base_url if a provider preset was selected
181
- if (value) {
182
- const preset = _providers.find(p => p.id === value);
183
- if (preset) {
184
- $("onboard-model").value = preset.default_model || "";
185
- $("onboard-base-url").value = preset.base_url || "";
186
- }
168
+ // Use event delegation on the dropdown container — works for any option
169
+ // including dynamically added ones (no need to re-bind on Back/Next).
170
+ dropdown.addEventListener("click", e => {
171
+ e.stopPropagation();
172
+ const opt = e.target.closest(".custom-select-option");
173
+ if (!opt) return;
174
+
175
+ const value = opt.dataset.value;
176
+ valueSpan.textContent = opt.textContent;
177
+ valueSpan.classList.toggle("placeholder", !value);
178
+ dropdown.querySelectorAll(".custom-select-option").forEach(o => o.classList.remove("selected"));
179
+ opt.classList.add("selected");
180
+ dropdown.classList.remove("open");
181
+ trigger.classList.remove("open");
182
+
183
+ if (value === "__custom__") {
184
+ // Custom: clear presets so the user can fill in their own values
185
+ $("setup-model").value = "";
186
+ $("setup-base-url").value = "";
187
+ } else if (value) {
188
+ const preset = _providers.find(p => p.id === value);
189
+ if (preset) {
190
+ $("setup-model").value = preset.default_model || "";
191
+ $("setup-base-url").value = preset.base_url || "";
187
192
  }
188
- });
193
+ }
189
194
  });
190
195
 
191
- // Close dropdown when clicking outside
196
+ // Single global click-outside listener
192
197
  document.addEventListener("click", () => {
193
198
  dropdown.classList.remove("open");
194
199
  trigger.classList.remove("open");
195
200
  });
196
201
  }
197
202
 
198
- function _bindKeyPhase() {
199
- // Toggle key visibility with icon change
200
- const toggleKeyBtn = $("onboard-toggle-key");
201
- const apiKeyInput = $("onboard-api-key");
202
- const eyeIcon = toggleKeyBtn.querySelector("svg");
203
-
204
- toggleKeyBtn.addEventListener("click", () => {
205
- const isPassword = apiKeyInput.type === "password";
206
- apiKeyInput.type = isPassword ? "text" : "password";
207
-
208
- // Update icon
209
- if (isPassword) {
210
- // Show eye-off icon
211
- eyeIcon.innerHTML = `
212
- <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M13.875 18.825A10.05 10.05 0 0112 19c-4.478 0-8.268-2.943-9.543-7a9.97 9.97 0 011.563-3.029m5.858.908a3 3 0 114.243 4.243M9.878 9.878l4.242 4.242M9.88 9.88l-3.29-3.29m7.532 7.532l3.29 3.29M3 3l3.59 3.59m0 0A9.953 9.953 0 0112 5c4.478 0 8.268 2.943 9.543 7a10.025 10.025 0 01-4.132 5.411m0 0L21 21"></path>
213
- `;
214
- } else {
215
- // Show eye icon
216
- eyeIcon.innerHTML = `
217
- <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15 12a3 3 0 11-6 0 3 3 0 016 0z"></path>
218
- <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M2.458 12C3.732 7.943 7.523 5 12 5c4.478 0 8.268 2.943 9.542 7-1.274 4.057-5.064 7-9.542 7-4.477 0-8.268-2.943-9.542-7z"></path>
219
- `;
220
- }
203
+ // Guard: key-step listeners are attached only once
204
+ let _keyStepBound = false;
205
+
206
+ function _bindKeyStep() {
207
+ if (_keyStepBound) return;
208
+ _keyStepBound = true;
209
+
210
+ // Toggle key visibility
211
+ const toggleBtn = $("setup-toggle-key");
212
+ const keyInput = $("setup-api-key");
213
+ const eyeIcon = toggleBtn.querySelector("svg");
214
+
215
+ toggleBtn.addEventListener("click", () => {
216
+ const isPassword = keyInput.type === "password";
217
+ keyInput.type = isPassword ? "text" : "password";
218
+ eyeIcon.innerHTML = isPassword
219
+ ? `<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M13.875 18.825A10.05 10.05 0 0112 19c-4.478 0-8.268-2.943-9.543-7a9.97 9.97 0 011.563-3.029m5.858.908a3 3 0 114.243 4.243M9.878 9.878l4.242 4.242M9.88 9.88l-3.29-3.29m7.532 7.532l3.29 3.29M3 3l3.59 3.59m0 0A9.953 9.953 0 0112 5c4.478 0 8.268 2.943 9.543 7a10.025 10.025 0 01-4.132 5.411m0 0L21 21"></path>`
220
+ : `<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15 12a3 3 0 11-6 0 3 3 0 016 0z"></path><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M2.458 12C3.732 7.943 7.523 5 12 5c4.478 0 8.268 2.943 9.542 7-1.274 4.057-5.064 7-9.542 7-4.477 0-8.268-2.943-9.542-7z"></path>`;
221
221
  });
222
222
 
223
- // Test & Continue
224
- $("onboard-btn-test").addEventListener("click", _testAndSave);
223
+ $("setup-btn-test").addEventListener("click", _testAndSave);
224
+
225
+ // Back to Step 1
226
+ $("setup-btn-back").addEventListener("click", () => {
227
+ _showSetupStep("lang");
228
+ });
225
229
  }
226
230
 
227
231
  async function _testAndSave() {
228
- const btn = $("onboard-btn-test");
229
- const model = $("onboard-model").value.trim();
230
- const baseUrl = $("onboard-base-url").value.trim();
231
- const apiKey = $("onboard-api-key").value.trim();
232
+ const btn = $("setup-btn-test");
233
+ const model = $("setup-model").value.trim();
234
+ const baseUrl = $("setup-base-url").value.trim();
235
+ const apiKey = $("setup-api-key").value.trim();
236
+ const zh = _selectedLang === "zh";
232
237
 
233
238
  if (!model || !baseUrl || !apiKey) {
234
- _setTestResult(false, I18n.lang() === "zh"
235
- ? "请填写模型、Base URL 和 API Key。"
236
- : "Please fill in Model, Base URL and API Key.");
239
+ _setResult(false, zh ? "请填写模型、Base URL 和 API Key。" : "Please fill in Model, Base URL and API Key.");
237
240
  return;
238
241
  }
239
242
 
240
243
  btn.disabled = true;
241
244
  btn.textContent = I18n.t("onboard.key.testing");
242
- _setTestResult(null, "");
245
+ _setResult(null, "");
243
246
 
244
247
  // Step 1: test connection
245
248
  try {
246
- const testRes = await fetch("/api/config/test", {
249
+ const res = await fetch("/api/config/test", {
247
250
  method: "POST",
248
251
  headers: { "Content-Type": "application/json" },
249
252
  body: JSON.stringify({ model, base_url: baseUrl, api_key: apiKey, index: 0 })
250
253
  });
251
- const testData = await testRes.json();
252
- if (!testData.ok) {
253
- _setTestResult(false, testData.message || (I18n.lang() === "zh" ? "连接失败。" : "Connection failed."));
254
+ const data = await res.json();
255
+ if (!data.ok) {
256
+ _setResult(false, data.message || (zh ? "连接失败。" : "Connection failed."));
254
257
  btn.disabled = false;
255
258
  btn.textContent = I18n.t("onboard.key.btn.test");
256
259
  return;
257
260
  }
258
261
  } catch (e) {
259
- _setTestResult(false, e.message);
262
+ _setResult(false, e.message);
260
263
  btn.disabled = false;
261
264
  btn.textContent = I18n.t("onboard.key.btn.test");
262
265
  return;
@@ -265,93 +268,68 @@ const Onboard = (() => {
265
268
  // Step 2: save config
266
269
  btn.textContent = I18n.t("onboard.key.saving");
267
270
  try {
268
- const saveRes = await fetch("/api/config", {
271
+ const res = await fetch("/api/config", {
269
272
  method: "POST",
270
273
  headers: { "Content-Type": "application/json" },
271
274
  body: JSON.stringify({
272
275
  models: [{ type: "default", model, base_url: baseUrl, api_key: apiKey, anthropic_format: false }]
273
276
  })
274
277
  });
275
- const saveData = await saveRes.json();
276
- if (!saveData.ok) {
277
- _setTestResult(false, saveData.error || (I18n.lang() === "zh" ? "保存失败。" : "Save failed."));
278
+ const data = await res.json();
279
+ if (!data.ok) {
280
+ _setResult(false, data.error || (zh ? "保存失败。" : "Save failed."));
278
281
  btn.disabled = false;
279
282
  btn.textContent = I18n.t("onboard.key.btn.test");
280
283
  return;
281
284
  }
282
285
  } catch (e) {
283
- _setTestResult(false, e.message);
286
+ _setResult(false, e.message);
284
287
  btn.disabled = false;
285
288
  btn.textContent = I18n.t("onboard.key.btn.test");
286
289
  return;
287
290
  }
288
291
 
289
- // Step 3: advance directly to the onboard session (skip the soul confirmation page)
290
- _setTestResult(true, I18n.lang() === "zh" ? "连接成功!" : "Connected!");
291
- setTimeout(() => {
292
- _startSoulSession();
293
- }, 600);
292
+ // Success show brief feedback then auto-launch /onboard session
293
+ _setResult(true, zh ? "连接成功!" : "Connected!");
294
+ setTimeout(() => _launchOnboardSession(), 600);
294
295
  }
295
296
 
296
- function _setTestResult(ok, msg) {
297
- const el = $("onboard-test-result");
298
- if (ok === null) { el.textContent = ""; el.className = "onboard-test-result"; return; }
297
+ function _setResult(ok, msg) {
298
+ const el = $("setup-test-result");
299
+ if (!el) return;
300
+ if (ok === null) { el.textContent = ""; el.className = "setup-test-result"; return; }
299
301
  el.textContent = ok ? "✓ " + msg : "✗ " + msg;
300
- el.className = "onboard-test-result " + (ok ? "result-ok" : "result-fail");
301
- }
302
-
303
- // ── Phase 2: Soul setup ──────────────────────────────────────────────────────
304
-
305
- function _bindSoulPhase() {
306
- $("onboard-btn-start-soul").addEventListener("click", _startSoulSession);
307
- $("onboard-btn-skip").addEventListener("click", _skipSoul);
302
+ el.className = "setup-test-result " + (ok ? "result-ok" : "result-fail");
308
303
  }
309
304
 
310
- // Start the onboard skill in a dedicated session.
311
- // Pattern: identical to Tasks.createInSession() — create session → boot UI
312
- // → select session (triggers WS subscribe) → send /onboard slash command.
313
- async function _startSoulSession() {
314
- const btn = $("onboard-btn-start-soul");
315
- btn.disabled = true;
316
- btn.textContent = I18n.t("settings.personalize.btn.starting");
305
+ // ── /onboard session launcher ───────────────────────────────────────────────
317
306
 
307
+ // Create a dedicated session and send the /onboard slash command.
308
+ // Called after key_setup succeeds AND on soul_setup phase (auto, no panel shown).
309
+ async function _launchOnboardSession() {
318
310
  try {
319
- // Ensure config is persisted, then create the onboard session
320
311
  await _complete();
321
312
  const res = await fetch("/api/sessions", {
322
313
  method: "POST",
323
314
  headers: { "Content-Type": "application/json" },
324
315
  body: JSON.stringify({ name: "✨ Onboard" })
325
316
  });
326
- const data = await res.json();
317
+ const data = await res.json();
327
318
  const session = data.session;
328
319
  if (!session) throw new Error("No session returned");
329
320
 
330
- // Register the session and set up navigation BEFORE connecting WS.
331
- // This ensures Router.current === "session" when the first session_list
332
- // event arrives, preventing restoreFromHash() from wrongly redirecting
333
- // to "welcome" (which would happen because there is no hash set during onboard).
334
321
  Sessions.add(session);
335
322
  Sessions.renderList();
336
323
  Sessions.setPendingMessage(session.id, `/onboard lang:${_selectedLang}`);
337
324
  Sessions.select(session.id);
338
325
 
339
- // Boot WS + sidebar data. WS.connect() is async; the subscribe message
340
- // queued by Sessions.select() will be flushed once the socket opens.
341
326
  _bootUI();
342
- } catch (e) {
343
- btn.disabled = false;
344
- btn.textContent = I18n.t("onboard.soul.btn.start");
327
+ } catch (_) {
328
+ // Fallback: just boot normally if session creation fails
329
+ _bootUI();
345
330
  }
346
331
  }
347
332
 
348
- async function _skipSoul() {
349
- // Write a default SOUL.md so onboard isn't re-triggered, then boot normally
350
- await _complete();
351
- await _ensureSoulFile();
352
- _bootUI();
353
- }
354
-
355
333
  // POST /api/onboard/complete — persists config, creates default session if missing.
356
334
  async function _complete() {
357
335
  try {
@@ -360,24 +338,13 @@ const Onboard = (() => {
360
338
  } catch (_) { return null; }
361
339
  }
362
340
 
363
- // POST /api/onboard/skip-soul — writes a minimal default SOUL.md (lang-aware).
364
- async function _ensureSoulFile() {
365
- try {
366
- await fetch("/api/onboard/skip-soul", {
367
- method: "POST",
368
- headers: { "Content-Type": "application/json" },
369
- body: JSON.stringify({ lang: _selectedLang })
370
- });
371
- } catch (_) { /* ignore */ }
372
- }
373
-
374
341
  // Boot the normal UI (WS + sessions sidebar + tasks + skills).
375
- // WS.connect() is idempotent (guards against double-connect internally).
376
342
  function _bootUI() {
343
+ document.body.classList.remove("setup-mode");
377
344
  WS.connect();
378
345
  Tasks.load();
379
346
  Skills.load();
380
347
  }
381
348
 
382
- return { check, startSoulSession: _startSoulSession };
349
+ return { check, startSoulSession: _launchOnboardSession };
383
350
  })();
@@ -737,19 +737,23 @@ const Sessions = (() => {
737
737
  const cost = ev.cost || 0;
738
738
  const costStr = `$${cost.toFixed(5)}`;
739
739
 
740
+ // Always-visible: label, delta, cache indicator, cost
741
+ // Detail fields (Input/Output/Total) are hidden until hover
740
742
  el.innerHTML =
741
743
  `<span class="tu-label">[Tokens]</span>` +
742
744
  `<span class="tu-sep">|</span>` +
743
745
  `<span class="tu-delta ${deltaCls}">${escapeHtml(deltaStr)}</span>` +
746
+ (cacheUsed ? `<span class="tu-sep">|</span><span class="tu-cache">[*]</span>` : "") +
744
747
  `<span class="tu-sep">|</span>` +
745
- (cacheUsed ? `<span class="tu-cache">[*]</span><span class="tu-sep">|</span>` : "") +
746
- `<span class="tu-field">Input: <b>${escapeHtml(inputStr)}</b></span>` +
747
- `<span class="tu-sep">|</span>` +
748
- `<span class="tu-field">Output: <b>${(ev.completion_tokens || 0).toLocaleString()}</b></span>` +
749
- `<span class="tu-sep">|</span>` +
750
- `<span class="tu-field">Total: <b>${(ev.total_tokens || 0).toLocaleString()}</b></span>` +
751
- `<span class="tu-sep">|</span>` +
752
- `<span class="tu-cost">Cost: ${escapeHtml(costStr)}</span>`;
748
+ `<span class="tu-cost">Cost: ${escapeHtml(costStr)}</span>` +
749
+ `<span class="tu-detail">` +
750
+ `<span class="tu-sep">|</span>` +
751
+ `<span class="tu-field">Input: <b>${escapeHtml(inputStr)}</b></span>` +
752
+ `<span class="tu-sep">|</span>` +
753
+ `<span class="tu-field">Output: <b>${(ev.completion_tokens || 0).toLocaleString()}</b></span>` +
754
+ `<span class="tu-sep">|</span>` +
755
+ `<span class="tu-field">Total: <b>${(ev.total_tokens || 0).toLocaleString()}</b></span>` +
756
+ `</span>`;
753
757
 
754
758
  messages.appendChild(el);
755
759
  if (!container) messages.scrollTop = messages.scrollHeight; // only auto-scroll for live events