openclacky 1.1.2 → 1.1.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.
Files changed (58) hide show
  1. checksums.yaml +4 -4
  2. data/.clacky/skills/gem-release/SKILL.md +27 -31
  3. data/CHANGELOG.md +30 -0
  4. data/Dockerfile +28 -0
  5. data/README.md +4 -0
  6. data/README_CN.md +198 -0
  7. data/docs/engineering-article.md +343 -0
  8. data/lib/clacky/agent/llm_caller.rb +2 -5
  9. data/lib/clacky/agent/session_serializer.rb +4 -0
  10. data/lib/clacky/agent.rb +22 -1
  11. data/lib/clacky/brand_config.rb +87 -5
  12. data/lib/clacky/cli.rb +1 -1
  13. data/lib/clacky/client.rb +15 -11
  14. data/lib/clacky/message_format/anthropic.rb +30 -2
  15. data/lib/clacky/message_format/bedrock.rb +13 -1
  16. data/lib/clacky/message_format/open_ai.rb +5 -1
  17. data/lib/clacky/providers.rb +34 -0
  18. data/lib/clacky/server/channel/adapters/dingtalk/adapter.rb +142 -5
  19. data/lib/clacky/server/channel/adapters/dingtalk/api_client.rb +309 -0
  20. data/lib/clacky/server/http_server.rb +130 -15
  21. data/lib/clacky/server/session_registry.rb +9 -6
  22. data/lib/clacky/ui2/ui_controller.rb +14 -0
  23. data/lib/clacky/ui_interface.rb +14 -0
  24. data/lib/clacky/utils/model_pricing.rb +96 -25
  25. data/lib/clacky/version.rb +1 -1
  26. data/lib/clacky/web/app.css +1286 -1116
  27. data/lib/clacky/web/brand.js +20 -5
  28. data/lib/clacky/web/i18n.js +42 -0
  29. data/lib/clacky/web/index.html +26 -7
  30. data/lib/clacky/web/onboard.js +6 -0
  31. data/lib/clacky/web/sessions.js +194 -11
  32. data/lib/clacky/web/settings.js +51 -10
  33. data/lib/clacky/web/skills.js +53 -31
  34. data/lib/clacky/web/vendor/hljs/highlight.min.js +1244 -0
  35. data/lib/clacky/web/vendor/hljs/hljs-theme.css +95 -0
  36. data/scripts/build/lib/apt.sh +30 -10
  37. data/scripts/build/lib/network.sh +3 -2
  38. data/scripts/install.sh +30 -9
  39. data/scripts/install_browser.sh +2 -1
  40. data/scripts/install_full.sh +2 -1
  41. data/scripts/install_rails_deps.sh +30 -9
  42. data/scripts/install_system_deps.sh +30 -9
  43. metadata +7 -17
  44. data/docs/HOW-TO-USE-CN.md +0 -96
  45. data/docs/HOW-TO-USE.md +0 -94
  46. data/docs/browser-cdp-native-design.md +0 -195
  47. data/docs/c-end-user-positioning.md +0 -64
  48. data/docs/config.example.yml +0 -27
  49. data/docs/deploy-architecture.md +0 -619
  50. data/docs/deploy_subagent_design.md +0 -540
  51. data/docs/install-script-simplification.md +0 -89
  52. data/docs/memory-architecture.md +0 -343
  53. data/docs/openclacky_cloud_api_reference.md +0 -584
  54. data/docs/security-design.md +0 -109
  55. data/docs/session-management-redesign.md +0 -202
  56. data/docs/system-skill-authoring-guide.md +0 -47
  57. data/docs/why-developer.md +0 -371
  58. data/docs/why-openclacky.md +0 -266
@@ -39,7 +39,7 @@ const Brand = (() => {
39
39
  if (data.needs_activation) {
40
40
  // Show a top banner instead of a blocking full-screen panel.
41
41
  // Boot continues normally; user can activate at any time via the banner.
42
- _showActivationBanner(data.product_name);
42
+ _showActivationBanner(data.product_name, data.free_skills_count, data.paid_skills_count);
43
43
 
44
44
  // Apply logo/theme from whatever is already cached in brand.yml —
45
45
  // install.sh only writes product_name + package_name, but if a
@@ -77,7 +77,7 @@ const Brand = (() => {
77
77
  // Show a dismissible activation banner at the top of the page.
78
78
  // Clicking the banner creates a dedicated session and invokes the
79
79
  // Clicking the banner opens Settings and focuses the license key input directly.
80
- function _showActivationBanner(brandName) {
80
+ function _showActivationBanner(brandName, freeCount, paidCount) {
81
81
  const existing = document.getElementById("brand-activation-banner");
82
82
  if (existing) return;
83
83
 
@@ -87,9 +87,19 @@ const Brand = (() => {
87
87
 
88
88
  const span = document.createElement("span");
89
89
  const name = brandName || I18n.t("brand.banner.defaultName");
90
- span.textContent = I18n.t("brand.banner.prompt", { name });
91
- span.setAttribute("data-i18n", "brand.banner.prompt");
92
- if (brandName) span.setAttribute("data-i18n-vars", `name=${brandName}`);
90
+ const free = Number(freeCount) || 0;
91
+ const paid = Number(paidCount) || 0;
92
+
93
+ let i18nKey;
94
+ if (free > 0 && paid > 0) i18nKey = "brand.banner.freePromptBoth";
95
+ else if (free > 0 && paid === 0) i18nKey = "brand.banner.freePromptOnlyFree";
96
+ else if (free === 0 && paid > 0) i18nKey = "brand.banner.freePromptOnlyPaid";
97
+ else i18nKey = "brand.banner.prompt";
98
+
99
+ const vars = { name, free, paid, freePlural: free === 1 ? "" : "s", paidPlural: paid === 1 ? "" : "s" };
100
+ span.textContent = I18n.t(i18nKey, vars);
101
+ span.setAttribute("data-i18n", i18nKey);
102
+ span.setAttribute("data-i18n-vars", `name=${name};free=${free};paid=${paid};freePlural=${vars.freePlural};paidPlural=${vars.paidPlural}`);
93
103
 
94
104
  const link = document.createElement("button");
95
105
  link.className = "brand-activation-banner-link";
@@ -97,6 +107,11 @@ const Brand = (() => {
97
107
  link.setAttribute("data-i18n", "brand.banner.action");
98
108
  link.addEventListener("click", () => _goToLicenseInput());
99
109
 
110
+ // Hide the "Activate Now" button when there is nothing premium to unlock.
111
+ if (paid === 0 && free > 0) {
112
+ link.style.display = "none";
113
+ }
114
+
100
115
  const closeBtn = document.createElement("button");
101
116
  closeBtn.className = "brand-activation-banner-close";
102
117
  closeBtn.innerHTML = "✕";
@@ -78,6 +78,14 @@ const I18n = (() => {
78
78
  "sib.dir.changePrompt": "Change working directory:",
79
79
  "sib.model.tooltip": "Click to switch model",
80
80
  "sib.signal.tooltip": "Recent LLM latency",
81
+ "sib.reasoning.tooltip": "Click to change reasoning effort",
82
+ "sib.reasoning.label": "Reasoning",
83
+ "sib.reasoning.heading": "Reasoning effort",
84
+ "sib.reasoning.hint": "Higher effort = deeper thinking, slower response.",
85
+ "sib.reasoning.off": "Default",
86
+ "sib.reasoning.low": "Low",
87
+ "sib.reasoning.medium": "Medium",
88
+ "sib.reasoning.high": "High",
81
89
  "sessions.thinking": "Thinking…",
82
90
  "sessions.default_name": "Session {{n}}",
83
91
  "sessions.badge.cron": "Auto",
@@ -330,6 +338,10 @@ const I18n = (() => {
330
338
  "skills.brand.btn.use": "Use",
331
339
  "skills.brand.private": "Private",
332
340
  "skills.brand.privateTip": "Private — licensed to your organization",
341
+ "skills.brand.free": "Free",
342
+ "skills.brand.freeTip": "Free — no serial number required",
343
+ "skills.brand.lockedTip": "Activate your license to unlock this premium skill.",
344
+ "skills.brand.paidHint": "{{n}} more premium skill(s) available — activate your license to unlock.",
333
345
  "skills.brand.installFailed": "Install failed: ",
334
346
  "skills.brand.unknownError": "unknown error",
335
347
  "skills.brand.networkError": "Network error during install.",
@@ -481,6 +493,11 @@ const I18n = (() => {
481
493
  "settings.lang.en": "English",
482
494
  "settings.lang.zh": "中文",
483
495
 
496
+ "settings.fontSize.title": "Font Size",
497
+ "settings.fontSize.small": "Small",
498
+ "settings.fontSize.medium": "Medium",
499
+ "settings.fontSize.large": "Large",
500
+
484
501
  // ── Onboard ──
485
502
  "onboard.title": "Welcome to {{brand}}",
486
503
  "onboard.subtitle": "Let's get you set up in a minute.",
@@ -526,6 +543,10 @@ const I18n = (() => {
526
543
  "brand.banner.defaultName": "Your license",
527
544
  "brand.banner.action": "Activate Now",
528
545
  "brand.banner.sessionName": "License Activation",
546
+ "brand.banner.freePromptZero": "Welcome to {{name}} — {{paid}} premium skill{{paidPlural}} can be unlocked with a serial number.",
547
+ "brand.banner.freePromptOnlyFree": "Welcome to {{name}} — {{free}} free skill{{freePlural}} ready to use.",
548
+ "brand.banner.freePromptOnlyPaid": "Welcome to {{name}} — {{paid}} premium skill{{paidPlural}} can be unlocked with a serial number.",
549
+ "brand.banner.freePromptBoth": "Welcome to {{name}} — {{free}} free skill{{freePlural}} ready to use, plus {{paid}} premium skill{{paidPlural}} unlockable with a serial number.",
529
550
 
530
551
  "header.owner.tooltip": "Creator — click to open Creator Hub",
531
552
 
@@ -604,6 +625,14 @@ const I18n = (() => {
604
625
  "sib.dir.changePrompt": "切换工作目录:",
605
626
  "sib.model.tooltip": "点击切换模型",
606
627
  "sib.signal.tooltip": "最近一次 LLM 响应延迟",
628
+ "sib.reasoning.tooltip": "点击调整思考等级",
629
+ "sib.reasoning.label": "思考",
630
+ "sib.reasoning.heading": "思考等级",
631
+ "sib.reasoning.hint": "等级越高,思考越深,响应越慢。",
632
+ "sib.reasoning.off": "默认",
633
+ "sib.reasoning.low": "低",
634
+ "sib.reasoning.medium": "中",
635
+ "sib.reasoning.high": "高",
607
636
  "sessions.thinking": "思考中…",
608
637
  "sessions.default_name": "对话 {{n}}",
609
638
  "sessions.badge.cron": "定时",
@@ -853,6 +882,10 @@ const I18n = (() => {
853
882
  "skills.brand.btn.use": "使用",
854
883
  "skills.brand.private": "私有",
855
884
  "skills.brand.privateTip": "私有 — 仅授权给您的组织",
885
+ "skills.brand.free": "免费",
886
+ "skills.brand.freeTip": "免费 — 无需序列号即可使用",
887
+ "skills.brand.lockedTip": "激活授权后可解锁该增值技能。",
888
+ "skills.brand.paidHint": "还有 {{n}} 个增值技能 — 激活序列号后即可解锁。",
856
889
  "skills.brand.installFailed": "安装失败:",
857
890
  "skills.brand.unknownError": "未知错误",
858
891
  "skills.brand.networkError": "安装时网络错误。",
@@ -1004,6 +1037,11 @@ const I18n = (() => {
1004
1037
  "settings.lang.en": "English",
1005
1038
  "settings.lang.zh": "中文",
1006
1039
 
1040
+ "settings.fontSize.title": "字体大小",
1041
+ "settings.fontSize.small": "小",
1042
+ "settings.fontSize.medium": "中",
1043
+ "settings.fontSize.large": "大",
1044
+
1007
1045
  // ── Onboard ──
1008
1046
  "onboard.title": "欢迎使用 {{brand}}",
1009
1047
  "onboard.subtitle": "一分钟完成配置,马上开始。",
@@ -1049,6 +1087,10 @@ const I18n = (() => {
1049
1087
  "brand.banner.defaultName": "您的授权",
1050
1088
  "brand.banner.action": "立即激活",
1051
1089
  "brand.banner.sessionName": "激活授权",
1090
+ "brand.banner.freePromptZero": "欢迎使用 {{name}} — 还有 {{paid}} 个增值技能,输入序列号后即可解锁。",
1091
+ "brand.banner.freePromptOnlyFree": "欢迎使用 {{name}} — 已自动安装 {{free}} 个免费技能,现可直接使用。",
1092
+ "brand.banner.freePromptOnlyPaid": "欢迎使用 {{name}} — 还有 {{paid}} 个增值技能,输入序列号后即可解锁。",
1093
+ "brand.banner.freePromptBoth": "欢迎使用 {{name}} — 已自动安装 {{free}} 个免费技能,还有 {{paid}} 个增值技能可输入序列号解锁。",
1052
1094
 
1053
1095
  "header.owner.tooltip": "创作者 — 点击进入创作者中心",
1054
1096
 
@@ -1,11 +1,12 @@
1
1
  <!DOCTYPE html>
2
- <html lang="en" id="html-root">
2
+ <html lang="en" id="html-root" data-font-size="medium">
3
3
  <head>
4
4
  <meta charset="UTF-8">
5
5
  <meta name="viewport" content="width=device-width, initial-scale=1.0">
6
6
  <title id="page-title">{{BRAND_NAME}}</title>
7
7
  <link rel="icon" type="image/svg+xml" href="/icon.svg">
8
8
  <link rel="stylesheet" href="/vendor/katex/katex.min.css">
9
+ <link rel="stylesheet" href="/vendor/hljs/hljs-theme.css">
9
10
  <link rel="stylesheet" href="/app.css">
10
11
  <script>
11
12
  // Inline theme init — must run before CSS renders to prevent flash of wrong theme.
@@ -308,6 +309,11 @@
308
309
  <div id="sib-model-dropdown" class="sib-model-dropdown" style="display:none"></div>
309
310
  </span>
310
311
  <span class="sib-sep sib-sep-after-model">│</span>
312
+ <span id="sib-reasoning-wrap">
313
+ <span id="sib-reasoning" class="sib-reasoning-clickable" data-i18n-title="sib.reasoning.tooltip" title="Click to change reasoning effort"></span>
314
+ <div id="sib-reasoning-dropdown" class="sib-reasoning-dropdown" style="display:none" role="menu"></div>
315
+ </span>
316
+ <span class="sib-sep sib-sep-after-reasoning">│</span>
311
317
  <!-- Latency signal: 4-bar signal + TTFT number. Hidden until the first LLM
312
318
  call completes (see updateInfoBar / Sessions.renderSignalBars). Click
313
319
  opens a mini benchmark panel (see Step 3/4 — not yet implemented). -->
@@ -380,7 +386,7 @@
380
386
  <polygon points="12 2 15.09 8.26 22 9.27 17 14.14 18.18 21.02 12 17.77 5.82 21.02 7 14.14 2 9.27 8.91 8.26 12 2"/>
381
387
  </svg>
382
388
  <span data-i18n="creator.promo.text">Publish your skills &amp; build your own brand on OpenClacky.</span>
383
- <a href="https://www.openclacky.com" target="_blank" data-i18n="creator.promo.link">Learn more →</a>
389
+ <a href="https://www.openclacky.com/creators" target="_blank" data-i18n="creator.promo.link">Learn more →</a>
384
390
  </div>
385
391
 
386
392
  <!-- Section 1: Cloud skills (published) -->
@@ -700,6 +706,18 @@
700
706
  </div>
701
707
  </section>
702
708
 
709
+ <!-- Font Size section -->
710
+ <section class="settings-section" id="font-size-section">
711
+ <div class="settings-section-title">
712
+ <span data-i18n="settings.fontSize.title">Font Size</span>
713
+ </div>
714
+ <div class="settings-lang-btns">
715
+ <button id="settings-btn-font-small" class="settings-lang-btn" data-font="small" data-i18n="settings.fontSize.small">小</button>
716
+ <button id="settings-btn-font-medium" class="settings-lang-btn" data-font="medium" data-i18n="settings.fontSize.medium">中</button>
717
+ <button id="settings-btn-font-large" class="settings-lang-btn" data-font="large" data-i18n="settings.fontSize.large">大</button>
718
+ </div>
719
+ </section>
720
+
703
721
  <!-- Brand & License section -->
704
722
  <section class="settings-section" id="brand-license-section">
705
723
  <div class="settings-section-title">
@@ -834,7 +852,7 @@
834
852
  <div class="setup-field">
835
853
  <label class="setup-label" data-i18n="onboard.key.provider">Provider</label>
836
854
  <div class="custom-select-wrapper" id="setup-provider-wrapper">
837
- <div class="custom-select-trigger">
855
+ <div class="custom-select-trigger" tabindex="0">
838
856
  <span class="custom-select-value placeholder" data-i18n="onboard.key.provider.placeholder">— Choose provider —</span>
839
857
  <svg class="custom-select-arrow" fill="none" stroke="currentColor" viewBox="0 0 24 24">
840
858
  <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 9l-7 7-7-7"></path>
@@ -872,7 +890,7 @@
872
890
  <div class="setup-field">
873
891
  <label class="setup-label">
874
892
  <span data-i18n="onboard.key.apikey">API Key</span>
875
- <a id="setup-get-apikey-link" href="#" target="_blank" rel="noopener" style="display:none;margin-left:8px;font-size:12px;color:var(--accent,#6366f1);text-decoration:none;opacity:0.85;" data-i18n="onboard.key.getApiKey">How to get →</a>
893
+ <a id="setup-get-apikey-link" href="#" target="_blank" rel="noopener" style="display:none;margin-left:0.5rem;font-size:0.75rem;color:var(--accent,#6366f1);text-decoration:none;opacity:0.85;" data-i18n="onboard.key.getApiKey">How to get →</a>
876
894
  </label>
877
895
  <div class="setup-input-row">
878
896
  <input id="setup-api-key" type="password" class="setup-input" data-i18n-placeholder="settings.models.placeholder.apikey" placeholder="sk-…">
@@ -885,9 +903,9 @@
885
903
  </div>
886
904
  </div>
887
905
 
888
- <div class="setup-docs-hint" style="font-size:12px;margin-top:2px;text-align:left;">
906
+ <div class="setup-docs-hint" style="font-size:0.75rem;margin-top:2px;text-align:left;">
889
907
  <span style="color:var(--muted,#6b7280);" data-i18n="onboard.key.docsGuide.question">New to AI keys?</span>
890
- <a id="setup-docs-link" href="https://www.openclacky.com/docs/ai-key-guide" target="_blank" rel="noopener" style="margin-left:4px;color:var(--accent,#6366f1);text-decoration:none;" data-i18n="onboard.key.docsGuide.cta">See the guide →</a>
908
+ <a id="setup-docs-link" href="https://www.openclacky.com/docs/ai-key-guide" target="_blank" rel="noopener" style="margin-left:0.25rem;color:var(--accent,#6366f1);text-decoration:none;" data-i18n="onboard.key.docsGuide.cta">See the guide →</a>
891
909
  </div>
892
910
 
893
911
  <div id="setup-test-result" class="setup-test-result" style="min-height:0;"></div>
@@ -965,7 +983,7 @@
965
983
  <!-- Prompt modal for text input -->
966
984
  <div id="prompt-modal-overlay" class="modal-overlay" style="display:none">
967
985
  <div class="modal-box sm">
968
- <div id="prompt-modal-message" style="font-size:14px;line-height:1.6;margin-bottom:12px"></div>
986
+ <div id="prompt-modal-message" style="font-size:0.875rem;line-height:1.6;margin-bottom:0.75rem"></div>
969
987
  <input type="text" id="prompt-modal-input" class="prompt-modal-input" autocomplete="off" spellcheck="false">
970
988
  <div class="modal-actions">
971
989
  <button id="prompt-modal-cancel" class="btn-secondary" data-i18n="modal.cancel">Cancel</button>
@@ -997,6 +1015,7 @@
997
1015
 
998
1016
 
999
1017
  <script src="/marked.min.js"></script>
1018
+ <script src="/vendor/hljs/highlight.min.js"></script>
1000
1019
  <script src="/vendor/katex/katex.min.js"></script>
1001
1020
  <script src="/vendor/katex/auto-render.min.js"></script>
1002
1021
  <script src="/i18n.js"></script>
@@ -86,6 +86,12 @@ const Onboard = (() => {
86
86
  _showSetupStep("key");
87
87
  await _loadProviders();
88
88
  _bindKeyStep();
89
+ // Nudge: focus the provider trigger so the user knows step 1 is "pick
90
+ // a provider". `tabindex="0"` on the trigger makes it focusable; the
91
+ // .open class is NOT toggled, so the dropdown stays closed — we just
92
+ // get the accent-color border via `.custom-select-trigger:focus`.
93
+ const trigger = $("setup-provider-wrapper")?.querySelector(".custom-select-trigger");
94
+ if (trigger) trigger.focus({ preventScroll: true });
89
95
  });
90
96
  }
91
97
 
@@ -103,6 +103,29 @@ const Sessions = (() => {
103
103
  const titleAttr = title ? ` title="${title}"` : "";
104
104
  return `<a href="${href}"${titleAttr} target="_blank" rel="noopener noreferrer">${text}</a>`;
105
105
  };
106
+ // Override code block rendering: apply syntax highlighting + header with
107
+ // language label and copy button.
108
+ renderer.code = function({ text: code, lang }) {
109
+ const language = (lang || "").split(/\s+/)[0]; // strip extra info after lang
110
+ const highlighted = _highlightCode(code, language);
111
+ const displayLang = language || "text";
112
+ return (
113
+ `<div class="code-block">` +
114
+ `<div class="code-block-header">` +
115
+ `<span class="code-block-lang">${escapeHtml(displayLang)}</span>` +
116
+ `<button type="button" class="code-block-copy" aria-label="${I18n.t("chat.copy")}" title="${I18n.t("chat.copy")}">` +
117
+ `<svg class="code-copy-icon" viewBox="0 0 16 16" width="14" height="14" aria-hidden="true">` +
118
+ `<path fill="currentColor" d="M10 1H4a2 2 0 0 0-2 2v8h1.5V3a.5.5 0 0 1 .5-.5h6V1zm3 3H6a2 2 0 0 0-2 2v8a2 2 0 0 0 2 2h7a2 2 0 0 0 2-2V6a2 2 0 0 0-2-2zm.5 10a.5.5 0 0 1-.5.5H6a.5.5 0 0 1-.5-.5V6a.5.5 0 0 1 .5-.5h7a.5.5 0 0 1 .5.5v8z"/>` +
119
+ `</svg>` +
120
+ `<svg class="code-copy-icon-check" viewBox="0 0 16 16" width="14" height="14" aria-hidden="true">` +
121
+ `<path fill="currentColor" d="M13.5 3.5 6 11 2.5 7.5 1 9l5 5 9-9z"/>` +
122
+ `</svg>` +
123
+ `</button>` +
124
+ `</div>` +
125
+ `<pre><code class="hljs${language ? ` language-${escapeHtml(language)}` : ""}">${highlighted}</code></pre>` +
126
+ `</div>`
127
+ );
128
+ };
106
129
  html = marked.parse(prepared, { breaks: true, gfm: true, renderer });
107
130
  } else {
108
131
  html = escapeHtml(prepared).replace(/\n/g, "<br>");
@@ -114,6 +137,23 @@ const Sessions = (() => {
114
137
  return html;
115
138
  }
116
139
 
140
+ // Apply highlight.js to a code string. Returns highlighted HTML (already escaped
141
+ // by hljs). Falls back to plain escaped text if hljs is unavailable.
142
+ function _highlightCode(code, language) {
143
+ if (typeof hljs === "undefined") return escapeHtml(code);
144
+ if (language && hljs.getLanguage(language)) {
145
+ try {
146
+ return hljs.highlight(code, { language, ignoreIllegals: true }).value;
147
+ } catch (_) { /* fall through */ }
148
+ }
149
+ // Auto-detect when no language specified or language not recognized
150
+ try {
151
+ return hljs.highlightAuto(code).value;
152
+ } catch (_) {
153
+ return escapeHtml(code);
154
+ }
155
+ }
156
+
117
157
  // Pull $$...$$, \[...\], $...$, \(...\) out of `text` and replace each with a
118
158
  // sentinel placeholder so marked won't mangle the LaTeX source. The matched
119
159
  // segments are pushed (with display flag) onto `out` for later KaTeX rendering.
@@ -1414,13 +1454,27 @@ const Sessions = (() => {
1414
1454
  }
1415
1455
 
1416
1456
  // Install the click-delegation listener on #messages exactly once.
1417
- // Handles copy clicks for all current AND future assistant bubbles.
1457
+ // Handles copy clicks for all current AND future assistant bubbles
1458
+ // AND code block copy buttons.
1418
1459
  let _copyDelegationInstalled = false;
1419
1460
  function _ensureCopyDelegation() {
1420
1461
  if (_copyDelegationInstalled) return;
1421
1462
  const messages = $("messages");
1422
1463
  if (!messages) return;
1423
1464
  messages.addEventListener("click", (e) => {
1465
+ // ── Code block copy button ──
1466
+ const codeBtn = e.target.closest(".code-block-copy");
1467
+ if (codeBtn) {
1468
+ e.preventDefault();
1469
+ e.stopPropagation();
1470
+ const block = codeBtn.closest(".code-block");
1471
+ if (!block) return;
1472
+ const codeEl = block.querySelector("pre code");
1473
+ if (!codeEl) return;
1474
+ _copyTextAndFlash(codeBtn, codeEl.textContent || "");
1475
+ return;
1476
+ }
1477
+ // ── Message-level copy button ──
1424
1478
  const btn = e.target.closest(".msg-copy-btn");
1425
1479
  if (!btn) return;
1426
1480
  e.preventDefault();
@@ -1605,8 +1659,10 @@ const Sessions = (() => {
1605
1659
  // Re-render session list (badges/labels) when the user switches language
1606
1660
  document.addEventListener("langchange", () => Sessions.renderList());
1607
1661
  // Browsers block file:// navigation from http:// pages. Intercept clicks on
1608
- // file:// links and delegate to the backend API (OS default handler).
1609
- document.addEventListener("click", (e) => {
1662
+ // file:// links and delegate to the backend API.
1663
+ // Local deployments (localhost / 127.0.0.1 / ::1): open the file with the
1664
+ // OS default handler. Remote deployments: download the file.
1665
+ document.addEventListener("click", async (e) => {
1610
1666
  const link = e.target.closest("a[href^='file://']");
1611
1667
  if (!link) return;
1612
1668
  e.preventDefault();
@@ -1614,11 +1670,32 @@ const Sessions = (() => {
1614
1670
  // file:///C:/foo → /C:/foo after replace; strip the leading slash for Windows drive letters
1615
1671
  if (/^\/[A-Za-z]:/.test(filePath)) filePath = filePath.substring(1);
1616
1672
  if (!filePath) return;
1617
- fetch("/api/open-file", {
1618
- method: "POST",
1619
- headers: { "Content-Type": "application/json" },
1620
- body: JSON.stringify({ path: filePath })
1621
- });
1673
+
1674
+ const hostname = window.location.hostname;
1675
+ const isLocal = ["localhost", "127.0.0.1", "::1"].includes(hostname);
1676
+ const action = isLocal ? "open" : "download";
1677
+
1678
+ try {
1679
+ const resp = await fetch("/api/file-action", {
1680
+ method: "POST",
1681
+ headers: { "Content-Type": "application/json" },
1682
+ body: JSON.stringify({ path: filePath, action })
1683
+ });
1684
+
1685
+ if (action === "download" && resp.ok) {
1686
+ const blob = await resp.blob();
1687
+ const url = URL.createObjectURL(blob);
1688
+ const a = document.createElement("a");
1689
+ a.href = url;
1690
+ a.download = filePath.split("/").pop() || "download";
1691
+ document.body.appendChild(a);
1692
+ a.click();
1693
+ a.remove();
1694
+ URL.revokeObjectURL(url);
1695
+ }
1696
+ } catch (err) {
1697
+ console.error("file-action failed:", err);
1698
+ }
1622
1699
  });
1623
1700
  },
1624
1701
 
@@ -2122,7 +2199,7 @@ const Sessions = (() => {
2122
2199
  this._lastSession = s;
2123
2200
  if (!s) {
2124
2201
  // Hide all spans when no session
2125
- ["sib-id", "sib-status", "sib-dir", "sib-mode", "sib-model", "sib-tasks", "sib-cost"].forEach(id => {
2202
+ ["sib-id", "sib-status", "sib-dir", "sib-mode", "sib-model", "sib-reasoning", "sib-tasks", "sib-cost"].forEach(id => {
2126
2203
  const el = $(id); if (el) el.textContent = "";
2127
2204
  });
2128
2205
  const sibIdEl = $("sib-id");
@@ -2184,6 +2261,18 @@ const Sessions = (() => {
2184
2261
  }
2185
2262
  if (sibModelWrap) sibModelWrap.style.display = s.model ? "" : "none";
2186
2263
 
2264
+ const sibReasoning = $("sib-reasoning");
2265
+ const sibReasoningWrap = $("sib-reasoning-wrap");
2266
+ const sibSepAfterReasoning = document.querySelector(".sib-sep-after-reasoning");
2267
+ if (sibReasoning) {
2268
+ const eff = (s.reasoning_effort || "off").toLowerCase();
2269
+ sibReasoning.textContent = I18n.t(`sib.reasoning.${eff}`);
2270
+ sibReasoning.dataset.sessionId = s.id;
2271
+ sibReasoning.dataset.reasoningEffort = eff;
2272
+ }
2273
+ if (sibReasoningWrap) sibReasoningWrap.style.display = "";
2274
+ if (sibSepAfterReasoning) sibSepAfterReasoning.style.display = "";
2275
+
2187
2276
  // Latency signal — read from s.latest_latency (populated by:
2188
2277
  // - HTTP /api/sessions → session_registry#list (from agent.latest_latency)
2189
2278
  // - WS session_update events patched by app.js
@@ -3097,7 +3186,7 @@ const Sessions = (() => {
3097
3186
  console.log("[Model Switcher] Models count:", models.length);
3098
3187
 
3099
3188
  if (models.length === 0) {
3100
- dropdown.innerHTML = '<div style="padding:12px;text-align:center;color:var(--color-text-secondary);font-size:11px;">No models configured</div>';
3189
+ dropdown.innerHTML = '<div style="padding:0.75rem;text-align:center;color:var(--color-text-secondary);font-size:0.6875rem;">No models configured</div>';
3101
3190
  return;
3102
3191
  }
3103
3192
 
@@ -3167,7 +3256,7 @@ const Sessions = (() => {
3167
3256
  console.log("[Model Switcher] Dropdown populated, children count:", dropdown.children.length);
3168
3257
  } catch (e) {
3169
3258
  console.error("Failed to load models:", e);
3170
- dropdown.innerHTML = '<div style="padding:12px;text-align:center;color:var(--color-error);font-size:11px;">Error loading models</div>';
3259
+ dropdown.innerHTML = '<div style="padding:0.75rem;text-align:center;color:var(--color-error);font-size:0.6875rem;">Error loading models</div>';
3171
3260
  }
3172
3261
  }
3173
3262
 
@@ -3491,6 +3580,100 @@ const Sessions = (() => {
3491
3580
 
3492
3581
  })();
3493
3582
 
3583
+ // ── Session Info Bar Reasoning Effort Switcher ────────────────────────────
3584
+ (function() {
3585
+ let _isOpen = false;
3586
+ const LEVELS = ["off", "low", "medium", "high"];
3587
+
3588
+ document.addEventListener("click", async (e) => {
3589
+ const el = e.target.closest("#sib-reasoning");
3590
+ if (el) {
3591
+ e.stopPropagation();
3592
+ const dropdown = $("sib-reasoning-dropdown");
3593
+ if (!dropdown) return;
3594
+
3595
+ if (_isOpen) {
3596
+ dropdown.style.display = "none";
3597
+ _isOpen = false;
3598
+ return;
3599
+ }
3600
+
3601
+ _populate(dropdown, el.dataset.sessionId, el.dataset.reasoningEffort || "off");
3602
+
3603
+ const rect = el.getBoundingClientRect();
3604
+ dropdown.style.left = `${rect.left + rect.width / 2}px`;
3605
+ dropdown.style.top = `${rect.top - 6}px`;
3606
+ dropdown.style.transform = "translate(-50%, -100%)";
3607
+ dropdown.style.display = "block";
3608
+ _isOpen = true;
3609
+ return;
3610
+ }
3611
+
3612
+ if (_isOpen && !e.target.closest("#sib-reasoning-dropdown")) {
3613
+ const dropdown = $("sib-reasoning-dropdown");
3614
+ if (dropdown) dropdown.style.display = "none";
3615
+ _isOpen = false;
3616
+ }
3617
+ });
3618
+
3619
+ function _populate(dropdown, sessionId, current) {
3620
+ dropdown.innerHTML = "";
3621
+
3622
+ const header = document.createElement("div");
3623
+ header.className = "sib-reasoning-header";
3624
+ const heading = document.createElement("div");
3625
+ heading.className = "sib-reasoning-heading";
3626
+ heading.textContent = I18n.t("sib.reasoning.heading");
3627
+ const hint = document.createElement("div");
3628
+ hint.className = "sib-reasoning-hint";
3629
+ hint.textContent = I18n.t("sib.reasoning.hint");
3630
+ header.appendChild(heading);
3631
+ header.appendChild(hint);
3632
+ dropdown.appendChild(header);
3633
+
3634
+ LEVELS.forEach(level => {
3635
+ const opt = document.createElement("div");
3636
+ opt.className = "sib-reasoning-option";
3637
+ if (level === current) opt.classList.add("current");
3638
+
3639
+ const label = document.createElement("span");
3640
+ label.className = "sib-reasoning-name";
3641
+ label.textContent = I18n.t(`sib.reasoning.${level}`);
3642
+ opt.appendChild(label);
3643
+
3644
+ opt.addEventListener("click", () => _switch(sessionId, level));
3645
+ dropdown.appendChild(opt);
3646
+ });
3647
+ }
3648
+
3649
+ async function _switch(sessionId, level) {
3650
+ const dropdown = $("sib-reasoning-dropdown");
3651
+ if (dropdown) {
3652
+ dropdown.style.display = "none";
3653
+ _isOpen = false;
3654
+ }
3655
+
3656
+ try {
3657
+ const res = await fetch(`/api/sessions/${sessionId}/reasoning_effort`, {
3658
+ method: "PATCH",
3659
+ headers: { "Content-Type": "application/json" },
3660
+ body: JSON.stringify({ reasoning_effort: level })
3661
+ });
3662
+ const data = await res.json();
3663
+ if (!res.ok) throw new Error(data.error || "Unknown error");
3664
+
3665
+ const el = $("sib-reasoning");
3666
+ if (el) {
3667
+ el.textContent = I18n.t(`sib.reasoning.${level}`);
3668
+ el.dataset.reasoningEffort = level;
3669
+ }
3670
+ } catch (e) {
3671
+ console.error("Failed to switch reasoning effort:", e);
3672
+ alert("Failed to switch reasoning effort: " + e.message);
3673
+ }
3674
+ }
3675
+ })();
3676
+
3494
3677
  document.addEventListener("langchange", () => {
3495
3678
  if (Sessions._lastSession) Sessions.updateInfoBar(Sessions._lastSession);
3496
3679
  });
@@ -81,7 +81,7 @@ const Settings = (() => {
81
81
  <label class="model-field quick-setup-field" ${(model.model || model.base_url) ? 'style="display:none"' : ''}>
82
82
  <span class="field-label">${I18n.t("settings.models.field.quicksetup")}</span>
83
83
  <div class="custom-select-wrapper" data-index="${index}">
84
- <div class="custom-select-trigger">
84
+ <div class="custom-select-trigger" tabindex="0">
85
85
  <span class="custom-select-value placeholder">${I18n.t("settings.models.placeholder.provider")}</span>
86
86
  <svg class="custom-select-arrow" fill="none" stroke="currentColor" viewBox="0 0 24 24">
87
87
  <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 9l-7 7-7-7"></path>
@@ -126,7 +126,7 @@ const Settings = (() => {
126
126
  <label class="model-field">
127
127
  <span class="field-label">
128
128
  ${I18n.t("settings.models.field.apikey")}
129
- <a class="get-apikey-link" data-index="${index}" href="#" target="_blank" rel="noopener" style="display:none;margin-left:8px;font-size:12px;color:var(--accent,#6366f1);text-decoration:none;opacity:0.85;">${I18n.t("settings.models.field.getApiKey")}</a>
129
+ <a class="get-apikey-link" data-index="${index}" href="#" target="_blank" rel="noopener" style="display:none;margin-left:0.5rem;font-size:0.75rem;color:var(--accent,#6366f1);text-decoration:none;opacity:0.85;">${I18n.t("settings.models.field.getApiKey")}</a>
130
130
  </span>
131
131
  <div class="field-input-row">
132
132
  <input type="password" class="field-input api-key-input" data-key="api_key" data-index="${index}"
@@ -142,9 +142,9 @@ const Settings = (() => {
142
142
  </div>
143
143
 
144
144
  <div class="model-card-footer">
145
- ${!model.api_key_masked ? `<span class="model-card-docs-link" style="font-size:12px;">
145
+ ${!model.api_key_masked ? `<span class="model-card-docs-link" style="font-size:0.75rem;">
146
146
  <span style="color:var(--muted,#6b7280);">${I18n.t("settings.models.field.docsGuide.question")}</span>
147
- <a href="https://www.openclacky.com/docs/ai-key-guide" target="_blank" rel="noopener" style="margin-left:4px;color:var(--accent,#6366f1);text-decoration:none;">${I18n.t("settings.models.field.docsGuide.cta")}</a>
147
+ <a href="https://www.openclacky.com/docs/ai-key-guide" target="_blank" rel="noopener" style="margin-left:0.25rem;color:var(--accent,#6366f1);text-decoration:none;">${I18n.t("settings.models.field.docsGuide.cta")}</a>
148
148
  </span>` : ""}
149
149
  <span class="model-test-result" data-index="${index}"></span>
150
150
  <div class="model-card-actions-row">
@@ -709,10 +709,22 @@ const Settings = (() => {
709
709
  behavior: "smooth"
710
710
  });
711
711
 
712
- // Put focus on the new card's first input so the user can start
713
- // typing immediately.
714
- const firstInput = last.querySelector("input, select, textarea");
715
- if (firstInput) firstInput.focus({ preventScroll: true });
712
+ // Put focus on the new card so the user can start configuring.
713
+ // Priority order:
714
+ // 1. The provider `.custom-select-trigger` — nudges the user to
715
+ // pick a provider first (step 1 of the 3-field form). It's a
716
+ // div with tabindex="0", so it gets the accent border via
717
+ // `:focus` without expanding the dropdown.
718
+ // 2. Fall back to the first form input (used when the quick-setup
719
+ // field is hidden, e.g. on a model card that already has values).
720
+ const providerTrigger = last.querySelector(".custom-select-wrapper .custom-select-trigger");
721
+ const isVisible = el => el && el.offsetParent !== null;
722
+ if (isVisible(providerTrigger)) {
723
+ providerTrigger.focus({ preventScroll: true });
724
+ } else {
725
+ const firstInput = last.querySelector("input, select, textarea");
726
+ if (firstInput) firstInput.focus({ preventScroll: true });
727
+ }
716
728
  });
717
729
  });
718
730
  }
@@ -1048,11 +1060,11 @@ const Settings = (() => {
1048
1060
 
1049
1061
  function _initLangBtns() {
1050
1062
  // Highlight the active language button on open
1051
- document.querySelectorAll(".settings-lang-btn").forEach(btn => {
1063
+ document.querySelectorAll("#language-section .settings-lang-btn").forEach(btn => {
1052
1064
  btn.classList.toggle("active", btn.dataset.lang === I18n.lang());
1053
1065
  btn.addEventListener("click", () => {
1054
1066
  I18n.setLang(btn.dataset.lang);
1055
- document.querySelectorAll(".settings-lang-btn").forEach(b =>
1067
+ document.querySelectorAll("#language-section .settings-lang-btn").forEach(b =>
1056
1068
  b.classList.toggle("active", b.dataset.lang === I18n.lang())
1057
1069
  );
1058
1070
  });
@@ -1129,11 +1141,40 @@ const Settings = (() => {
1129
1141
  });
1130
1142
 
1131
1143
  _initLangBtns();
1144
+ _initFontBtns();
1132
1145
 
1133
1146
  // Re-render model cards when language changes (dynamic HTML, not data-i18n)
1134
1147
  document.addEventListener("langchange", () => _renderCards());
1135
1148
  }
1136
1149
 
1150
+ // ── Font Size ──────────────────────────────────────────────────────────
1151
+ const FONT_STORAGE_KEY = "clacky-font-size";
1152
+ const FONT_DEFAULT = "medium";
1153
+
1154
+ function _applyFontSize(size) {
1155
+ document.documentElement.setAttribute("data-font-size", size);
1156
+ try { localStorage.setItem(FONT_STORAGE_KEY, size); } catch (_) {}
1157
+ // Update active state on all font-size buttons (if settings panel is open)
1158
+ document.querySelectorAll("#font-size-section .settings-lang-btn").forEach(btn => {
1159
+ btn.classList.toggle("active", btn.dataset.font === size);
1160
+ });
1161
+ }
1162
+
1163
+ function _initFontBtns() {
1164
+ // Apply saved preference (or default) on page load
1165
+ let saved = null;
1166
+ try { saved = localStorage.getItem(FONT_STORAGE_KEY); } catch (_) {}
1167
+ _applyFontSize(saved || FONT_DEFAULT);
1168
+
1169
+ // Wire up button clicks
1170
+ document.querySelectorAll("#font-size-section .settings-lang-btn").forEach(btn => {
1171
+ btn.classList.toggle("active", btn.dataset.font === (saved || FONT_DEFAULT));
1172
+ btn.addEventListener("click", () => {
1173
+ _applyFontSize(btn.dataset.font);
1174
+ });
1175
+ });
1176
+ }
1177
+
1137
1178
  // ── QR Code Lightbox ───────────────────────────────────────────────────
1138
1179
  // Sets up click-to-enlarge behaviour for the support QR code.
1139
1180
  // Safe to call multiple times — idempotent via a data attribute guard.