openclacky 1.0.1 → 1.0.2

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.
@@ -78,6 +78,9 @@
78
78
  --shadow-md: 0 4px 12px rgba(15, 18, 28, 0.08), 0 2px 4px rgba(15, 18, 28, 0.04);
79
79
  --shadow-lg: 0 12px 28px rgba(15, 18, 28, 0.12), 0 4px 8px rgba(15, 18, 28, 0.06);
80
80
 
81
+ /* Layout — shared dimensions */
82
+ --footer-height: 56px;
83
+
81
84
  /* Transitions — unified timing */
82
85
  --transition-fast: 120ms ease;
83
86
  --transition-base: 160ms ease;
@@ -2079,7 +2082,6 @@ body {
2079
2082
  padding: 4px 14px;
2080
2083
  background: var(--color-bg-primary);
2081
2084
  border-top: 1px solid var(--color-border-secondary);
2082
- border-bottom: 1px solid var(--color-border-secondary);
2083
2085
  font-size: 11px;
2084
2086
  color: var(--color-text-secondary);
2085
2087
  font-family: var(--font-mono, monospace);
@@ -2437,6 +2439,9 @@ body {
2437
2439
  border-top: 1px solid var(--color-border-primary);
2438
2440
  background: var(--color-bg-secondary);
2439
2441
  flex-shrink: 0;
2442
+ min-height: var(--footer-height);
2443
+ display: flex;
2444
+ flex-direction: column;
2440
2445
  }
2441
2446
 
2442
2447
  /* Hide top border when skill autocomplete is visible */
@@ -2762,9 +2767,11 @@ body {
2762
2767
 
2763
2768
  /* ── Input bar ───────────────────────────────────────────────────────────── */
2764
2769
  #input-bar {
2765
- padding: 13.5px 16px;
2770
+ margin-top: auto;
2771
+ margin-bottom: auto;
2772
+ padding: 0 16px;
2766
2773
  display: flex;
2767
- gap: 8px;
2774
+ gap: 6px;
2768
2775
  align-items: center;
2769
2776
  background: var(--color-bg-secondary);
2770
2777
  }
@@ -2775,7 +2782,9 @@ body {
2775
2782
  background: transparent;
2776
2783
  color: var(--color-text-secondary);
2777
2784
  cursor: pointer;
2778
- padding: 6px;
2785
+ width: 28px;
2786
+ height: 28px;
2787
+ padding: 0;
2779
2788
  line-height: 1;
2780
2789
  flex-shrink: 0;
2781
2790
  display: flex;
@@ -2789,17 +2798,21 @@ body {
2789
2798
  background: transparent;
2790
2799
  color: var(--color-text-secondary);
2791
2800
  cursor: pointer;
2792
- padding: 4px 7px;
2801
+ width: 28px;
2802
+ height: 28px;
2803
+ padding: 0;
2793
2804
  line-height: 1;
2794
2805
  flex-shrink: 0;
2795
2806
  display: flex;
2796
2807
  align-items: center;
2797
2808
  justify-content: center;
2798
2809
  border-radius: 6px;
2799
- font-size: 15px;
2800
2810
  font-weight: 600;
2801
2811
  font-family: ui-monospace, SFMono-Regular, "SF Mono", Menlo, monospace;
2802
2812
  }
2813
+ #btn-slash span {
2814
+ font-size: 18px;
2815
+ }
2803
2816
  #btn-slash:hover { color: var(--color-accent-primary); background: var(--color-border-secondary); }
2804
2817
  #btn-slash.active { color: var(--color-accent-primary); }
2805
2818
  #user-input {
@@ -2822,24 +2835,32 @@ body {
2822
2835
  #btn-send, #btn-interrupt {
2823
2836
  border: none;
2824
2837
  border-radius: 6px;
2825
- padding: 8px 16px;
2838
+ padding: 0 16px;
2839
+ height: 32px;
2826
2840
  font-size: 13px;
2827
2841
  cursor: pointer;
2828
2842
  white-space: nowrap;
2829
2843
  flex-shrink: 0;
2844
+ display: flex;
2845
+ align-items: center;
2846
+ justify-content: center;
2830
2847
  }
2831
2848
  #btn-send { background: var(--color-button-primary); color: #fff; }
2832
2849
  #btn-send:hover { background: var(--color-button-primary-hover); }
2833
2850
  [data-theme="dark"] #btn-send { background: #2563eb; }
2834
2851
  [data-theme="dark"] #btn-send:hover { background: #1d4ed8; }
2835
2852
  #btn-send:disabled { background: var(--color-border-primary); color: var(--color-text-secondary); cursor: not-allowed; }
2836
- #btn-interrupt { background: var(--color-error); color: #fff; font-size: 16px; line-height: 1; padding: 8px 16px; }
2853
+ #btn-interrupt { background: var(--color-error); color: #fff; }
2854
+ #btn-interrupt::after { content: ''; display: block; width: 10px; height: 10px; background: #fff; border-radius: 2px; }
2837
2855
  #btn-interrupt:hover{ background: var(--color-error); opacity: 0.85; }
2838
2856
 
2839
2857
  /* ── Sidebar footer ──────────────────────────────────────────────────────── */
2840
2858
  #sidebar-footer {
2841
2859
  border-top: 1px solid var(--color-border-primary);
2842
- padding: 14px 8px;
2860
+ min-height: var(--footer-height);
2861
+ display: flex;
2862
+ align-items: center;
2863
+ padding: 0 8px;
2843
2864
  flex-shrink: 0;
2844
2865
  }
2845
2866
  .sidebar-nav-btn {
@@ -3263,6 +3284,74 @@ body {
3263
3284
  font-style: italic;
3264
3285
  }
3265
3286
 
3287
+ /* Base URL combobox — mirrors model-name-combobox shape; differs only in
3288
+ showing a two-line option (label + URL) so users can distinguish
3289
+ regional/billing-plan variants under one provider. */
3290
+ .base-url-combobox {
3291
+ position: relative;
3292
+ display: flex;
3293
+ gap: 0;
3294
+ }
3295
+ .base-url-combobox .base-url-input {
3296
+ flex: 1;
3297
+ border-top-right-radius: 0;
3298
+ border-bottom-right-radius: 0;
3299
+ border-right: none;
3300
+ }
3301
+ .base-url-combobox .base-url-dropdown-btn {
3302
+ background: var(--color-bg-primary);
3303
+ border: 1px solid var(--color-border-primary);
3304
+ border-top-right-radius: 6px;
3305
+ border-bottom-right-radius: 6px;
3306
+ color: var(--color-text-secondary);
3307
+ padding: 0 8px;
3308
+ cursor: pointer;
3309
+ flex-shrink: 0;
3310
+ display: flex;
3311
+ align-items: center;
3312
+ justify-content: center;
3313
+ transition: all 0.15s ease;
3314
+ }
3315
+ .base-url-combobox .base-url-dropdown-btn:hover {
3316
+ background: var(--color-bg-hover);
3317
+ color: var(--color-text-primary);
3318
+ }
3319
+ .base-url-dropdown {
3320
+ position: absolute;
3321
+ top: calc(100% + 4px);
3322
+ left: 0;
3323
+ right: 0;
3324
+ background: var(--color-bg-secondary);
3325
+ border: 1px solid var(--color-border-primary);
3326
+ border-radius: 8px;
3327
+ box-shadow: 0 4px 12px rgba(0, 0, 0, 0.3);
3328
+ max-height: 280px;
3329
+ overflow-y: auto;
3330
+ z-index: 1000;
3331
+ }
3332
+ .base-url-dropdown-option {
3333
+ padding: 8px 12px;
3334
+ cursor: pointer;
3335
+ transition: background-color 0.15s ease;
3336
+ }
3337
+ .base-url-dropdown-option:hover {
3338
+ background: var(--color-bg-hover);
3339
+ }
3340
+ .base-url-dropdown-label {
3341
+ font-size: 13px;
3342
+ color: var(--color-text-primary);
3343
+ font-weight: 500;
3344
+ }
3345
+ .base-url-dropdown-option:hover .base-url-dropdown-label {
3346
+ color: var(--color-accent-primary);
3347
+ }
3348
+ .base-url-dropdown-url {
3349
+ font-size: 11px;
3350
+ color: var(--color-text-secondary);
3351
+ font-family: 'SF Mono', 'Monaco', 'Inconsolata', 'Fira Code', 'Courier New', monospace;
3352
+ margin-top: 2px;
3353
+ }
3354
+
3266
3355
  /* Model card footer */
3267
3356
  .model-card-footer {
3268
3357
  display: flex;
@@ -5748,6 +5837,7 @@ body.setup-mode[data-theme="dark"] {
5748
5837
  border-radius: 6px;
5749
5838
  overflow: hidden;
5750
5839
  transition: background .15s;
5840
+ width: 100%;
5751
5841
  }
5752
5842
  .sidebar-nav-row:hover {
5753
5843
  background: var(--color-bg-hover);
@@ -386,6 +386,13 @@ const I18n = (() => {
386
386
  "settings.models.placeholder.baseurl": "https://api.anthropic.com",
387
387
  "settings.models.placeholder.apikey": "sk-…",
388
388
  "settings.models.custom": "Custom",
389
+ "settings.models.baseurl.noVariants": "No preset endpoints available",
390
+ "settings.models.baseurl.variant.mainland_cn": "Mainland China",
391
+ "settings.models.baseurl.variant.international": "International",
392
+ "settings.models.baseurl.variant.mainland_cn_payg": "Mainland · Pay-as-you-go",
393
+ "settings.models.baseurl.variant.mainland_cn_coding": "Mainland · Coding Plan",
394
+ "settings.models.baseurl.variant.international_payg": "International · Pay-as-you-go",
395
+ "settings.models.baseurl.variant.international_coding": "International · Coding Plan",
389
396
  "settings.models.btn.save": "Save",
390
397
  "settings.models.btn.saving": "Saving…",
391
398
  "settings.models.btn.saved": "Saved ✓",
@@ -876,6 +883,13 @@ const I18n = (() => {
876
883
  "settings.models.placeholder.baseurl": "https://api.anthropic.com",
877
884
  "settings.models.placeholder.apikey": "sk-…",
878
885
  "settings.models.custom": "自定义",
886
+ "settings.models.baseurl.noVariants": "暂无预设端点",
887
+ "settings.models.baseurl.variant.mainland_cn": "中国大陆",
888
+ "settings.models.baseurl.variant.international": "海外",
889
+ "settings.models.baseurl.variant.mainland_cn_payg": "大陆 · 按量付费",
890
+ "settings.models.baseurl.variant.mainland_cn_coding": "大陆 · Coding Plan",
891
+ "settings.models.baseurl.variant.international_payg": "海外 · 按量付费",
892
+ "settings.models.baseurl.variant.international_coding": "海外 · Coding Plan",
879
893
  "settings.models.btn.save": "保存",
880
894
  "settings.models.btn.saving": "保存中…",
881
895
  "settings.models.btn.saved": "已保存 ✓",
@@ -360,7 +360,7 @@
360
360
  data-i18n-placeholder="chat.input.placeholder"
361
361
  placeholder="Message… (Enter to send, Shift+Enter for newline)"></textarea>
362
362
  <button id="btn-send" data-i18n="chat.btn.send">Send</button>
363
- <button id="btn-interrupt" style="display:none" title="Stop">■</button>
363
+ <button id="btn-interrupt" style="display:none" title="Stop"></button>
364
364
  </div>
365
365
  </div>
366
366
  </div>
@@ -858,7 +858,13 @@
858
858
 
859
859
  <div class="setup-field">
860
860
  <label class="setup-label" data-i18n="onboard.key.baseurl">Base URL</label>
861
- <input id="setup-base-url" type="text" class="setup-input" data-i18n-placeholder="settings.models.placeholder.baseurl" placeholder="https://api.anthropic.com">
861
+ <div class="base-url-combobox">
862
+ <input id="setup-base-url" type="text" class="setup-input base-url-input" data-i18n-placeholder="settings.models.placeholder.baseurl" placeholder="https://api.anthropic.com">
863
+ <button type="button" id="setup-base-url-dropdown-btn" class="base-url-dropdown-btn" aria-label="Select preset endpoint">
864
+ <svg width="14" height="14" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 9l-7 7-7-7"/></svg>
865
+ </button>
866
+ <div id="setup-base-url-dropdown" class="base-url-dropdown" style="display:none"></div>
867
+ </div>
862
868
  </div>
863
869
 
864
870
  <div class="setup-field">
@@ -171,6 +171,60 @@ const Onboard = (() => {
171
171
  });
172
172
  }
173
173
 
174
+ // ── Populate Base URL dropdown options from preset.endpoint_variants ──────
175
+ // Mirrors settings.js _renderBaseUrlDropdown but for the single-shot
176
+ // onboarding combobox. Called whenever the provider changes so the dropdown
177
+ // reflects the currently selected preset (empty for Custom / presets with
178
+ // no variants — button stays inert in that case).
179
+ function _updateSetupBaseUrlDropdown(preset) {
180
+ const dd = $("setup-base-url-dropdown");
181
+ if (!dd) return;
182
+ dd.innerHTML = "";
183
+
184
+ const variants = preset && Array.isArray(preset.endpoint_variants)
185
+ ? preset.endpoint_variants
186
+ : [];
187
+
188
+ if (variants.length === 0) {
189
+ // Leave dd empty; the dropdown-btn still toggles but shows a no-variant
190
+ // hint to signal "this provider has only one endpoint".
191
+ const empty = document.createElement("div");
192
+ empty.className = "model-dropdown-empty";
193
+ empty.textContent = I18n.t("settings.models.baseurl.noVariants");
194
+ dd.appendChild(empty);
195
+ return;
196
+ }
197
+
198
+ variants.forEach(v => {
199
+ // Prefer i18n key (localised per UI language); fall back to literal
200
+ // `label` (shipped English copy) then base_url for safety.
201
+ const translated = v.label_key ? I18n.t(v.label_key) : null;
202
+ const labelText = (translated && translated !== v.label_key) ? translated : (v.label || v.base_url);
203
+
204
+ const opt = document.createElement("div");
205
+ opt.className = "model-dropdown-option base-url-dropdown-option";
206
+ opt.dataset.value = v.base_url;
207
+
208
+ const lbl = document.createElement("div");
209
+ lbl.className = "base-url-dropdown-label";
210
+ lbl.textContent = labelText;
211
+
212
+ const url = document.createElement("div");
213
+ url.className = "base-url-dropdown-url";
214
+ url.textContent = v.base_url;
215
+
216
+ opt.appendChild(lbl);
217
+ opt.appendChild(url);
218
+
219
+ opt.addEventListener("click", (e) => {
220
+ e.stopPropagation();
221
+ $("setup-base-url").value = v.base_url;
222
+ dd.style.display = "none";
223
+ });
224
+ dd.appendChild(opt);
225
+ });
226
+ }
227
+
174
228
  function _bindCustomDropdown() {
175
229
  if (_dropdownBound) return; // listeners already attached
176
230
  _dropdownBound = true;
@@ -207,6 +261,7 @@ const Onboard = (() => {
207
261
  $("setup-model").value = "";
208
262
  $("setup-base-url").value = "";
209
263
  _updateSetupModelDropdown([]);
264
+ _updateSetupBaseUrlDropdown(null);
210
265
  if (getApiKeyLink) getApiKeyLink.style.display = "none";
211
266
  } else if (value) {
212
267
  const preset = _providers.find(p => p.id === value);
@@ -214,6 +269,7 @@ const Onboard = (() => {
214
269
  $("setup-model").value = preset.default_model || "";
215
270
  $("setup-base-url").value = preset.base_url || "";
216
271
  _updateSetupModelDropdown(preset.models || []);
272
+ _updateSetupBaseUrlDropdown(preset);
217
273
  // Show "how to get" link if provider has a website_url
218
274
  if (getApiKeyLink && preset.website_url) {
219
275
  getApiKeyLink.href = preset.website_url;
@@ -262,11 +318,30 @@ const Onboard = (() => {
262
318
  modelDropdownBtn.addEventListener("click", (e) => {
263
319
  e.stopPropagation();
264
320
  const isOpen = modelDropdown.style.display === "block";
321
+ // Close sibling dropdown to avoid overlap.
322
+ $("setup-base-url-dropdown").style.display = "none";
265
323
  modelDropdown.style.display = isOpen ? "none" : "block";
266
324
  });
267
325
 
268
- document.addEventListener("click", () => {
326
+ // ── Base URL combobox dropdown ────────────────────────────────────────────
327
+ // Shows preset.endpoint_variants (e.g. GLM mainland vs Z.ai international).
328
+ // Rendering is driven by the currently selected provider — see the
329
+ // provider custom-select handler above, which calls
330
+ // _updateSetupBaseUrlDropdown(preset) on every switch.
331
+ const baseUrlDropdownBtn = $("setup-base-url-dropdown-btn");
332
+ const baseUrlDropdown = $("setup-base-url-dropdown");
333
+
334
+ baseUrlDropdownBtn.addEventListener("click", (e) => {
335
+ e.stopPropagation();
336
+ const isOpen = baseUrlDropdown.style.display === "block";
337
+ // Close sibling dropdown (model combobox) to avoid overlap.
269
338
  modelDropdown.style.display = "none";
339
+ baseUrlDropdown.style.display = isOpen ? "none" : "block";
340
+ });
341
+
342
+ document.addEventListener("click", () => {
343
+ modelDropdown.style.display = "none";
344
+ baseUrlDropdown.style.display = "none";
270
345
  });
271
346
 
272
347
  $("setup-btn-test").addEventListener("click", _testAndSave);
@@ -388,6 +463,7 @@ const Onboard = (() => {
388
463
  // Boot the normal UI (WS + sessions sidebar + tasks + skills).
389
464
  function _bootUI() {
390
465
  document.body.classList.remove("setup-mode");
466
+ SkillAC.init();
391
467
  WS.connect();
392
468
  Tasks.load();
393
469
  Skills.load();
@@ -2484,14 +2484,14 @@ const Sessions = (() => {
2484
2484
 
2485
2485
  card.innerHTML = cardHtml;
2486
2486
 
2487
- // Click → disable card + submit immediately via sendMessage()
2487
+ // Click → disable card + submit immediately via _sendMessage()
2488
2488
  card.querySelectorAll(".feedback-option-btn").forEach(btn => {
2489
2489
  btn.onclick = () => {
2490
2490
  card.querySelectorAll(".feedback-option-btn").forEach(b => b.disabled = true);
2491
2491
  card.classList.add("feedback-card--submitted");
2492
2492
  const input = $("user-input");
2493
2493
  if (input) input.value = btn.textContent.trim();
2494
- sendMessage();
2494
+ _sendMessage();
2495
2495
  };
2496
2496
  });
2497
2497
 
@@ -110,8 +110,17 @@ const Settings = (() => {
110
110
  </label>
111
111
  <label class="model-field">
112
112
  <span class="field-label">${I18n.t("settings.models.field.baseurl")}</span>
113
- <input type="text" class="field-input" data-key="base_url" data-index="${index}"
114
- placeholder="${I18n.t("settings.models.placeholder.baseurl")}" value="${_esc(model.base_url)}">
113
+ <div class="base-url-combobox" data-index="${index}">
114
+ <input type="text" class="field-input base-url-input" data-key="base_url" data-index="${index}"
115
+ placeholder="${I18n.t("settings.models.placeholder.baseurl")}" value="${_esc(model.base_url)}"
116
+ autocomplete="off">
117
+ <button class="base-url-dropdown-btn" type="button" title="Select preset endpoint">
118
+ <svg width="12" height="12" fill="none" stroke="currentColor" viewBox="0 0 24 24">
119
+ <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 9l-7 7-7-7"></path>
120
+ </svg>
121
+ </button>
122
+ <div class="base-url-dropdown" style="display:none"></div>
123
+ </div>
115
124
  </label>
116
125
  <label class="model-field">
117
126
  <span class="field-label">
@@ -267,10 +276,21 @@ const Settings = (() => {
267
276
  // Build model list from current base_url's provider
268
277
  const _updateModelDropdown = () => {
269
278
  const baseUrlInput = card.querySelector(`[data-key="base_url"]`);
270
- const baseUrl = baseUrlInput ? baseUrlInput.value.trim() : "";
271
-
272
- // Find provider by base_url
273
- const provider = _providers.find(p => p.base_url === baseUrl);
279
+ const baseUrl = baseUrlInput ? baseUrlInput.value.trim().replace(/\/+$/, "") : "";
280
+
281
+ // Find provider by matching base_url against BOTH the canonical
282
+ // preset.base_url AND every endpoint_variants[].base_url otherwise
283
+ // picking e.g. GLM's Coding-Plan variant would wipe the model list
284
+ // because only the canonical URL would match.
285
+ const provider = _providers.find(p => {
286
+ const candidates = [p.base_url].concat(
287
+ Array.isArray(p.endpoint_variants) ? p.endpoint_variants.map(v => v.base_url) : []
288
+ ).filter(Boolean);
289
+ return candidates.some(c => {
290
+ const norm = String(c).replace(/\/+$/, "");
291
+ return baseUrl === norm || baseUrl.startsWith(norm + "/");
292
+ });
293
+ });
274
294
  const models = provider?.models || [];
275
295
 
276
296
  if (models.length === 0) {
@@ -322,6 +342,107 @@ const Settings = (() => {
322
342
  _updateModelDropdown();
323
343
  });
324
344
  }
345
+
346
+ // Base URL combobox: dropdown button + endpoint_variants list.
347
+ //
348
+ // Rationale: some providers (GLM on Zhipu/Z.ai, MiniMax on .com/.io) run
349
+ // multiple regional / billing-plan endpoints under a single identity.
350
+ // Listing every variant lets the user pick the right one instead of
351
+ // hand-editing the URL, while still allowing free-form input for
352
+ // unknown / self-hosted proxies. Mirrors the model-name combobox.
353
+ //
354
+ // Data source: the endpoint_variants[] field on each provider preset,
355
+ // resolved by matching the currently-entered base_url against every
356
+ // preset's {base_url + endpoint_variants[].base_url}. When no variants
357
+ // are declared for the matched provider (single-endpoint providers like
358
+ // Anthropic, OpenClacky), the dropdown shows an "empty" hint.
359
+ const baseUrlCombobox = card.querySelector(".base-url-combobox");
360
+ const baseUrlDropdownBtn = baseUrlCombobox.querySelector(".base-url-dropdown-btn");
361
+ const baseUrlDropdown = baseUrlCombobox.querySelector(".base-url-dropdown");
362
+
363
+ // Resolve the "active" provider preset from the current form values:
364
+ // 1. If the Quick Setup select points at a known provider, use that
365
+ // (even before the base_url input is typed into).
366
+ // 2. Otherwise fall back to matching the current base_url against all
367
+ // preset base_url + endpoint_variants. Unknown URLs → null.
368
+ const _currentProvider = () => {
369
+ const selected = card.querySelector(".custom-select-option.selected");
370
+ const selectedId = selected?.dataset.value;
371
+ if (selectedId && selectedId !== "custom") {
372
+ const byId = _providers.find(p => p.id === selectedId);
373
+ if (byId) return byId;
374
+ }
375
+ const url = (baseUrlInput?.value || "").trim().replace(/\/+$/, "");
376
+ if (!url) return null;
377
+ return _providers.find(p => {
378
+ const candidates = [p.base_url].concat(
379
+ Array.isArray(p.endpoint_variants) ? p.endpoint_variants.map(v => v.base_url) : []
380
+ ).filter(Boolean);
381
+ return candidates.some(c => {
382
+ const norm = String(c).replace(/\/+$/, "");
383
+ return url === norm || url.startsWith(norm + "/");
384
+ });
385
+ }) || null;
386
+ };
387
+
388
+ const _renderBaseUrlDropdown = () => {
389
+ const provider = _currentProvider();
390
+ const variants = provider && Array.isArray(provider.endpoint_variants)
391
+ ? provider.endpoint_variants
392
+ : [];
393
+
394
+ if (variants.length === 0) {
395
+ baseUrlDropdown.innerHTML =
396
+ `<div class="model-dropdown-empty">${I18n.t("settings.models.baseurl.noVariants")}</div>`;
397
+ return;
398
+ }
399
+
400
+ baseUrlDropdown.innerHTML = variants.map(v => {
401
+ // Prefer i18n key (localised per UI language); fall back to literal
402
+ // `label` (shipped English copy) and finally to base_url for safety.
403
+ // Pattern: _translateVariant(v) -> "大陆 · 按量付费" in zh, "Mainland · Pay-as-you-go" in en.
404
+ const translated = v.label_key ? I18n.t(v.label_key) : null;
405
+ // I18n.t typically returns the key itself when missing — treat that as a miss.
406
+ const labelText = (translated && translated !== v.label_key) ? translated : (v.label || v.base_url);
407
+ const label = _esc(labelText);
408
+ const url = _esc(v.base_url);
409
+ return `
410
+ <div class="model-dropdown-option base-url-dropdown-option" data-value="${url}">
411
+ <div class="base-url-dropdown-label">${label}</div>
412
+ <div class="base-url-dropdown-url">${url}</div>
413
+ </div>`;
414
+ }).join("");
415
+
416
+ baseUrlDropdown.querySelectorAll(".base-url-dropdown-option").forEach(opt => {
417
+ opt.addEventListener("click", (e) => {
418
+ e.stopPropagation();
419
+ if (baseUrlInput) {
420
+ baseUrlInput.value = opt.dataset.value;
421
+ // Trigger model-list refresh since base_url just changed.
422
+ _updateModelDropdown();
423
+ }
424
+ baseUrlDropdown.style.display = "none";
425
+ });
426
+ });
427
+ };
428
+
429
+ baseUrlDropdownBtn.addEventListener("click", (e) => {
430
+ e.stopPropagation();
431
+ const isOpen = baseUrlDropdown.style.display === "block";
432
+ // Close sibling dropdowns (model-name + other base-url) to avoid overlap.
433
+ document.querySelectorAll(".model-name-dropdown, .base-url-dropdown").forEach(d => {
434
+ d.style.display = "none";
435
+ });
436
+ if (!isOpen) {
437
+ _renderBaseUrlDropdown();
438
+ baseUrlDropdown.style.display = "block";
439
+ }
440
+ });
441
+
442
+ // Close dropdown when clicking outside
443
+ document.addEventListener("click", () => {
444
+ baseUrlDropdown.style.display = "none";
445
+ });
325
446
  }
326
447
 
327
448
  // ── Read form values from a card ────────────────────────────────────────────
@@ -782,6 +782,7 @@ const Skills = (() => {
782
782
  // I18n, global $ helper.
783
783
  // ─────────────────────────────────────────────────────────────────────────
784
784
  const SkillAC = (() => {
785
+ let _initialized = false;
785
786
  let _visible = false;
786
787
  let _activeIndex = -1;
787
788
  let _items = []; // filtered [{ name, description, encrypted, source }]
@@ -1175,6 +1176,9 @@ const SkillAC = (() => {
1175
1176
 
1176
1177
  /** Initialize event listeners (call once on page load). */
1177
1178
  init() {
1179
+ if (_initialized) return;
1180
+ _initialized = true;
1181
+
1178
1182
  const chk = $("chk-ac-show-system-skills");
1179
1183
 
1180
1184
  if (chk) {
data/lib/clacky.rb CHANGED
@@ -133,6 +133,11 @@ module Clacky
133
133
  class AgentError < StandardError; end
134
134
  class BadRequestError < AgentError; end # 400 errors — our request was malformed, history should be rolled back
135
135
  class RetryableError < StandardError; end # Transient errors that should be retried (5xx, HTML response, rate limit)
136
+ # Upstream (model/router like OpenRouter/Bedrock) returned finish_reason="stop" together with
137
+ # one or more tool_calls whose `arguments` JSON was truncated (empty, "{}" placeholder, or
138
+ # otherwise unparseable). Subclass of RetryableError so it flows through the existing
139
+ # retry/fallback pipeline in LlmCaller#call_llm.
140
+ class UpstreamTruncatedError < RetryableError; end
136
141
  class ToolCallError < AgentError; end # Raised when tool call fails due to invalid parameters
137
142
  class BrowserNotReachableError < AgentError; end # Chrome/Edge not running or remote debugging disabled
138
143
  # BrowserManager singleton: Clacky::BrowserManager.instance
metadata CHANGED
@@ -1,14 +1,14 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: openclacky
3
3
  version: !ruby/object:Gem::Version
4
- version: 1.0.1
4
+ version: 1.0.2
5
5
  platform: ruby
6
6
  authors:
7
7
  - windy
8
8
  autorequire:
9
9
  bindir: bin
10
10
  cert_chain: []
11
- date: 2026-05-06 00:00:00.000000000 Z
11
+ date: 2026-05-07 00:00:00.000000000 Z
12
12
  dependencies:
13
13
  - !ruby/object:Gem::Dependency
14
14
  name: faraday
@@ -347,6 +347,7 @@ files:
347
347
  - lib/clacky/default_skills/new/scripts/create_rails_project.sh
348
348
  - lib/clacky/default_skills/onboard/SKILL.md
349
349
  - lib/clacky/default_skills/onboard/scripts/import_external_skills.rb
350
+ - lib/clacky/default_skills/onboard/scripts/install_builtin_skills.rb
350
351
  - lib/clacky/default_skills/personal-website/SKILL.md
351
352
  - lib/clacky/default_skills/personal-website/publish.rb
352
353
  - lib/clacky/default_skills/product-help/SKILL.md