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
@@ -14,6 +14,8 @@
14
14
 
15
15
  const Composer = (() => {
16
16
  const CHIP_CLASS = "mention-chip";
17
+ const ELEMENT_NODE = 1;
18
+ const TEXT_NODE = 3;
17
19
 
18
20
  // Lucide-style 24×24 stroke paths for the chip type icons (matches the
19
21
  // inline SVGs used across the UI).
@@ -48,7 +50,7 @@ const Composer = (() => {
48
50
  }
49
51
 
50
52
  function _isChip(node) {
51
- return !!node && node.nodeType === 1 && node.classList && node.classList.contains(CHIP_CLASS);
53
+ return !!node && node.nodeType === ELEMENT_NODE && node.classList && node.classList.contains(CHIP_CLASS);
52
54
  }
53
55
 
54
56
  // Plain text of the composer, excluding chips. Newlines are reconstructed
@@ -57,11 +59,11 @@ const Composer = (() => {
57
59
  let out = "";
58
60
  const walk = (node) => {
59
61
  for (const n of Array.from(node.childNodes)) {
60
- if (n.nodeType === 3) {
62
+ if (n.nodeType === TEXT_NODE) {
61
63
  out += n.nodeValue;
62
64
  continue;
63
65
  }
64
- if (n.nodeType !== 1) continue;
66
+ if (n.nodeType !== ELEMENT_NODE) continue;
65
67
  if (_isChip(n)) continue;
66
68
  switch (n.tagName) {
67
69
  case "BR":
@@ -175,6 +177,14 @@ const Composer = (() => {
175
177
  range.insertNode(chipEl);
176
178
  chipEl.parentNode.insertBefore(space, chipEl.nextSibling);
177
179
 
180
+ // A leading zero-width guard keeps the caret from sitting on the bare
181
+ // block boundary before an atomic chip, where browsers insert a <br>/<div>
182
+ // on input/delete and push the chip onto its own line.
183
+ const prev = chipEl.previousSibling;
184
+ if (!prev || prev.nodeType !== TEXT_NODE) {
185
+ chipEl.parentNode.insertBefore(document.createTextNode("\u200B"), chipEl);
186
+ }
187
+
178
188
  const after = document.createRange();
179
189
  after.setStartAfter(space);
180
190
  after.collapse(true);
@@ -184,15 +194,42 @@ const Composer = (() => {
184
194
  _emitInput(el);
185
195
  }
186
196
 
187
- // Remove a chip DOM node and its trailing separator whitespace.
197
+ // Remove a chip DOM node and its surrounding zero-width guards + separator whitespace.
188
198
  function removeChip(chipEl) {
199
+ const prev = chipEl.previousSibling;
200
+ if (prev && prev.nodeType === TEXT_NODE && /^\u200B+$/.test(prev.nodeValue)) {
201
+ prev.remove();
202
+ }
189
203
  const next = chipEl.nextSibling;
190
- if (next && next.nodeType === 3 && /^[\s\u00A0\u200B]*$/.test(next.nodeValue)) {
204
+ if (next && next.nodeType === TEXT_NODE && /^[\s\u00A0\u200B]*$/.test(next.nodeValue)) {
191
205
  next.remove();
192
206
  }
193
207
  chipEl.remove();
194
208
  }
195
209
 
210
+ // Re-establish a text anchor before each chip. Editing can delete the
211
+ // leading zero-width guard (when the caret sits inside it), after which the
212
+ // browser drops a <br>/<div> line anchor before the chip and wraps it onto
213
+ // its own line. Runs on every input event.
214
+ function _ensureChipGuards(el) {
215
+ for (const chip of Array.from(el.querySelectorAll("." + CHIP_CLASS))) {
216
+ let prev = chip.previousSibling;
217
+ // A stray <br> that has no visible text before it is the browser's line
218
+ // anchor, not a user Shift+Enter break — drop it so the chip rejoins the
219
+ // current line.
220
+ if (prev && prev.nodeType === ELEMENT_NODE && prev.tagName === "BR") {
221
+ const beforeBr = prev.previousSibling;
222
+ const noTextBefore = !beforeBr ||
223
+ (beforeBr.nodeType === TEXT_NODE && /^[\u200B]*$/.test(beforeBr.nodeValue));
224
+ if (noTextBefore) prev.remove();
225
+ }
226
+ const before = chip.previousSibling;
227
+ if (!before || before.nodeType !== TEXT_NODE || before.nodeValue === "") {
228
+ chip.parentNode.insertBefore(document.createTextNode("\u200B"), chip);
229
+ }
230
+ }
231
+ }
232
+
196
233
  // Backspace/Delete at a chip boundary removes the chip atomically.
197
234
  function _handleKeydown(el, e) {
198
235
  if (e.key !== "Backspace" && e.key !== "Delete") return false;
@@ -204,13 +241,13 @@ const Composer = (() => {
204
241
 
205
242
  let target = null;
206
243
  if (e.key === "Backspace") {
207
- if (container.nodeType === 3) {
244
+ if (container.nodeType === TEXT_NODE) {
208
245
  if (offset === 0) target = container.previousSibling;
209
246
  } else if (container === el) {
210
247
  target = container.childNodes[offset - 1];
211
248
  }
212
249
  } else {
213
- if (container.nodeType === 3) {
250
+ if (container.nodeType === TEXT_NODE) {
214
251
  if (offset === container.nodeValue.length) target = container.nextSibling;
215
252
  } else if (container === el) {
216
253
  target = container.childNodes[offset];
@@ -262,6 +299,7 @@ const Composer = (() => {
262
299
  // Browsers leave a stray <br> after deleting all text, which breaks the
263
300
  // :empty::before placeholder. Clear it so the placeholder shows again.
264
301
  el.addEventListener("input", () => {
302
+ _ensureChipGuards(el);
265
303
  if (text(el).trim() === "" && !el.querySelector("." + CHIP_CLASS) && el.innerHTML !== "") {
266
304
  el.innerHTML = "";
267
305
  }
@@ -0,0 +1,136 @@
1
+ // Generic single-choice dropdown shared by settings/onboard.
2
+ // DOM contract:
3
+ // wrapper > .custom-select-trigger > .custom-select-value (+ .custom-select-arrow)
4
+ // + .custom-select-dropdown > .custom-select-option[data-value][data-label]
5
+ // Business code owns the option markup (populate); this module owns the
6
+ // open/close/selection mechanics so every dropdown behaves identically.
7
+ window.CustomSelect = (function () {
8
+ "use strict";
9
+
10
+ const instances = [];
11
+ let globalBound = false;
12
+
13
+ function bindGlobalClose() {
14
+ if (globalBound) return;
15
+ globalBound = true;
16
+ document.addEventListener("click", () => {
17
+ for (let i = instances.length - 1; i >= 0; i--) {
18
+ const inst = instances[i];
19
+ if (!inst._trigger.isConnected) { instances.splice(i, 1); continue; }
20
+ inst.close();
21
+ }
22
+ });
23
+ }
24
+
25
+ function positionFixed(dropdown, anchor) {
26
+ const rect = anchor.getBoundingClientRect();
27
+ dropdown.style.position = "fixed";
28
+ dropdown.style.top = (rect.bottom + 4) + "px";
29
+ dropdown.style.left = rect.left + "px";
30
+ // min-width:100% resolves against the viewport once position:fixed,
31
+ // so pin it to the anchor width; leave width to the CSS max-content
32
+ // rule so narrow triggers (search type filter) still fit their options.
33
+ dropdown.style.minWidth = rect.width + "px";
34
+ dropdown.style.right = "auto";
35
+ dropdown.style.zIndex = "9999";
36
+ }
37
+
38
+ function resetPosition(dropdown) {
39
+ dropdown.style.position = "";
40
+ dropdown.style.top = "";
41
+ dropdown.style.left = "";
42
+ dropdown.style.width = "";
43
+ dropdown.style.minWidth = "";
44
+ dropdown.style.right = "";
45
+ dropdown.style.zIndex = "";
46
+ }
47
+
48
+ function init(opts) {
49
+ const trigger = opts.trigger;
50
+ const dropdown = opts.dropdown;
51
+ const valueSpan = trigger.querySelector(".custom-select-value");
52
+ const home = dropdown.parentElement;
53
+ const anchor = opts.anchor || home;
54
+ const placeholderText = valueSpan ? valueSpan.textContent.trim() : "";
55
+ const selectedOption = dropdown.querySelector(".custom-select-option.selected");
56
+
57
+ const instance = {
58
+ _trigger: trigger,
59
+ value: selectedOption ? (selectedOption.dataset.value || "") : "",
60
+ open: function () {
61
+ instances.forEach(function (other) {
62
+ if (other !== instance) other.close();
63
+ });
64
+ if (opts.portal) {
65
+ positionFixed(dropdown, anchor);
66
+ document.body.appendChild(dropdown);
67
+ }
68
+ dropdown.classList.add("open");
69
+ trigger.classList.add("open");
70
+ },
71
+ close: function () {
72
+ if (!dropdown.classList.contains("open")) return;
73
+ dropdown.classList.remove("open");
74
+ trigger.classList.remove("open");
75
+ if (opts.portal && home) {
76
+ resetPosition(dropdown);
77
+ home.appendChild(dropdown);
78
+ }
79
+ },
80
+ setValue: function (value, text) {
81
+ if (valueSpan) {
82
+ let label = text;
83
+ if (typeof label !== "string") {
84
+ const opt = dropdown.querySelector('.custom-select-option[data-value="' + value + '"]');
85
+ label = opt ? (opt.dataset.label || opt.textContent.trim()) : "";
86
+ }
87
+ if (!value && !label) label = placeholderText;
88
+ valueSpan.textContent = label;
89
+ valueSpan.classList.toggle("placeholder", !value);
90
+ }
91
+ dropdown.querySelectorAll(".custom-select-option").forEach(function (opt) {
92
+ opt.classList.toggle("selected", opt.dataset.value === value);
93
+ });
94
+ instance.value = value;
95
+ }
96
+ };
97
+
98
+ trigger.addEventListener("click", function (e) {
99
+ e.stopPropagation();
100
+ if (dropdown.classList.contains("open")) {
101
+ instance.close();
102
+ } else {
103
+ instance.open();
104
+ }
105
+ });
106
+
107
+ trigger.addEventListener("keydown", function (e) {
108
+ if (e.key !== "Enter" && e.key !== " ") return;
109
+ e.preventDefault();
110
+ e.stopPropagation();
111
+ if (dropdown.classList.contains("open")) {
112
+ instance.close();
113
+ } else {
114
+ instance.open();
115
+ }
116
+ });
117
+
118
+ dropdown.addEventListener("click", function (e) {
119
+ const option = e.target.closest(".custom-select-option");
120
+ if (!option) return;
121
+ e.stopPropagation();
122
+ const value = option.dataset.value || "";
123
+ const text = option.dataset.label || option.textContent.trim();
124
+ instance.setValue(value, text);
125
+ instance.close();
126
+ if (typeof opts.onSelect === "function") opts.onSelect(value, text, option);
127
+ });
128
+
129
+ instances.push(instance);
130
+ dropdown.__customSelect = instance;
131
+ bindGlobalClose();
132
+ return instance;
133
+ }
134
+
135
+ return { init: init };
136
+ })();
@@ -404,6 +404,49 @@ const MentionAC = (() => {
404
404
  el.dispatchEvent(new Event("input", { bubbles: true }));
405
405
  }
406
406
 
407
+ // Promote an "@" already committed by the browser into the tracked marker.
408
+ // Windows IMEs may suppress the printable keydown (or expose it as Process),
409
+ // so the input/composition events are the only reliable character boundary.
410
+ function _adoptCommittedAtMarker() {
411
+ const el = _inputEl();
412
+ const sel = window.getSelection();
413
+ if (!el || !sel || !sel.rangeCount || !sel.isCollapsed) return false;
414
+
415
+ const caret = sel.getRangeAt(0);
416
+ if (!el.contains(caret.startContainer)) return false;
417
+
418
+ let node = caret.startContainer;
419
+ let offset = caret.startOffset;
420
+ if (node.nodeType !== Node.TEXT_NODE) {
421
+ if (node.nodeType !== Node.ELEMENT_NODE || offset === 0) return false;
422
+ const previousChildIndex = offset - 1;
423
+ node = node.childNodes[previousChildIndex];
424
+ while (node && node.lastChild) node = node.lastChild;
425
+ if (!node || node.nodeType !== Node.TEXT_NODE) return false;
426
+ offset = node.nodeValue.length;
427
+ }
428
+ const atOffset = offset - 1;
429
+ if (atOffset < 0 || node.nodeValue[atOffset] !== "@") return false;
430
+
431
+ const at = document.createRange();
432
+ at.setStart(node, atOffset);
433
+ at.setEnd(node, offset);
434
+ at.deleteContents();
435
+
436
+ const marker = document.createElement("span");
437
+ marker.className = "mention-at-marker";
438
+ marker.textContent = "@";
439
+ at.insertNode(marker);
440
+
441
+ const after = document.createRange();
442
+ after.setStartAfter(marker);
443
+ after.collapse(true);
444
+ sel.removeAllRanges();
445
+ sel.addRange(after);
446
+ _atNode = marker;
447
+ return true;
448
+ }
449
+
407
450
  // Cancel path: turn the tracked "@" marker back into plain text so the user
408
451
  // keeps the character they typed.
409
452
  function _unwrapAtMarker() {
@@ -569,14 +612,27 @@ const MentionAC = (() => {
569
612
  }
570
613
  });
571
614
 
572
- // If the user deletes the "@" while the menu is open, close the menu.
573
- el.addEventListener("input", () => {
615
+ // Keydown is not reliable while a Windows IME is active. Once the browser
616
+ // commits a literal "@", adopt it into the existing marker state machine.
617
+ // Composing input waits for compositionend so we never rewrite active IME
618
+ // text; non-composing input can be handled immediately.
619
+ el.addEventListener("input", (e) => {
620
+ _cfg = cfg;
621
+ if (!_visible && !e.isComposing && e.data === "@" && _adoptCommittedAtMarker()) {
622
+ open("root");
623
+ return;
624
+ }
574
625
  if (_visible && _atNode && !el.contains(_atNode)) {
575
626
  _atNode = null;
576
627
  hide();
577
628
  }
578
629
  });
579
630
 
631
+ el.addEventListener("compositionend", (e) => {
632
+ _cfg = cfg;
633
+ if (!_visible && e.data === "@" && _adoptCommittedAtMarker()) open("root");
634
+ });
635
+
580
636
  // Optional @ button removed — @ is opened via the keyboard only.
581
637
 
582
638
  return {