openclacky 1.5.12 → 1.5.13

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 (57) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +48 -0
  3. data/lib/clacky/access_key.rb +59 -0
  4. data/lib/clacky/agent/message_compressor_helper.rb +52 -1
  5. data/lib/clacky/agent/session_serializer.rb +35 -2
  6. data/lib/clacky/agent/time_machine.rb +9 -1
  7. data/lib/clacky/agent.rb +11 -7
  8. data/lib/clacky/agent_config.rb +40 -0
  9. data/lib/clacky/agent_profile.rb +5 -5
  10. data/lib/clacky/billing/platform_billing.rb +84 -1
  11. data/lib/clacky/brand_config.rb +4 -3
  12. data/lib/clacky/cli.rb +4 -2
  13. data/lib/clacky/client.rb +107 -0
  14. data/lib/clacky/default_extensions/ext-studio/api/handler.rb +3 -0
  15. data/lib/clacky/default_extensions/ext-studio/panels/studio/view.js +40 -21
  16. data/lib/clacky/default_extensions/git/ext.yml +1 -1
  17. data/lib/clacky/default_extensions/git/panels/git/view.js +483 -77
  18. data/lib/clacky/default_extensions/meeting/ext.yml +2 -1
  19. data/lib/clacky/default_extensions/time_machine/ext.yml +1 -1
  20. data/lib/clacky/default_extensions/time_machine/panels/time_machine/view.js +186 -208
  21. data/lib/clacky/extension/packager.rb +4 -2
  22. data/lib/clacky/extension/verifier.rb +14 -1
  23. data/lib/clacky/message_format/open_ai.rb +5 -1
  24. data/lib/clacky/message_format/open_ai_responses.rb +409 -0
  25. data/lib/clacky/message_history.rb +6 -2
  26. data/lib/clacky/openai_responses_stream_aggregator.rb +294 -0
  27. data/lib/clacky/providers.rb +18 -6
  28. data/lib/clacky/server/dir_picker.rb +154 -0
  29. data/lib/clacky/server/git_panel.rb +61 -9
  30. data/lib/clacky/server/http_server.rb +147 -103
  31. data/lib/clacky/tools/terminal.rb +27 -3
  32. data/lib/clacky/ui2/markdown_renderer.rb +15 -12
  33. data/lib/clacky/ui2/strings_cjk_patch.rb +132 -0
  34. data/lib/clacky/ui2/themes/hacker_theme.rb +1 -1
  35. data/lib/clacky/ui2/themes/minimal_theme.rb +1 -1
  36. data/lib/clacky/utils/model_pricing.rb +70 -4
  37. data/lib/clacky/version.rb +1 -1
  38. data/lib/clacky/web/app.css +412 -108
  39. data/lib/clacky/web/components/composer.js +45 -7
  40. data/lib/clacky/web/components/custom-select.js +136 -0
  41. data/lib/clacky/web/components/mentions.js +58 -2
  42. data/lib/clacky/web/components/model-picker.js +527 -0
  43. data/lib/clacky/web/components/onboard.js +29 -55
  44. data/lib/clacky/web/core/ext.js +38 -0
  45. data/lib/clacky/web/features/billing/view.js +20 -6
  46. data/lib/clacky/web/features/extensions/view.js +22 -10
  47. data/lib/clacky/web/features/new-session/store.js +15 -1
  48. data/lib/clacky/web/features/new-session/view.js +31 -22
  49. data/lib/clacky/web/features/trash/view.js +31 -19
  50. data/lib/clacky/web/features/workspace/store.js +1 -0
  51. data/lib/clacky/web/features/workspace/view.js +54 -3
  52. data/lib/clacky/web/i18n.js +73 -11
  53. data/lib/clacky/web/index.html +92 -37
  54. data/lib/clacky/web/sessions.js +550 -592
  55. data/lib/clacky/web/settings.js +163 -190
  56. data/lib/clacky.rb +3 -0
  57. metadata +8 -1
@@ -379,6 +379,19 @@ Clacky.ext = (() => {
379
379
  map.set(key, teardown);
380
380
  }
381
381
 
382
+ // Run and forget a single teardown by key. Used when a tab re-renders within
383
+ // the same session (e.g. an eager onAttach re-running) so its previous
384
+ // subscriptions are released before a new one is registered.
385
+ function _runViewTeardown(sessionId, key) {
386
+ if (!sessionId) return;
387
+ const map = _viewTeardowns.get(sessionId);
388
+ if (!map) return;
389
+ const fn = map.get(key);
390
+ if (typeof fn !== "function") return;
391
+ map.delete(key);
392
+ try { fn(); } catch (_e) { /* already guarded by caller */ }
393
+ }
394
+
382
395
  // Invoke a renderer's render function. Handles the (container, ctx, runtime)
383
396
  // signature used everywhere; if the render returns a function it is stored
384
397
  // as the view teardown, run before the next render or on session exit.
@@ -632,6 +645,31 @@ Clacky.ext = (() => {
632
645
  body.setAttribute("data-panel", id);
633
646
  bodyEls[id] = body;
634
647
  bodies.appendChild(body);
648
+
649
+ // Eager init: a tab can run setup (surface a badge, pre-fetch data,
650
+ // start a subscription) before the user ever opens it. onAttach runs
651
+ // immediately with a ctx carrying sessionId + setBadge; returning a
652
+ // function registers it as a teardown run on the next render pass /
653
+ // session switch. Any tab may opt in — no per-extension wiring needed.
654
+ if (typeof entry.tab.onAttach === "function") {
655
+ const attachKey = _mountKey(slot, entry) + "::attach";
656
+ // Re-running within the same session (rare, e.g. a host re-render)
657
+ // must release the previous teardown first to avoid leaks.
658
+ _runViewTeardown(renderCtx.sessionId, attachKey);
659
+ const attachCtx = Object.assign({}, renderCtx, {
660
+ setBadge: (n) => _setTabBadge(tabBtns[id], n),
661
+ });
662
+ let teardown;
663
+ try {
664
+ teardown = entry.tab.onAttach(attachCtx);
665
+ } catch (err) {
666
+ console.error(`[Clacky.ext] onAttach for tab "${id}" threw:`, err);
667
+ teardown = null;
668
+ }
669
+ if (typeof teardown === "function" && renderCtx.sessionId) {
670
+ _rememberViewTeardown(renderCtx.sessionId, attachKey, teardown);
671
+ }
672
+ }
635
673
  });
636
674
 
637
675
  container.appendChild(tabBar);
@@ -122,8 +122,8 @@ const BillingView = (() => {
122
122
 
123
123
  const allModels = BillingStore.state.allModels;
124
124
  const models = allModels.length > 0 ? allModels : (summary.by_model ? Object.keys(summary.by_model) : []);
125
- const modelOptions = [`<option value="all">${I18n.t("billing.allModels") || "All Models"}</option>`]
126
- .concat(models.filter(m => m).map(m => `<option value="${_esc(m)}" ${m === BillingStore.state.currentModel ? "selected" : ""}>${_esc(m)}</option>`))
125
+ const modelOptions = [`<div class="custom-select-option" data-value="all">${I18n.t("billing.allModels") || "All Models"}</div>`]
126
+ .concat(models.filter(m => m).map(m => `<div class="custom-select-option" data-value="${_esc(m)}">${_esc(m)}</div>`))
127
127
  .join("");
128
128
 
129
129
  container.innerHTML = `
@@ -135,7 +135,15 @@ const BillingView = (() => {
135
135
  </div>
136
136
  <div class="billing-controls">
137
137
  <div class="segmented-control">${periodBtns}</div>
138
- <select id="billing-model-filter" class="billing-model-filter">${modelOptions}</select>
138
+ <div class="custom-select-wrapper billing-model-filter" id="billing-model-filter">
139
+ <div class="custom-select-trigger" tabindex="0">
140
+ <span class="custom-select-value">${I18n.t("billing.allModels") || "All Models"}</span>
141
+ <svg class="custom-select-arrow" fill="none" stroke="currentColor" viewBox="0 0 24 24">
142
+ <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 9l-7 7-7-7"></path>
143
+ </svg>
144
+ </div>
145
+ <div class="custom-select-dropdown">${modelOptions}</div>
146
+ </div>
139
147
  <button id="billing-share-btn" class="billing-share-btn" title="${I18n.t('billing.share.tooltip') || 'Share scorecard'}">
140
148
  <svg width="14" height="14" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg"><path d="M4 12v8a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2v-8" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/><polyline points="16 6 12 2 8 6" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/><line x1="12" y1="2" x2="12" y2="15" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/></svg>
141
149
  ${I18n.t('billing.share.btn') || 'Share scorecard'}
@@ -207,9 +215,15 @@ const BillingView = (() => {
207
215
  btn.addEventListener("click", (e) => Billing.setPeriod(e.target.dataset.period));
208
216
  });
209
217
 
210
- document.getElementById("billing-model-filter")?.addEventListener("change", (e) => {
211
- Billing.setModel(e.target.value);
212
- });
218
+ const billingModelWrap = document.getElementById("billing-model-filter");
219
+ if (billingModelWrap && window.CustomSelect) {
220
+ const sel = window.CustomSelect.init({
221
+ trigger: billingModelWrap.querySelector(".custom-select-trigger"),
222
+ dropdown: billingModelWrap.querySelector(".custom-select-dropdown"),
223
+ onSelect: (value) => Billing.setModel(value)
224
+ });
225
+ sel.setValue(BillingStore.state.currentModel || "all");
226
+ }
213
227
 
214
228
  document.getElementById("billing-share-btn")?.addEventListener("click", _openScorecardShare);
215
229
 
@@ -587,18 +587,30 @@ const ExtensionsView = (() => {
587
587
  });
588
588
  }
589
589
 
590
- const sortSelect = $("extensions-sort");
591
- if (sortSelect) {
592
- sortSelect.value = ExtensionsStore.state.sort;
593
- sortSelect.addEventListener("change", () => Extensions.setSort(sortSelect.value));
590
+ const sortWrap = $("extensions-sort");
591
+ if (sortWrap && window.CustomSelect) {
592
+ const sortDropdown = sortWrap.querySelector(".custom-select-dropdown");
593
+ if (!sortDropdown.__customSelect) {
594
+ window.CustomSelect.init({
595
+ trigger: sortWrap.querySelector(".custom-select-trigger"),
596
+ dropdown: sortDropdown,
597
+ onSelect: (value) => Extensions.setSort(value)
598
+ });
599
+ }
600
+ sortDropdown.__customSelect.setValue(ExtensionsStore.state.sort);
594
601
  }
595
602
 
596
- const installedFilter = $("extensions-installed-filter");
597
- if (installedFilter) {
598
- installedFilter.value = ExtensionsStore.state.onlyInstalled ? "installed" : "all";
599
- installedFilter.addEventListener("change", () => {
600
- Extensions.setOnlyInstalled(installedFilter.value === "installed");
601
- });
603
+ const installedWrap = $("extensions-installed-filter");
604
+ if (installedWrap && window.CustomSelect) {
605
+ const installedDropdown = installedWrap.querySelector(".custom-select-dropdown");
606
+ if (!installedDropdown.__customSelect) {
607
+ window.CustomSelect.init({
608
+ trigger: installedWrap.querySelector(".custom-select-trigger"),
609
+ dropdown: installedDropdown,
610
+ onSelect: (value) => Extensions.setOnlyInstalled(value === "installed")
611
+ });
612
+ }
613
+ installedDropdown.__customSelect.setValue(ExtensionsStore.state.onlyInstalled ? "installed" : "all");
602
614
  }
603
615
  document.querySelectorAll(".extensions-filter-tab").forEach(btn => {
604
616
  btn.addEventListener("click", () => {
@@ -53,7 +53,9 @@ const NewSessionStore = (() => {
53
53
  const res = await fetch("/api/agents");
54
54
  if (!res.ok) return;
55
55
  const data = await res.json();
56
- _state.agents = (data && data.agents) || [];
56
+ // Hidden agents are still returned (so existing sessions can resolve
57
+ // their display names) but must not appear in the new-session picker.
58
+ _state.agents = ((data && data.agents) || []).filter((a) => !a.hidden);
57
59
 
58
60
  // Restore last-used agent from localStorage; fall back to the first
59
61
  // available one (typically "general").
@@ -206,6 +208,17 @@ const NewSessionStore = (() => {
206
208
  }
207
209
  }
208
210
 
211
+ async function loadAgentName() {
212
+ try {
213
+ const res = await fetch("/api/profile");
214
+ if (!res.ok) return "";
215
+ const data = await res.json();
216
+ return (data.soul && data.soul.name) || "";
217
+ } catch (e) {
218
+ return "";
219
+ }
220
+ }
221
+
209
222
  return {
210
223
  get state() { return _state; },
211
224
  on: _on,
@@ -218,6 +231,7 @@ const NewSessionStore = (() => {
218
231
  loadDefaultDirectory,
219
232
  createSession,
220
233
  fetchSkillsForAgent,
234
+ loadAgentName,
221
235
  reset,
222
236
  };
223
237
  })();
@@ -15,10 +15,33 @@ const NewSessionView = (() => {
15
15
  let _initialized = false;
16
16
  let _modelsLoaded = false;
17
17
 
18
+ let _agentName = "";
19
+ let _agentNamePromise = null;
20
+
18
21
  function _isZh() {
19
22
  return I18n.lang && I18n.lang().startsWith("zh");
20
23
  }
21
24
 
25
+ function _loadAgentName() {
26
+ if (_agentName) return Promise.resolve(_agentName);
27
+ if (_agentNamePromise) return _agentNamePromise;
28
+ _agentNamePromise = NewSessionStore.loadAgentName()
29
+ .then(name => {
30
+ _agentName = name || "";
31
+ return _agentName;
32
+ })
33
+ .finally(() => { _agentNamePromise = null; });
34
+ return _agentNamePromise;
35
+ }
36
+
37
+ function _renderTitle() {
38
+ const el = document.querySelector(".new-session-title");
39
+ if (!el) return;
40
+ el.textContent = _agentName
41
+ ? I18n.t("sessions.new.title.named", { name: _agentName })
42
+ : I18n.t("sessions.new.title");
43
+ }
44
+
22
45
  function _agentLabel(a) {
23
46
  if (_isZh() && a.title_zh) return a.title_zh;
24
47
  return a.title || a.id;
@@ -672,32 +695,15 @@ const NewSessionView = (() => {
672
695
  pop.appendChild(empty);
673
696
  return;
674
697
  }
675
- models.forEach((m) => {
676
- const isDefault = m.type === "default";
677
- const modelName = m.model || m.id || "";
678
-
679
- const item = document.createElement("button");
680
- item.type = "button";
681
- item.className = "ns-ctx-popover-item sib-model-option" + ((m.id || "") === currentId ? " active" : "");
682
- item.style.cssText = "display:flex;align-items:center;justify-content:space-between;gap:0.5rem;";
683
-
684
- const nameSpan = document.createElement("span");
685
- nameSpan.textContent = modelName;
686
- item.appendChild(nameSpan);
687
-
688
- if (isDefault) {
689
- const badge = document.createElement("span");
690
- badge.className = "model-badge default";
691
- badge.textContent = "default";
692
- item.appendChild(badge);
693
- }
694
698
 
695
- item.addEventListener("click", () => {
699
+ ModelPicker.populate(pop, {
700
+ models,
701
+ currentId,
702
+ onSelect: (m) => {
696
703
  NewSessionStore.updateAdvanced({ modelId: m.id || "" });
697
704
  _dismissCtxPopover();
698
705
  _renderContextBar();
699
- });
700
- pop.appendChild(item);
706
+ },
701
707
  });
702
708
  });
703
709
  }
@@ -853,6 +859,8 @@ const NewSessionView = (() => {
853
859
  if (_initialized) return;
854
860
  _initialized = true;
855
861
 
862
+ document.addEventListener("langchange", _renderTitle);
863
+
856
864
  NewSessionStore.on("newSession:agents-loaded", _renderAgents);
857
865
  NewSessionStore.on("newSession:selection-changed", () => {
858
866
  _renderAgents();
@@ -938,6 +946,7 @@ const NewSessionView = (() => {
938
946
 
939
947
  async function onPanelShow() {
940
948
  _bindOnce();
949
+ _loadAgentName().then(_renderTitle);
941
950
  await NewSessionStore.loadAgents();
942
951
  // Must run after loadAgents(): that call restores the remembered
943
952
  // projectId from localStorage, which decides whether the working dir is
@@ -52,26 +52,33 @@ const TrashView = (() => {
52
52
  // ── Project filter select helpers ────────────────────────────────────
53
53
 
54
54
  function _refreshProjectOptions() {
55
- const sel = $("trash-filter-project");
56
- if (!sel) return;
55
+ const wrap = $("trash-filter-project");
56
+ if (!wrap) return;
57
+ const dropdown = wrap.querySelector(".custom-select-dropdown");
58
+ if (!dropdown) return;
59
+ const ctl = dropdown.__customSelect;
60
+ const currentVal = ctl ? ctl.value : "";
57
61
 
58
62
  const projects = (typeof Projects !== "undefined") ? Projects.all() : [];
59
- const currentVal = sel.value;
60
-
61
- // Rebuild options: keep the "all" placeholder, then one per project
62
- sel.innerHTML = `<option value="">${I18n.t ? I18n.t("trash.filterAllProjects") : "所有项目"}</option>`;
63
+ const allLabel = I18n.t ? I18n.t("trash.filterAllProjects") : "所有项目";
64
+
65
+ dropdown.innerHTML = "";
66
+ const all = document.createElement("div");
67
+ all.className = "custom-select-option";
68
+ all.dataset.value = "";
69
+ all.textContent = allLabel;
70
+ dropdown.appendChild(all);
63
71
  projects.forEach(p => {
64
- const opt = document.createElement("option");
65
- opt.value = p.id;
72
+ const opt = document.createElement("div");
73
+ opt.className = "custom-select-option";
74
+ opt.dataset.value = p.id;
66
75
  opt.textContent = p.name || p.id;
67
- sel.appendChild(opt);
76
+ dropdown.appendChild(opt);
68
77
  });
69
78
 
70
- // Restore previous selection if still valid
71
- if (currentVal && projects.some(p => String(p.id) === currentVal)) {
72
- sel.value = currentVal;
73
- } else {
74
- sel.value = "";
79
+ if (ctl) {
80
+ const restore = (currentVal && projects.some(p => String(p.id) === currentVal)) ? currentVal : "";
81
+ ctl.setValue(restore);
75
82
  }
76
83
  }
77
84
 
@@ -464,11 +471,16 @@ const TrashView = (() => {
464
471
  });
465
472
 
466
473
  // Project filter select
467
- const projSel = $("trash-filter-project");
468
- if (projSel) {
469
- projSel.addEventListener("change", () => {
470
- Trash.setFilterProject(projSel.value || null);
471
- });
474
+ const projWrap = $("trash-filter-project");
475
+ if (projWrap && window.CustomSelect) {
476
+ const dropdown = projWrap.querySelector(".custom-select-dropdown");
477
+ if (!dropdown.__customSelect) {
478
+ window.CustomSelect.init({
479
+ trigger: projWrap.querySelector(".custom-select-trigger"),
480
+ dropdown: dropdown,
481
+ onSelect: (value) => Trash.setFilterProject(value || null)
482
+ });
483
+ }
472
484
  }
473
485
  }
474
486
 
@@ -110,6 +110,7 @@ const WorkspaceStore = (() => {
110
110
  body: JSON.stringify({ path: _absPath(entry.path), action: "save", content })
111
111
  });
112
112
  if (!resp.ok) throw new Error(`HTTP ${resp.status}`);
113
+ _emit("workspace:fileSaved", { sessionId: _sessionId, path: entry.path, name: entry.name });
113
114
  },
114
115
  };
115
116
 
@@ -740,6 +740,53 @@ const WorkspaceView = (() => {
740
740
  return { root, openTab, destroy };
741
741
  }
742
742
 
743
+ // Draggable split between the file tree and the viewer. The tree width is a
744
+ // CSS variable so a drag just rewrites it; the handle only shows once a file
745
+ // is open (mirrors the has-open-files class on .wv-root). Width persists.
746
+ const TREE_MIN_W = 168;
747
+ const TREE_MAX_W = 460;
748
+ const TREE_WIDTH_KEY = "clacky.workspace.treeWidth";
749
+
750
+ function initTreeResize(treePane, handle) {
751
+ try {
752
+ const saved = parseFloat(localStorage.getItem(TREE_WIDTH_KEY));
753
+ if (saved >= TREE_MIN_W && saved <= TREE_MAX_W) {
754
+ treePane.style.setProperty("--wv-tree-width", saved + "px");
755
+ }
756
+ } catch (_e) { /* ignore */ }
757
+
758
+ let dragging = false;
759
+ let startX = 0;
760
+ let startW = 0;
761
+
762
+ const onMove = (e) => {
763
+ if (!dragging) return;
764
+ const w = Math.min(TREE_MAX_W, Math.max(TREE_MIN_W, startW + (e.clientX - startX)));
765
+ treePane.style.setProperty("--wv-tree-width", w + "px");
766
+ };
767
+ const onUp = () => {
768
+ if (!dragging) return;
769
+ dragging = false;
770
+ document.body.style.cursor = "";
771
+ document.body.style.userSelect = "";
772
+ document.removeEventListener("mousemove", onMove);
773
+ document.removeEventListener("mouseup", onUp);
774
+ const w = treePane.getBoundingClientRect().width;
775
+ try { localStorage.setItem(TREE_WIDTH_KEY, String(Math.round(w))); } catch (_e) { /* ignore */ }
776
+ };
777
+
778
+ handle.addEventListener("mousedown", (e) => {
779
+ e.preventDefault();
780
+ dragging = true;
781
+ startX = e.clientX;
782
+ startW = treePane.getBoundingClientRect().width;
783
+ document.body.style.cursor = "col-resize";
784
+ document.body.style.userSelect = "none";
785
+ document.addEventListener("mousemove", onMove);
786
+ document.addEventListener("mouseup", onUp);
787
+ });
788
+ }
789
+
743
790
  // Build the Files tab body for the current session: file tree on the left,
744
791
  // tabbed viewer on the right. Mutates `container` and returns a destroy()
745
792
  // that frees CodeMirror instances and blob URLs when the tab is re-rendered
@@ -914,7 +961,11 @@ const WorkspaceView = (() => {
914
961
  treePane.appendChild(tree);
915
962
  treePane.appendChild(searchList);
916
963
 
964
+ const resizeHandle = el("div", "wv-tree-resize");
965
+ initTreeResize(treePane, resizeHandle);
966
+
917
967
  root.appendChild(treePane);
968
+ root.appendChild(resizeHandle);
918
969
  root.appendChild(viewer.root);
919
970
  container.appendChild(root);
920
971
 
@@ -934,14 +985,14 @@ const WorkspaceView = (() => {
934
985
  return { mount };
935
986
  })();
936
987
 
937
- // Files is a built-in tab: visible for every session, after the agent-scoped
938
- // panels (git/time-machine use orders 10/20).
988
+ // Files is a built-in tab: visible for every session, after ext-studio
989
+ // publish (20) and before git (30) / time-machine (40).
939
990
  if (window.Clacky && Clacky.ext) {
940
991
  Clacky.ext.ui.mountBuiltin("session.aside", (container, ctx) => {
941
992
  const viewer = WorkspaceView.mount(container, ctx);
942
993
  return () => viewer.destroy();
943
994
  }, {
944
- order: 40,
995
+ order: 25,
945
996
  tab: { id: "files", label: () => (typeof I18n !== "undefined" ? I18n.t("workspace.title") : "Files") },
946
997
  });
947
998
  }
@@ -66,6 +66,7 @@ const I18n = (() => {
66
66
 
67
67
  // ── Welcome screen ──
68
68
  "sessions.new.title": "Start a new session",
69
+ "sessions.new.title.named": "Hi, {{name}}. Ready to get started?",
69
70
  "sessions.new.subtitle": "Pick an agent to chat with, then tell it what you'd like to do.",
70
71
  "sessions.new.placeholder": "Type your first message to {{agent}}…",
71
72
  "sessions.new.moreOptions": "More options",
@@ -119,8 +120,9 @@ const I18n = (() => {
119
120
  "chat.send": "Send",
120
121
  "chat.edit.warn": "Editing this message will discard the conversation history, file snapshots, and token costs after it. Continue?",
121
122
  "chat.edit.warnSkip": "Don't show this again",
122
- "chat.empty.title": "How can I help you today?",
123
- "chat.empty.subtitle": "Ask a question, or start with a skill.",
123
+ "chat.empty.title": "How can I help you today?",
124
+ "chat.empty.title.named": "Hi, {{name}}. Ready to get started?",
125
+ "chat.empty.subtitle": "Ask a question, or start with a skill.",
124
126
  "chat.empty.tip1": "Research, write, and translate",
125
127
  "chat.empty.tip2": "Summarize and organize information",
126
128
  "chat.empty.tip3": "Upload an image or file for me to analyze",
@@ -155,6 +157,12 @@ const I18n = (() => {
155
157
  "sib.dir.default": "Default workspace",
156
158
  "sib.dir.current": "Current workspace",
157
159
  "sib.dir.inputPlaceholder": "Type or select a directory path",
160
+ "sib.dir.back": "Back",
161
+ "sib.dir.forward": "Forward",
162
+ "sib.dir.refresh": "Refresh",
163
+ "sib.dir.addFavorite": "Add to favorites",
164
+ "sib.dir.removeFavorite": "Remove from favorites",
165
+ "sib.dir.userFavorites": "My favorites",
158
166
  "sib.dir.loading": "Loading…",
159
167
  "sib.dir.empty": "Empty directory",
160
168
  "sib.dir.loadError": "Failed to load",
@@ -169,6 +177,8 @@ const I18n = (() => {
169
177
  "sib.dir.places.desktop": "Desktop",
170
178
  "sib.dir.places.downloads": "Downloads",
171
179
  "sib.dir.places.documents": "Documents",
180
+ "sib.dir.drives": "Drives",
181
+ "sib.dir.places.drive": "{{letter}}:",
172
182
  "workspace.title": "Files",
173
183
  "workspace.expand": "Open workspace",
174
184
  "workspace.collapse": "Collapse workspace",
@@ -211,9 +221,23 @@ const I18n = (() => {
211
221
  "changes.save.prefix": "Manual snapshot",
212
222
  "changes.save.btn": "Save snapshot",
213
223
  "changes.save.saving": "Saving…",
214
- "changes.save.done": "Saved — restore it from History",
224
+ "changes.save.done": "Saved",
215
225
  "changes.save.failed": "Save failed",
216
- "tm.tab": "History",
226
+ "changes.select.all": "All",
227
+ "changes.select.count": "{{n}}/{{total}} selected",
228
+ "changes.save.btnN": "Save {{n}} selected file(s)",
229
+ "changes.save.promptMsg": "Describe this snapshot",
230
+ "changes.diff.loading": "Loading diff…",
231
+ "changes.diff.fail": "Failed to load diff",
232
+ "changes.diff.same": "No content change in this file.",
233
+ "changes.diff.binary": "Binary file - line diff not available.",
234
+ "changes.diff.truncated": "Diff too long - showing first {{n}} lines",
235
+ "changes.restore.title": "Revert this file's changes",
236
+ "changes.restore.confirm": "Discard?",
237
+ "changes.restore.diffBtn": "Discard changes",
238
+ "changes.restore.doing": "Reverting…",
239
+ "changes.restore.done": "Reverted: {{path}}",
240
+ "changes.restore.failed": "Revert failed", "tm.tab": "History",
217
241
  "sib.model.tooltip": "Click to switch model",
218
242
  "sib.model.tooltip.busy": "Model switching is disabled while the agent is responding",
219
243
  "sib.variant.header": "Quick switch",
@@ -253,6 +277,8 @@ const I18n = (() => {
253
277
  "sessions.badge.channel": "Channel",
254
278
  "sessions.badge.coding": "Coding",
255
279
  "sessions.badge.setup": "Setup",
280
+ "sessions.setup.name": "Onboard",
281
+ "sessions.setup.browser.name": "🌐 Browser Setup",
256
282
  "sessions.newSession": "New Session",
257
283
  "sessions.advancedOptions": "Advanced options",
258
284
  "sessions.actions.pin": "Pin",
@@ -763,6 +789,7 @@ const I18n = (() => {
763
789
  "settings.models.apiFormat.auto": "Auto (detect from provider & model)",
764
790
  "settings.models.apiFormat.anthropicMessages": "Anthropic Messages API",
765
791
  "settings.models.apiFormat.openaiCompletions": "OpenAI Chat Completions",
792
+ "settings.models.apiFormat.openaiResponses": "OpenAI Responses API",
766
793
  "settings.models.field.getApiKey": "How to get →",
767
794
  "settings.models.field.docsGuide.question": "New to AI keys?",
768
795
  "settings.models.field.docsGuide.cta": "See the guide →",
@@ -969,7 +996,7 @@ const I18n = (() => {
969
996
  "onboard.device.success.credit.label": "Free trial credit:",
970
997
  "onboard.device.success.credit.value": "$1.00 (one-time, on us)",
971
998
  "onboard.device.success.model.label": "Trial model:",
972
- "onboard.device.success.note": "During the trial only Gemini 3.5 Flash is available — top up any amount to unlock all frontier models on this same key.",
999
+ "onboard.device.success.note": "During the trial only Gemini 3.8 Flash is available — top up any amount to unlock all frontier models on this same key.",
973
1000
  "onboard.device.success.btn": "Start using Clacky →",
974
1001
  "onboard.manual.toggle": "Configure manually with your own API key",
975
1002
  "onboard.provider.custom": "Custom",
@@ -1212,6 +1239,7 @@ const I18n = (() => {
1212
1239
 
1213
1240
  // ── Welcome screen ──
1214
1241
  "sessions.new.title": "开始新会话",
1242
+ "sessions.new.title.named": "你好,{{name}}。准备好开始了吗?",
1215
1243
  "sessions.new.subtitle": "选一个想聊的 Agent,说说你想做什么。",
1216
1244
  "sessions.new.placeholder": "输入第一条消息发给 {{agent}}…",
1217
1245
  "sessions.new.moreOptions": "更多选项",
@@ -1263,8 +1291,9 @@ const I18n = (() => {
1263
1291
  "chat.send": "发送",
1264
1292
  "chat.edit.warn": "编辑此消息将丢弃后续对话历史、文件快照及已产生的 token 费用,是否继续?",
1265
1293
  "chat.edit.warnSkip": "不再提示",
1266
- "chat.empty.title": "有什么可以帮你?",
1267
- "chat.empty.subtitle": "直接提问,或用一个技能开始。",
1294
+ "chat.empty.title": "有什么可以帮你?",
1295
+ "chat.empty.title.named": "你好,{{name}}。准备好开始了吗?",
1296
+ "chat.empty.subtitle": "直接提问,或用一个技能开始。",
1268
1297
  "chat.empty.tip1": "查资料、写文案、做翻译",
1269
1298
  "chat.empty.tip2": "归纳总结、整理信息",
1270
1299
  "chat.empty.tip3": "上传图片或文件,让我帮你分析",
@@ -1299,6 +1328,12 @@ const I18n = (() => {
1299
1328
  "sib.dir.default": "默认工作目录",
1300
1329
  "sib.dir.current": "当前工作目录",
1301
1330
  "sib.dir.inputPlaceholder": "输入或选择目录路径",
1331
+ "sib.dir.back": "后退",
1332
+ "sib.dir.forward": "前进",
1333
+ "sib.dir.refresh": "刷新",
1334
+ "sib.dir.addFavorite": "添加到收藏",
1335
+ "sib.dir.removeFavorite": "从收藏中移除",
1336
+ "sib.dir.userFavorites": "我的收藏",
1302
1337
  "sib.dir.loading": "加载中…",
1303
1338
  "sib.dir.empty": "空目录",
1304
1339
  "sib.dir.loadError": "加载失败",
@@ -1313,6 +1348,8 @@ const I18n = (() => {
1313
1348
  "sib.dir.places.desktop": "桌面",
1314
1349
  "sib.dir.places.downloads": "下载",
1315
1350
  "sib.dir.places.documents": "文稿",
1351
+ "sib.dir.drives": "驱动器",
1352
+ "sib.dir.places.drive": "{{letter}}盘",
1316
1353
  "workspace.title": "文件",
1317
1354
  "workspace.expand": "打开工作区",
1318
1355
  "workspace.collapse": "收起工作区",
@@ -1355,8 +1392,23 @@ const I18n = (() => {
1355
1392
  "changes.save.prefix": "手动存档",
1356
1393
  "changes.save.btn": "存档当前版本",
1357
1394
  "changes.save.saving": "正在存档…",
1358
- "changes.save.done": "已存档,可在「时光机」里回到这个版本",
1395
+ "changes.save.done": "已存档",
1359
1396
  "changes.save.failed": "存档失败",
1397
+ "changes.select.all": "全选",
1398
+ "changes.select.count": "已选 {{n}}/{{total}}",
1399
+ "changes.save.btnN": "存档选中的 {{n}} 个文件",
1400
+ "changes.save.promptMsg": "给这个版本写句说明",
1401
+ "changes.diff.loading": "正在读取差异…",
1402
+ "changes.diff.fail": "读取差异失败",
1403
+ "changes.diff.same": "这个文件的内容没有变化。",
1404
+ "changes.diff.binary": "二进制文件,无法逐行对比。",
1405
+ "changes.diff.truncated": "差异过大,仅显示前 {{n}} 行",
1406
+ "changes.restore.title": "撤销此文件的改动",
1407
+ "changes.restore.confirm": "确认丢弃?",
1408
+ "changes.restore.diffBtn": "撤销改动",
1409
+ "changes.restore.doing": "正在撤销…",
1410
+ "changes.restore.done": "已撤销:{{path}}",
1411
+ "changes.restore.failed": "撤销失败",
1360
1412
  "tm.tab": "时光机",
1361
1413
  "sib.model.tooltip": "点击切换模型",
1362
1414
  "sib.model.tooltip.busy": "Agent 回复中,暂时无法切换模型",
@@ -1381,7 +1433,7 @@ const I18n = (() => {
1381
1433
  "sib.reasoning.medium": "中",
1382
1434
  "sib.reasoning.high": "高",
1383
1435
  "sib.reasoning.xhigh": "超高",
1384
- "sib.reasoning.max": "Max",
1436
+ "sib.reasoning.max": "极高",
1385
1437
  "sessions.thinking": "思考中…",
1386
1438
  "sessions.default_name": "会话 {{n}}",
1387
1439
  "sessions.group.cron": "定时任务",
@@ -1397,6 +1449,8 @@ const I18n = (() => {
1397
1449
  "sessions.badge.channel": "频道",
1398
1450
  "sessions.badge.coding": "Coding",
1399
1451
  "sessions.badge.setup": "配置",
1452
+ "sessions.setup.name": "个性化引导",
1453
+ "sessions.setup.browser.name": "🌐 浏览器配置",
1400
1454
  "sessions.newSession": "新会话",
1401
1455
  "sessions.advancedOptions": "高级选项",
1402
1456
  "sessions.loadMore": "加载更多会话",
@@ -1913,6 +1967,7 @@ const I18n = (() => {
1913
1967
  "settings.models.apiFormat.auto": "自动(根据服务商和模型检测)",
1914
1968
  "settings.models.apiFormat.anthropicMessages": "Anthropic Messages API",
1915
1969
  "settings.models.apiFormat.openaiCompletions": "OpenAI Chat Completions",
1970
+ "settings.models.apiFormat.openaiResponses": "OpenAI Responses API",
1916
1971
  "settings.models.field.getApiKey": "如何获取 →",
1917
1972
  "settings.models.field.docsGuide.question": "不会配置 AI 模型?",
1918
1973
  "settings.models.field.docsGuide.cta": "查看指南 →",
@@ -2119,7 +2174,7 @@ const I18n = (() => {
2119
2174
  "onboard.device.success.credit.label": "免费试用额度:",
2120
2175
  "onboard.device.success.credit.value": "$1.00(一次性赠送)",
2121
2176
  "onboard.device.success.model.label": "试用模型:",
2122
- "onboard.device.success.note": "试用期内仅可使用 Gemini 3.5 Flash —— 任意金额充值后,同一 Key 立即解锁全部前沿模型。",
2177
+ "onboard.device.success.note": "试用期内仅可使用 Gemini 3.8 Flash —— 任意金额充值后,同一 Key 立即解锁全部前沿模型。",
2123
2178
  "onboard.device.success.btn": "开始使用",
2124
2179
  "onboard.manual.toggle": "使用自己的 API Key 手动配置",
2125
2180
  "onboard.provider.custom": "自定义",
@@ -2311,7 +2366,14 @@ const I18n = (() => {
2311
2366
  }
2312
2367
  };
2313
2368
  // ── State ──────────────────────────────────────────────────────────────────
2314
- let _lang = localStorage.getItem(STORAGE_KEY) || DEFAULT_LANG;
2369
+ // On first visit (no saved preference), match the OS/browser language.
2370
+ function _detectLang() {
2371
+ const saved = localStorage.getItem(STORAGE_KEY);
2372
+ if (saved) return TRANSLATIONS[saved] ? saved : DEFAULT_LANG;
2373
+ const nav = (navigator.language || "").toLowerCase();
2374
+ return nav.startsWith("zh") ? "zh" : DEFAULT_LANG;
2375
+ }
2376
+ let _lang = _detectLang();
2315
2377
 
2316
2378
  // ── Core functions ─────────────────────────────────────────────────────────
2317
2379