openclacky 1.0.0 → 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.
Files changed (70) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +39 -0
  3. data/README.md +87 -53
  4. data/lib/clacky/agent/cost_tracker.rb +19 -2
  5. data/lib/clacky/agent/llm_caller.rb +218 -0
  6. data/lib/clacky/agent/message_compressor_helper.rb +32 -2
  7. data/lib/clacky/agent.rb +54 -22
  8. data/lib/clacky/client.rb +44 -5
  9. data/lib/clacky/default_parsers/pdf_parser.rb +58 -17
  10. data/lib/clacky/default_parsers/pdf_parser_ocr.py +103 -0
  11. data/lib/clacky/default_parsers/pdf_parser_plumber.py +62 -0
  12. data/lib/clacky/default_skills/deploy/SKILL.md +201 -77
  13. data/lib/clacky/default_skills/new/SKILL.md +3 -114
  14. data/lib/clacky/default_skills/onboard/SKILL.md +349 -133
  15. data/lib/clacky/default_skills/onboard/scripts/import_external_skills.rb +371 -0
  16. data/lib/clacky/default_skills/onboard/scripts/install_builtin_skills.rb +175 -0
  17. data/lib/clacky/default_skills/skill-add/scripts/install_from_zip.rb +59 -26
  18. data/lib/clacky/message_format/anthropic.rb +72 -8
  19. data/lib/clacky/message_format/bedrock.rb +6 -3
  20. data/lib/clacky/providers.rb +146 -3
  21. data/lib/clacky/server/channel/adapters/feishu/adapter.rb +14 -0
  22. data/lib/clacky/server/channel/adapters/feishu/bot.rb +10 -0
  23. data/lib/clacky/server/channel/adapters/feishu/message_parser.rb +1 -0
  24. data/lib/clacky/server/channel/channel_manager.rb +12 -4
  25. data/lib/clacky/server/channel/channel_ui_controller.rb +8 -2
  26. data/lib/clacky/server/http_server.rb +746 -13
  27. data/lib/clacky/server/session_registry.rb +55 -24
  28. data/lib/clacky/skill.rb +10 -9
  29. data/lib/clacky/skill_loader.rb +23 -11
  30. data/lib/clacky/tools/file_reader.rb +232 -127
  31. data/lib/clacky/tools/security.rb +42 -64
  32. data/lib/clacky/tools/terminal/persistent_session.rb +15 -4
  33. data/lib/clacky/tools/terminal/safe_rm.sh +106 -0
  34. data/lib/clacky/tools/terminal/session_manager.rb +8 -3
  35. data/lib/clacky/tools/terminal.rb +263 -16
  36. data/lib/clacky/ui2/layout_manager.rb +8 -1
  37. data/lib/clacky/ui2/output_buffer.rb +83 -23
  38. data/lib/clacky/ui2/ui_controller.rb +74 -7
  39. data/lib/clacky/utils/file_processor.rb +14 -40
  40. data/lib/clacky/utils/model_pricing.rb +215 -0
  41. data/lib/clacky/utils/parser_manager.rb +70 -6
  42. data/lib/clacky/utils/string_matcher.rb +23 -1
  43. data/lib/clacky/version.rb +1 -1
  44. data/lib/clacky/web/app.css +673 -9
  45. data/lib/clacky/web/app.js +40 -1608
  46. data/lib/clacky/web/i18n.js +209 -0
  47. data/lib/clacky/web/index.html +166 -2
  48. data/lib/clacky/web/onboard.js +77 -1
  49. data/lib/clacky/web/profile.js +442 -0
  50. data/lib/clacky/web/sessions.js +1034 -2
  51. data/lib/clacky/web/settings.js +127 -6
  52. data/lib/clacky/web/sidebar.js +39 -0
  53. data/lib/clacky/web/skills.js +460 -0
  54. data/lib/clacky/web/trash.js +343 -0
  55. data/lib/clacky/web/ws-dispatcher.js +255 -0
  56. data/lib/clacky.rb +5 -3
  57. metadata +16 -17
  58. data/lib/clacky/clacky_auth_client.rb +0 -152
  59. data/lib/clacky/clacky_cloud_config.rb +0 -123
  60. data/lib/clacky/cloud_project_client.rb +0 -169
  61. data/lib/clacky/default_skills/deploy/scripts/rails_deploy.rb +0 -1377
  62. data/lib/clacky/default_skills/deploy/tools/check_health.rb +0 -116
  63. data/lib/clacky/default_skills/deploy/tools/create_database_service.rb +0 -341
  64. data/lib/clacky/default_skills/deploy/tools/execute_deployment.rb +0 -99
  65. data/lib/clacky/default_skills/deploy/tools/fetch_runtime_logs.rb +0 -77
  66. data/lib/clacky/default_skills/deploy/tools/list_services.rb +0 -67
  67. data/lib/clacky/default_skills/deploy/tools/report_deploy_status.rb +0 -67
  68. data/lib/clacky/default_skills/deploy/tools/set_deploy_variables.rb +0 -189
  69. data/lib/clacky/default_skills/new/scripts/cloud_project_init.sh +0 -74
  70. data/lib/clacky/deploy_api_client.rb +0 -484
@@ -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 ────────────────────────────────────────────
@@ -0,0 +1,39 @@
1
+ // ── sidebar.js — Left sidebar navigation ──────────────────────────────────
2
+ //
3
+ // Owns ONLY the left-rail navigation buttons whose sole job is to switch
4
+ // the main router view. Any "business action" button that happens to live
5
+ // in the sidebar (e.g. "new session") belongs to its own domain module
6
+ // (Sessions, Skills, …), not here.
7
+ //
8
+ // Contract:
9
+ // Sidebar.init() — attach click handlers. Must be called after DOM ready.
10
+ // ──────────────────────────────────────────────────────────────────────────
11
+
12
+ const Sidebar = (() => {
13
+ function init() {
14
+ // Settings button toggles between "settings" and "welcome" view.
15
+ document.getElementById("btn-settings").addEventListener("click", () => {
16
+ if (Router.current === "settings") {
17
+ Router.navigate("welcome");
18
+ } else {
19
+ Router.navigate("settings");
20
+ }
21
+ });
22
+
23
+ // Primary navigation items — each just swaps the current route.
24
+ document.getElementById("tasks-sidebar-item").addEventListener("click", () => Router.navigate("tasks"));
25
+ document.getElementById("skills-sidebar-item").addEventListener("click", () => Router.navigate("skills"));
26
+ document.getElementById("channels-sidebar-item").addEventListener("click", () => Router.navigate("channels"));
27
+ document.getElementById("trash-sidebar-item").addEventListener("click", () => Router.navigate("trash"));
28
+ document.getElementById("profile-sidebar-item").addEventListener("click", () => Router.navigate("profile"));
29
+
30
+ // memories-sidebar-item is retained as a hidden legacy placeholder — no click handler.
31
+
32
+ // creator-sidebar-item is conditionally rendered (only when user_licensed).
33
+ // This ?. is a legitimate business guard, not defensive padding — the
34
+ // element genuinely may not exist in the DOM for unlicensed users.
35
+ document.getElementById("creator-sidebar-item")?.addEventListener("click", () => Router.navigate("creator"));
36
+ }
37
+
38
+ return { init };
39
+ })();
@@ -764,3 +764,463 @@ const Skills = (() => {
764
764
  },
765
765
  };
766
766
  })();
767
+
768
+ // ─────────────────────────────────────────────────────────────────────────
769
+ // SkillAC — slash-command skill autocomplete dropdown + composer bindings
770
+ //
771
+ // Handles the "/xxx" slash-command autocomplete UI above the message input,
772
+ // plus all composer keyboard/composition/input DOM bindings that depend on it
773
+ // (Enter to send, / button, IME composition guard).
774
+ //
775
+ // Moved verbatim from app.js; structural changes only:
776
+ // - `_lastCompositionEndTime` moved into the IIFE closure (was module-level)
777
+ // - The bare DOM bindings (btn-slash, user-input keydown/input/compositionend,
778
+ // btn-create-skill, btn-import-skill) are wrapped in a private
779
+ // `_initDOMBindings()` function called at the end of `init()`.
780
+ //
781
+ // Depends on: Sessions (sendMessage), Skills (createInSession, toggleImportBar),
782
+ // I18n, global $ helper.
783
+ // ─────────────────────────────────────────────────────────────────────────
784
+ const SkillAC = (() => {
785
+ let _initialized = false;
786
+ let _visible = false;
787
+ let _activeIndex = -1;
788
+ let _items = []; // filtered [{ name, description, encrypted, source }]
789
+ let _currentSession = null; // track active session id for live fetch
790
+
791
+ // Load from localStorage, default to false (hide system skills)
792
+ let _showSystemSkills = localStorage.getItem("skill-ac-show-system") === "true";
793
+
794
+ // Cross-browser IME composition fix:
795
+ // Safari fires compositionend BEFORE keydown (violating W3C spec), and the
796
+ // gap between compositionend and keydown is ~5ms on Safari. We record the
797
+ // timestamp of compositionend and treat any Enter keydown within 20ms as
798
+ // still-composing. Chrome is unaffected because e.isComposing is still true.
799
+ // Reference: https://bugs.webkit.org/show_bug.cgi?id=165004
800
+ let _lastCompositionEndTime = -Infinity;
801
+
802
+ /** Called whenever the active session changes — just store the id, no prefetch. */
803
+ function _loadForSession(sessionId) {
804
+ _currentSession = sessionId || null;
805
+ }
806
+
807
+ /** Fetch live skill list from server for the current session. */
808
+ async function _fetchSkills() {
809
+ if (!_currentSession) return [];
810
+ try {
811
+ const res = await fetch(`/api/sessions/${_currentSession}/skills`);
812
+ const data = await res.json();
813
+ return data.skills || [];
814
+ } catch (e) {
815
+ console.error("[SkillAC] fetchSkills failed", e);
816
+ return [];
817
+ }
818
+ }
819
+
820
+ /** Return the /xxx prefix if the entire input is a slash command, else null. */
821
+ function _getSlashQuery(value) {
822
+ // Full-width slash / dunhao are already replaced in the input event handler,
823
+ // but guard here too in case value is passed programmatically.
824
+ let trimmed = value.replace(/^[/、]/, "/");
825
+
826
+ // Only activate when the whole input starts with / (no leading space)
827
+ if (!trimmed.startsWith("/")) return null;
828
+ // Only single-word slash token — no spaces allowed after /
829
+ if (/^\/\S*$/.test(trimmed)) return trimmed.slice(1).toLowerCase();
830
+ return null;
831
+ }
832
+
833
+ /**
834
+ * Score how well a skill matches the query string.
835
+ * Only matches against name and name_zh — description is intentionally excluded.
836
+ * All matches are contiguous substring matches (no fuzzy/subsequence).
837
+ * Returns 0 if no match (should be filtered out).
838
+ *
839
+ * Scoring tiers:
840
+ * 100 — name or name_zh exact match
841
+ * 80 — name or name_zh starts-with
842
+ * 60 — name or name_zh contains
843
+ * 0 — no match
844
+ */
845
+ function _scoreMatch(skill, query) {
846
+ if (!query) return 50; // empty query → show all with neutral score
847
+
848
+ const q = query.toLowerCase();
849
+ const name = (skill.name || "").toLowerCase();
850
+ const zh = (skill.name_zh || "").toLowerCase();
851
+
852
+ // Exact match
853
+ if (name === q || zh === q) return 100;
854
+
855
+ // Prefix match
856
+ if (name.startsWith(q) || zh.startsWith(q)) return 80;
857
+
858
+ // Contains match (contiguous substring)
859
+ if (name.includes(q) || zh.includes(q)) return 60;
860
+
861
+ return 0;
862
+ }
863
+
864
+ /**
865
+ * Wrap the matching substring in <mark> for highlighting.
866
+ * Returns an array of DOM nodes (text + mark nodes).
867
+ */
868
+ function _highlight(text, query) {
869
+ if (!query) return [document.createTextNode(text)];
870
+ const idx = text.toLowerCase().indexOf(query.toLowerCase());
871
+ if (idx === -1) return [document.createTextNode(text)];
872
+
873
+ const nodes = [];
874
+ if (idx > 0) nodes.push(document.createTextNode(text.slice(0, idx)));
875
+ const mark = document.createElement("span");
876
+ mark.className = "skill-ac-highlight";
877
+ mark.textContent = text.slice(idx, idx + query.length);
878
+ nodes.push(mark);
879
+ if (idx + query.length < text.length) {
880
+ nodes.push(document.createTextNode(text.slice(idx + query.length)));
881
+ }
882
+ return nodes;
883
+ }
884
+
885
+ async function _render(query) {
886
+ const all = await _fetchSkills();
887
+
888
+ // Score and filter
889
+ let scored = all
890
+ .map(s => ({ skill: s, score: _scoreMatch(s, query) }))
891
+ .filter(({ score }) => score > 0);
892
+
893
+ if (!_showSystemSkills) {
894
+ scored = scored.filter(({ skill }) => skill.source_type !== "default");
895
+ }
896
+
897
+ // Sort by score descending, stable secondary sort by name
898
+ scored.sort((a, b) => b.score - a.score || a.skill.name.localeCompare(b.skill.name));
899
+
900
+ _items = scored.map(({ skill }) => skill);
901
+
902
+ const list = $("skill-autocomplete-list");
903
+ list.innerHTML = "";
904
+
905
+ if (_items.length === 0) {
906
+ // Show empty state instead of hiding the dropdown
907
+ const emptyEl = document.createElement("div");
908
+ emptyEl.className = "skill-ac-empty";
909
+ emptyEl.textContent = I18n.t("skills.ac.empty");
910
+ list.appendChild(emptyEl);
911
+ $("skill-autocomplete").style.display = "";
912
+ _visible = true;
913
+ _createOverlay();
914
+ return;
915
+ }
916
+
917
+ _items.forEach((skill, idx) => {
918
+ const item = document.createElement("div");
919
+ item.className = "skill-ac-item" + (idx === _activeIndex ? " active" : "");
920
+ item.setAttribute("role", "option");
921
+ item.setAttribute("data-idx", idx);
922
+
923
+ const nameEl = document.createElement("span");
924
+ nameEl.className = "skill-ac-name";
925
+
926
+ const currentLangForName = I18n.lang();
927
+ const showZhFirst = currentLangForName === "zh" && skill.name_zh;
928
+
929
+ if (showZhFirst) {
930
+ // Chinese UI: /中文名 first (with slash), then english id (no slash) after
931
+ const zhEl = document.createElement("span");
932
+ zhEl.className = "skill-ac-name-zh";
933
+ zhEl.appendChild(document.createTextNode("/"));
934
+ _highlight(skill.name_zh, query).forEach(function(n) { zhEl.appendChild(n); });
935
+ nameEl.appendChild(zhEl);
936
+
937
+ const nameTextEl = document.createElement("span");
938
+ nameTextEl.className = "skill-ac-name-id";
939
+ _highlight(skill.name, query).forEach(function(n) { nameTextEl.appendChild(n); });
940
+ nameEl.appendChild(nameTextEl);
941
+ } else {
942
+ // English UI (or no zh name): show /id only, no zh name
943
+ const nameTextEl = document.createElement("span");
944
+ nameTextEl.appendChild(document.createTextNode("/"));
945
+ _highlight(skill.name, query).forEach(function(n) { nameTextEl.appendChild(n); });
946
+ nameEl.appendChild(nameTextEl);
947
+ }
948
+
949
+ // meta: encrypted badge + source type label (subtle)
950
+ const metaEl = document.createElement("span");
951
+ metaEl.className = "skill-ac-meta";
952
+ if (skill.encrypted) {
953
+ const encBadge = document.createElement("span");
954
+ encBadge.className = "skill-ac-enc";
955
+ encBadge.textContent = "🔒";
956
+ metaEl.appendChild(encBadge);
957
+ }
958
+ const sourceLabel = {
959
+ "default": "built-in",
960
+ "global_clacky": "user",
961
+ "global_claude": "user",
962
+ "project_clacky": "project",
963
+ "project_claude": "project",
964
+ "brand": "brand",
965
+ }[skill.source_type];
966
+ if (sourceLabel) {
967
+ const srcEl = document.createElement("span");
968
+ srcEl.className = "skill-ac-src";
969
+ srcEl.textContent = sourceLabel;
970
+ metaEl.appendChild(srcEl);
971
+ }
972
+
973
+ const descEl = document.createElement("span");
974
+ descEl.className = "skill-ac-desc";
975
+ // Choose description based on current language
976
+ const description = (currentLangForName === "zh" && skill.description_zh)
977
+ ? skill.description_zh
978
+ : skill.description || "";
979
+ descEl.textContent = description;
980
+
981
+ item.appendChild(nameEl);
982
+ item.appendChild(metaEl);
983
+ item.appendChild(descEl);
984
+
985
+ item.addEventListener("mousedown", e => {
986
+ // mousedown fires before blur — prevent input losing focus
987
+ e.preventDefault();
988
+ _select(idx);
989
+ });
990
+
991
+ list.appendChild(item);
992
+ });
993
+
994
+ $("skill-autocomplete").style.display = "";
995
+ _visible = true;
996
+ _createOverlay();
997
+ }
998
+
999
+ function _hide() {
1000
+ $("skill-autocomplete").style.display = "none";
1001
+ _visible = false;
1002
+ _activeIndex = -1;
1003
+ _items = [];
1004
+ $("btn-slash")?.classList.remove("active");
1005
+ _removeOverlay();
1006
+ }
1007
+
1008
+ function _createOverlay() {
1009
+ // Remove existing overlay if any
1010
+ _removeOverlay();
1011
+
1012
+ const overlay = document.createElement("div");
1013
+ overlay.id = "skill-ac-overlay";
1014
+ overlay.style.cssText = "position: fixed; top: 0; left: 0; right: 0; bottom: 0; z-index: 999; background: transparent;";
1015
+
1016
+ // Click overlay to close dropdown
1017
+ overlay.addEventListener("click", () => {
1018
+ _hide();
1019
+ });
1020
+
1021
+ document.body.appendChild(overlay);
1022
+ }
1023
+
1024
+ function _removeOverlay() {
1025
+ const overlay = document.getElementById("skill-ac-overlay");
1026
+ if (overlay) overlay.remove();
1027
+ }
1028
+
1029
+ function _select(idx) {
1030
+ const skill = _items[idx];
1031
+ if (!skill) return;
1032
+ const input = $("user-input");
1033
+ input.value = "/" + skill.name + " ";
1034
+ input.style.height = "auto";
1035
+ input.style.height = Math.min(input.scrollHeight, 200) + "px";
1036
+ _hide();
1037
+ input.focus();
1038
+ }
1039
+
1040
+ function _moveActive(delta) {
1041
+ if (!_visible || _items.length === 0) return;
1042
+ _activeIndex = (_activeIndex + delta + _items.length) % _items.length;
1043
+ // Re-render to apply active class
1044
+ const list = $("skill-autocomplete-list");
1045
+ list.querySelectorAll(".skill-ac-item").forEach((el, i) => {
1046
+ el.classList.toggle("active", i === _activeIndex);
1047
+ if (i === _activeIndex) el.scrollIntoView({ block: "nearest" });
1048
+ });
1049
+ }
1050
+
1051
+ /** Open the dropdown showing all skills, used by the / button. */
1052
+ async function _openAll() {
1053
+ _activeIndex = 0; // Default to first item
1054
+ await _render("");
1055
+ $("user-input").focus();
1056
+ }
1057
+
1058
+ /** Toggle the dropdown (open if hidden, close if visible). */
1059
+ async function _toggle() {
1060
+ if (_visible) {
1061
+ _hide();
1062
+ } else {
1063
+ await _openAll();
1064
+ }
1065
+ }
1066
+
1067
+ // ── DOM bindings: composer keyboard/composition/input + slash button + ────
1068
+ // ── skill-panel create/import buttons. Called once from init(). ──
1069
+ function _initDOMBindings() {
1070
+ // / button: set input to "/" and open skill autocomplete.
1071
+ // mousedown + preventDefault prevents the textarea from losing focus
1072
+ // (which would trigger the blur→hide timer and immediately close
1073
+ // the dropdown we're about to open).
1074
+ $("btn-slash").addEventListener("mousedown", e => {
1075
+ e.preventDefault(); // keep focus on user-input
1076
+ });
1077
+ $("btn-slash").addEventListener("click", () => {
1078
+ const input = $("user-input");
1079
+ if (input.value === "" || input.value === "/") {
1080
+ input.value = "/";
1081
+ input.style.height = "auto";
1082
+ input.style.height = Math.min(input.scrollHeight, 200) + "px";
1083
+ }
1084
+ _toggle(); // Toggle dropdown instead of always opening
1085
+ if (_visible) {
1086
+ $("btn-slash").classList.add("active");
1087
+ }
1088
+ input.focus();
1089
+ });
1090
+
1091
+ // IME composition guard: record timestamp of compositionend so the
1092
+ // Enter keydown handler can detect Safari's out-of-order firing.
1093
+ $("user-input").addEventListener("compositionend", () => {
1094
+ _lastCompositionEndTime = Date.now();
1095
+ });
1096
+
1097
+ // Main composer keydown: SkillAC consumes nav keys first, then Enter → send.
1098
+ $("user-input").addEventListener("keydown", e => {
1099
+ // Let skill autocomplete consume arrow/enter/escape first
1100
+ if (_handleKey(e)) return;
1101
+
1102
+ if (e.key === "Enter" && !e.shiftKey && !e.isComposing && (Date.now() - _lastCompositionEndTime) > 20) {
1103
+ e.preventDefault();
1104
+ Sessions.sendMessage();
1105
+ }
1106
+ });
1107
+
1108
+ // Composer input: auto-grow textarea, normalize full-width slash, drive AC.
1109
+ $("user-input").addEventListener("input", () => {
1110
+ const el = $("user-input");
1111
+ el.style.height = "auto";
1112
+ el.style.height = Math.min(el.scrollHeight, 200) + "px";
1113
+
1114
+ // Replace full-width slash / or Chinese dunhao 、 with ASCII / in-place
1115
+ if (/^[/、]/.test(el.value)) {
1116
+ const pos = el.selectionStart;
1117
+ el.value = el.value.replace(/^[/、]/, "/");
1118
+ el.setSelectionRange(pos, pos);
1119
+ }
1120
+
1121
+ // Trigger skill autocomplete
1122
+ _update(el.value);
1123
+ });
1124
+
1125
+ // Skills panel action buttons (domain belongs to Skills, but the
1126
+ // bindings live here because this is where all composer/skill-related
1127
+ // DOM wiring was historically colocated).
1128
+ $("btn-create-skill").addEventListener("click", () => Skills.createInSession());
1129
+ $("btn-import-skill").addEventListener("click", () => Skills.toggleImportBar());
1130
+ }
1131
+
1132
+ // Update handler — driven from the input event above. Exposed on the
1133
+ // public API for programmatic use too.
1134
+ function _update(value) {
1135
+ const query = _getSlashQuery(value);
1136
+ if (query === null) { _hide(); return; }
1137
+ _activeIndex = 0; // Always highlight the first match
1138
+ _render(query); // async, fire-and-forget
1139
+ }
1140
+
1141
+ // Keyboard handler for the dropdown. Returns true if the event was consumed.
1142
+ function _handleKey(e) {
1143
+ if (!_visible) return false;
1144
+ if (e.key === "ArrowDown") { e.preventDefault(); _moveActive(1); return true; }
1145
+ if (e.key === "ArrowUp") { e.preventDefault(); _moveActive(-1); return true; }
1146
+ if (e.key === "Escape") { e.preventDefault(); _hide(); return true; }
1147
+ if (e.key === "Tab") {
1148
+ // Tab: select active item if one is highlighted, otherwise select first item
1149
+ e.preventDefault();
1150
+ const targetIdx = _activeIndex >= 0 ? _activeIndex : 0;
1151
+ _select(targetIdx);
1152
+ return true;
1153
+ }
1154
+ if (e.key === "Enter" && !e.isComposing && (Date.now() - _lastCompositionEndTime) > 20) {
1155
+ if (_activeIndex >= 0) {
1156
+ e.preventDefault();
1157
+ _select(_activeIndex);
1158
+ return true;
1159
+ }
1160
+ // No item highlighted — select first item if available
1161
+ if (_items.length > 0) {
1162
+ e.preventDefault();
1163
+ _select(0);
1164
+ return true;
1165
+ }
1166
+ // No items — let Enter fall through to sendMessage
1167
+ _hide();
1168
+ return false;
1169
+ }
1170
+ return false;
1171
+ }
1172
+
1173
+ return {
1174
+ get visible() { return _visible; },
1175
+ get activeIndex() { return _activeIndex; },
1176
+
1177
+ /** Initialize event listeners (call once on page load). */
1178
+ init() {
1179
+ if (_initialized) return;
1180
+ _initialized = true;
1181
+
1182
+ const chk = $("chk-ac-show-system-skills");
1183
+
1184
+ if (chk) {
1185
+ // Restore state from localStorage
1186
+ chk.checked = _showSystemSkills;
1187
+
1188
+ chk.addEventListener("change", async () => {
1189
+ _showSystemSkills = chk.checked;
1190
+ // Persist to localStorage
1191
+ localStorage.setItem("skill-ac-show-system", _showSystemSkills ? "true" : "false");
1192
+
1193
+ // If dropdown is visible, re-fetch and re-render
1194
+ if (_visible) {
1195
+ const input = $("user-input");
1196
+ const query = _getSlashQuery(input.value);
1197
+ if (query !== null) {
1198
+ await _render(query);
1199
+ }
1200
+ }
1201
+ });
1202
+ }
1203
+
1204
+ // Wire up all composer/slash DOM bindings.
1205
+ _initDOMBindings();
1206
+ },
1207
+
1208
+ /** Called on every `input` event — decide whether to show/hide/update. */
1209
+ update: _update,
1210
+
1211
+ /** Open dropdown with all skills (triggered by / button). */
1212
+ openAll: _openAll,
1213
+
1214
+ /** Toggle dropdown visibility (used by / button). */
1215
+ toggle: _toggle,
1216
+
1217
+ /** Hide the dropdown. */
1218
+ hide: _hide,
1219
+
1220
+ /** Reload session-scoped skill list when the active session changes. */
1221
+ loadForSession: _loadForSession,
1222
+
1223
+ /** Handle keyboard nav inside the dropdown. Returns true if event was consumed. */
1224
+ handleKey: _handleKey,
1225
+ };
1226
+ })();