openclacky 1.5.13 → 1.5.14

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 (66) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +51 -0
  3. data/lib/clacky/agent/chunk_index.rb +83 -0
  4. data/lib/clacky/agent/history_navigation.rb +239 -0
  5. data/lib/clacky/agent/session_serializer.rb +38 -97
  6. data/lib/clacky/agent.rb +281 -276
  7. data/lib/clacky/agent_config.rb +1 -1
  8. data/lib/clacky/billing/billing_store.rb +2 -2
  9. data/lib/clacky/billing/platform_billing.rb +6 -0
  10. data/lib/clacky/brand_config.rb +13 -2
  11. data/lib/clacky/cli.rb +27 -0
  12. data/lib/clacky/client.rb +2 -2
  13. data/lib/clacky/default_extensions/ext-studio/agents/ext-developer/system_prompt.md +70 -134
  14. data/lib/clacky/default_extensions/ext-studio/panels/studio/view.js +91 -5
  15. data/lib/clacky/default_extensions/ext-studio/skills/ext-develop/SKILL.md +179 -577
  16. data/lib/clacky/default_extensions/git/panels/git/view.js +34 -14
  17. data/lib/clacky/default_extensions/preview/ext.yml +20 -0
  18. data/lib/clacky/default_extensions/preview/panels/preview/view.js +475 -0
  19. data/lib/clacky/default_extensions/time_machine/panels/time_machine/view.js +1 -10
  20. data/lib/clacky/extension/api_extension.rb +1 -1
  21. data/lib/clacky/extension/verifier.rb +1 -1
  22. data/lib/clacky/media/openai_compat.rb +70 -30
  23. data/lib/clacky/message_format/bedrock.rb +6 -1
  24. data/lib/clacky/prompts/base.md +1 -1
  25. data/lib/clacky/providers.rb +38 -3
  26. data/lib/clacky/rich_ui/rich_ui_controller.rb +1 -1
  27. data/lib/clacky/search_config.rb +3 -3
  28. data/lib/clacky/server/channel/channel_manager.rb +8 -5
  29. data/lib/clacky/server/git_panel.rb +10 -2
  30. data/lib/clacky/server/http_server.rb +161 -14
  31. data/lib/clacky/server/preview.rb +351 -0
  32. data/lib/clacky/server/web_ui_controller.rb +1 -1
  33. data/lib/clacky/skill.rb +48 -0
  34. data/lib/clacky/tools/browser.rb +176 -19
  35. data/lib/clacky/tools/web_search.rb +79 -8
  36. data/lib/clacky/ui2/components/command_suggestions.rb +2 -1
  37. data/lib/clacky/ui2/components/input_area.rb +22 -2
  38. data/lib/clacky/ui2/components/modal_component.rb +35 -6
  39. data/lib/clacky/ui2/ui_controller.rb +44 -7
  40. data/lib/clacky/utils/file_processor.rb +19 -7
  41. data/lib/clacky/utils/mac_app_detector.rb +186 -0
  42. data/lib/clacky/utils/model_pricing.rb +114 -66
  43. data/lib/clacky/utils/windows_app_detector.rb +334 -0
  44. data/lib/clacky/version.rb +1 -1
  45. data/lib/clacky/web/app.css +596 -130
  46. data/lib/clacky/web/components/chat-navigator.js +489 -202
  47. data/lib/clacky/web/components/code-editor.js +191 -5
  48. data/lib/clacky/web/components/model-picker.js +2 -1
  49. data/lib/clacky/web/components/quote-select.js +235 -0
  50. data/lib/clacky/web/core/aside.js +157 -8
  51. data/lib/clacky/web/core/ext.js +16 -0
  52. data/lib/clacky/web/features/billing/view.js +75 -21
  53. data/lib/clacky/web/features/skills/store.js +18 -1
  54. data/lib/clacky/web/features/skills/view.js +128 -7
  55. data/lib/clacky/web/features/workspace/store.js +80 -7
  56. data/lib/clacky/web/features/workspace/view.js +392 -40
  57. data/lib/clacky/web/i18n.js +90 -5
  58. data/lib/clacky/web/index.html +15 -6
  59. data/lib/clacky/web/sessions.js +193 -38
  60. data/lib/clacky/web/utils.js +34 -0
  61. data/lib/clacky/web/vendor/codemirror/codemirror.min.js +24 -19
  62. data/lib/clacky/web/vendor/codemirror/entry.js +130 -0
  63. data/lib/clacky/web/vendor/codemirror/package.json +29 -0
  64. data/lib/clacky/web/ws-dispatcher.js +20 -0
  65. data/lib/clacky.rb +2 -0
  66. metadata +11 -1
@@ -17,24 +17,208 @@
17
17
  const LANG_MAP = {
18
18
  markdown: () => CM.markdown({ base: CM.markdownLanguage }),
19
19
  md: () => CM.markdown({ base: CM.markdownLanguage }),
20
+ python: () => CM.python(),
21
+ javascript: () => CM.javascript(),
22
+ jsx: () => CM.jsx(),
23
+ typescript: () => CM.typescript(),
24
+ tsx: () => CM.tsx(),
25
+ html: () => CM.html(),
26
+ css: () => CM.css(),
27
+ json: () => CM.json(),
28
+ sql: () => CM.sql(),
29
+ xml: () => CM.xml(),
30
+ rust: () => CM.rust(),
31
+ java: () => CM.java(),
32
+ cpp: () => CM.cpp(),
33
+ php: () => CM.php(),
34
+ ruby: () => CM.ruby(),
35
+ go: () => CM.go(),
36
+ shell: () => CM.shell(),
37
+ yaml: () => CM.yaml(),
38
+ toml: () => CM.toml(),
39
+ dockerfile: () => CM.dockerfile(),
40
+ };
41
+
42
+ // Extension → language id. Keyed by lowercase extension (no leading dot).
43
+ const EXT_LANG = {
44
+ md: "markdown", markdown: "markdown", mdx: "markdown",
45
+ py: "python", pyw: "python",
46
+ js: "javascript", mjs: "javascript", cjs: "javascript",
47
+ jsx: "jsx",
48
+ ts: "typescript", mts: "typescript", cts: "typescript",
49
+ tsx: "tsx",
50
+ html: "html", htm: "html",
51
+ css: "css",
52
+ json: "json", jsonc: "json",
53
+ sql: "sql",
54
+ xml: "xml", svg: "xml", xsd: "xml", xsl: "xml", plist: "xml",
55
+ rs: "rust",
56
+ java: "java",
57
+ c: "cpp", h: "cpp", cpp: "cpp", cc: "cpp", cxx: "cpp", hpp: "cpp", hxx: "cpp", hh: "cpp",
58
+ php: "php", php3: "php", php4: "php", php5: "php", phtml: "php",
59
+ rb: "ruby", rbw: "ruby", gemspec: "ruby", rake: "ruby", ru: "ruby",
60
+ go: "go",
61
+ sh: "shell", bash: "shell", zsh: "shell", ksh: "shell",
62
+ yaml: "yaml", yml: "yaml",
63
+ toml: "toml",
64
+ vue: "html", svelte: "html",
65
+ };
66
+
67
+ // Whole filename (no extension) → language id.
68
+ const FILENAME_LANG = {
69
+ dockerfile: "dockerfile",
70
+ gemfile: "ruby", rakefile: "ruby", vagrantfile: "ruby", guardfile: "ruby",
71
+ podfile: "ruby", berksfile: "ruby", brewfile: "ruby",
20
72
  };
21
73
 
22
74
  const IMAGE_EXTS = new Set(["png","jpg","jpeg","gif","bmp","webp","svg","ico","tiff","tif","avif"]);
23
- const BINARY_EXTS = new Set(["zip","gz","7z","tar","dmg","pdf","xls","xlsx","doc","docx","exe","rar","ttf","mov","mp4","mp3","db","db3","sqlite","sqlite3","dat","wasm","bin","so","dylib","dll"]);
75
+ const PDF_EXTS = new Set(["pdf"]);
76
+ const BINARY_EXTS = new Set([
77
+ // archives
78
+ "zip","gz","7z","tar","dmg","rar","tgz","iso","jar","war",
79
+ // office docs (previewed via the "open with app" fallback instead)
80
+ "xls","xlsx","doc","docx","ppt","pptx","pot","pps","ppsx","dot",
81
+ "odt","ods","odp","wps","et","dps","key","numbers","pages",
82
+ // media
83
+ "mov","mp4","mp3","avi","mkv","wmv","weba","flac","wav","m4a","aac","aiff",
84
+ // fonts
85
+ "ttf","otf","woff","woff2","eot",
86
+ // system / misc
87
+ "exe","msi","db","db3","sqlite","sqlite3","dat","wasm","bin","so","dylib","dll",
88
+ "class","pyc","psd","ai","eps","indd","sketch","fig",
89
+ ]);
90
+
91
+ // Sample size matches the server-side 512-byte sniff; the ratio tolerates a
92
+ // few stray replacement chars in otherwise valid text.
93
+ const SNIFF_SAMPLE_CHARS = 512;
94
+ const SNIFF_REPLACEMENT_RATIO = 0.1;
95
+
96
+ const TABLE_EXTS = new Set(["csv","tsv"]);
97
+
98
+ // Rendering a full <table> forces the browser to lay out every cell to resolve
99
+ // column widths, so cost grows with cells rather than lines. Measured in Chrome:
100
+ // 1000x20 (20k cells) ~73ms, 3000x20 (60k cells) ~231ms.
101
+ const TABLE_MAX_ROWS = 1000;
102
+ const TABLE_MAX_CELLS = 50000;
24
103
 
25
104
  function _fileKind(filename) {
26
105
  if (!filename) return "text";
27
106
  const ext = filename.split(".").pop().toLowerCase();
28
107
  if (IMAGE_EXTS.has(ext)) return "image";
108
+ if (PDF_EXTS.has(ext)) return "pdf";
29
109
  if (BINARY_EXTS.has(ext)) return "binary";
110
+ if (TABLE_EXTS.has(ext)) return "table";
30
111
  return "text";
31
112
  }
32
113
 
114
+ // BINARY_EXTS can only list what we thought of, so unlisted binaries still
115
+ // reach the text branch and render as noise. Mirrors the server-side sniff in
116
+ // Utils::FileProcessor.binary_file_path?: a NUL byte means "not text". Decoding
117
+ // already turned invalid sequences into U+FFFD, so a high density of those is
118
+ // the same signal for encodings that carry no NUL.
119
+ function _looksBinary(text) {
120
+ if (!text) return false;
121
+ const head = text.slice(0, SNIFF_SAMPLE_CHARS);
122
+ if (head.indexOf("\u0000") >= 0) return true;
123
+ let replacements = 0;
124
+ for (let i = 0; i < head.length; i++) {
125
+ if (head.charCodeAt(i) === 0xFFFD) replacements++;
126
+ }
127
+ return replacements / head.length > SNIFF_REPLACEMENT_RATIO;
128
+ }
129
+
130
+ function _tableDelimiter(filename) {
131
+ const ext = (filename || "").split(".").pop().toLowerCase();
132
+ return ext === "tsv" ? "\t" : ",";
133
+ }
134
+
135
+ // RFC 4180: quoted fields may contain the delimiter, newlines and "" escapes.
136
+ function _parseDelimited(text, delimiter, maxRows) {
137
+ const rows = [];
138
+ let row = [];
139
+ let field = "";
140
+ let quoted = false;
141
+ let i = 0;
142
+ const limit = typeof maxRows === "number" ? maxRows : Infinity;
143
+
144
+ const endRow = () => {
145
+ row.push(field);
146
+ field = "";
147
+ rows.push(row);
148
+ row = [];
149
+ };
150
+
151
+ while (i < text.length) {
152
+ const ch = text[i];
153
+ if (quoted) {
154
+ if (ch === '"') {
155
+ if (text[i + 1] === '"') { field += '"'; i += 2; continue; }
156
+ quoted = false; i++; continue;
157
+ }
158
+ field += ch; i++; continue;
159
+ }
160
+ if (ch === '"' && field === "") { quoted = true; i++; continue; }
161
+ if (ch === delimiter) { row.push(field); field = ""; i++; continue; }
162
+ if (ch === "\r") { i += text[i + 1] === "\n" ? 2 : 1; endRow(); if (rows.length >= limit) return rows; continue; }
163
+ if (ch === "\n") { i++; endRow(); if (rows.length >= limit) return rows; continue; }
164
+ field += ch; i++;
165
+ }
166
+ if (field !== "" || row.length) endRow();
167
+ return rows;
168
+ }
169
+
170
+ // Returns { table, rows, cols, truncated }. Cells are set via textContent so
171
+ // untrusted file data can never be interpreted as markup.
172
+ function _renderTable(text, filename) {
173
+ const delimiter = _tableDelimiter(filename);
174
+ const rows = _parseDelimited(text, delimiter, TABLE_MAX_ROWS + 1);
175
+
176
+ let truncated = rows.length > TABLE_MAX_ROWS;
177
+ if (truncated) rows.length = TABLE_MAX_ROWS;
178
+
179
+ const cols = rows.reduce((max, r) => (r.length > max ? r.length : max), 0);
180
+ if (cols > 0) {
181
+ const maxRowsByCells = Math.max(1, Math.floor(TABLE_MAX_CELLS / cols));
182
+ if (rows.length > maxRowsByCells) {
183
+ rows.length = maxRowsByCells;
184
+ truncated = true;
185
+ }
186
+ }
187
+
188
+ const table = document.createElement("table");
189
+ if (rows.length) {
190
+ const thead = document.createElement("thead");
191
+ const headTr = document.createElement("tr");
192
+ rows[0].forEach((cell) => {
193
+ const th = document.createElement("th");
194
+ th.textContent = cell;
195
+ headTr.appendChild(th);
196
+ });
197
+ thead.appendChild(headTr);
198
+ table.appendChild(thead);
199
+
200
+ const tbody = document.createElement("tbody");
201
+ for (let r = 1; r < rows.length; r++) {
202
+ const tr = document.createElement("tr");
203
+ for (let c = 0; c < cols; c++) {
204
+ const td = document.createElement("td");
205
+ td.textContent = rows[r][c] == null ? "" : rows[r][c];
206
+ tr.appendChild(td);
207
+ }
208
+ tbody.appendChild(tr);
209
+ }
210
+ table.appendChild(tbody);
211
+ }
212
+ return { table: table, rows: rows.length, cols: cols, truncated: truncated };
213
+ }
214
+
33
215
  function _detectLanguage(filename) {
34
216
  if (!filename) return null;
35
- const ext = filename.split(".").pop().toLowerCase();
36
- const map = { md: "markdown", markdown: "markdown" };
37
- return map[ext] || null;
217
+ const base = filename.split(/[\\/]/).pop().toLowerCase();
218
+ if (FILENAME_LANG[base]) return FILENAME_LANG[base];
219
+ const dot = base.lastIndexOf(".");
220
+ if (dot < 0) return null;
221
+ return EXT_LANG[base.slice(dot + 1)] || null;
38
222
  }
39
223
 
40
224
  function _isDark() {
@@ -76,7 +260,7 @@
76
260
  extensions.push(CM.oneDark);
77
261
  }
78
262
 
79
- const langFn = LANG_MAP[opts.language || "markdown"];
263
+ const langFn = opts.language ? LANG_MAP[opts.language] : null;
80
264
  if (langFn) extensions.push(langFn());
81
265
 
82
266
  if (opts.readOnly) {
@@ -261,7 +445,9 @@
261
445
  open,
262
446
  createInline,
263
447
  fileKind: _fileKind,
448
+ looksBinary: _looksBinary,
264
449
  detectLanguage: _detectLanguage,
265
450
  renderMarkdown: _renderMarkdownHtml,
451
+ renderTable: _renderTable,
266
452
  };
267
453
  })(window);
@@ -36,7 +36,8 @@ const ModelPicker = (() => {
36
36
  svg: '<path d="M16.5376 0.0307374L14.3326 3.13223C14.1617 3.37577 13.9331 3.57415 13.6667 3.71001C13.4004 3.84586 13.1045 3.91504 12.8048 3.9115H0.787598V0.015152H16.5376V0.0307374Z"/><path d="M31.5 0.0321655L12.6 26.5273H0L18.9 0.0321655H31.5Z"/><path d="M14.9624 26.5272L17.1832 23.4101C17.3564 23.1689 17.5856 22.9723 17.8513 22.8368C18.1171 22.7012 18.4119 22.6306 18.7109 22.6308H30.7124V26.5272H14.9624Z"/>' },
37
37
  { re: /gemini/i, label: "G", color: "#3B78E7",
38
38
  svg: '<path d="M11.04 19.32Q12 21.51 12 24q0-2.49.93-4.68.96-2.19 2.58-3.81t3.81-2.55Q21.51 12 24 12q-2.49 0-4.68-.93a12.3 12.3 0 0 1-3.81-2.58 12.3 12.3 0 0 1-2.58-3.81Q12 2.49 12 0q0 2.49-.96 4.68-.93 2.19-2.55 3.81a12.3 12.3 0 0 1-3.81 2.58Q2.49 12 0 12q2.49 0 4.68.96 2.19.93 3.81 2.55t2.55 3.81"/>' },
39
- { re: /gpt|openai/i, label: "O", color: "#10A37F" },
39
+ { re: /gpt|openai/i, label: "G", color: "#000",
40
+ svg: '<path d="M22.2819 9.8211a5.9847 5.9847 0 0 0-.5157-4.9108 6.0462 6.0462 0 0 0-6.5098-2.9A6.0651 6.0651 0 0 0 4.9807 4.1818a5.9847 5.9847 0 0 0-3.9977 2.9 6.0462 6.0462 0 0 0 .7427 7.0966 5.98 5.98 0 0 0 .511 4.9107 6.051 6.051 0 0 0 6.5146 2.9001A5.9847 5.9847 0 0 0 13.2599 24a6.0557 6.0557 0 0 0 5.7718-4.2058 5.9894 5.9894 0 0 0 3.9977-2.9001 6.0557 6.0557 0 0 0-.7475-7.0729zm-9.022 12.6081a4.4755 4.4755 0 0 1-2.8764-1.0408l.1419-.0804 4.7783-2.7582a.7948.7948 0 0 0 .3927-.6813v-6.7369l2.02 1.1686a.071.071 0 0 1 .038.052v5.5826a4.504 4.504 0 0 1-4.4945 4.4944zm-9.6607-4.1254a4.4708 4.4708 0 0 1-.5346-3.0137l.142.0852 4.783 2.7582a.7712.7712 0 0 0 .7806 0l5.8428-3.3685v2.3324a.0804.0804 0 0 1-.0332.0615L9.74 19.9502a4.4992 4.4992 0 0 1-6.1408-1.6464zM2.3408 7.8956a4.485 4.485 0 0 1 2.3655-1.9728V11.6a.7664.7664 0 0 0 .3879.6765l5.8144 3.3543-2.0201 1.1685a.0757.0757 0 0 1-.071 0l-4.8303-2.7865A4.504 4.504 0 0 1 2.3408 7.872zm16.5963 3.8558L13.1038 8.364 15.1192 7.2a.0757.0757 0 0 1 .071 0l4.8303 2.7913a4.4944 4.4944 0 0 1-.6765 8.1042v-5.6772a.79.79 0 0 0-.407-.667zm2.0107-3.0231l-.142-.0852-4.7735-2.7818a.7759.7759 0 0 0-.7854 0L9.409 9.2297V6.8974a.0662.0662 0 0 1 .0284-.0615l4.8303-2.7866a4.4992 4.4992 0 0 1 6.6802 4.66zM8.3065 12.863l-2.02-1.1638a.0804.0804 0 0 1-.038-.0567V6.0742a4.4992 4.4992 0 0 1 7.3757-3.4537l-.142.0805L8.704 5.459a.7948.7948 0 0 0-.3927.6813zm1.0976-2.3654l2.602-1.4998 2.6069 1.4998v2.9994l-2.5974 1.4997-2.6067-1.4997Z"/>' },
40
41
  { re: /minimax/i, label: "M", color: "#F2402A",
41
42
  svg: '<path style="fill:none;stroke:currentColor;stroke-width:2.4;stroke-linecap:round" d="M4 9.5V14.5M8 6V18M12 3V21M16 6V18M20 9.5V14.5"/>' },
42
43
  { re: /qwen|tongyi/i, label: "Q", color: "#615CED" },
@@ -0,0 +1,235 @@
1
+ // ── quote-select.js — Quote-on-selection ────────────────────────────────────
2
+ //
3
+ // Drag-select text inside any user or assistant message bubble; a floating
4
+ // "Quote" pill appears above the selection. Clicking it appends the selected
5
+ // text to the composer as a markdown blockquote with a provenance line:
6
+ //
7
+ // > [Assistant · #3]
8
+ // > selected text…
9
+ //
10
+ // Pure frontend: the quote is plain text in the composer and rides the
11
+ // existing WS message pipeline unchanged (no backend/protocol changes).
12
+ //
13
+ // Event bindings are delegation-style on document, so they survive history
14
+ // lazy-loading and streaming DOM rebuilds. The button itself is a single
15
+ // recycled element, created lazily and hidden when any selection dies.
16
+
17
+ const QuoteSelect = (() => {
18
+ let _btn = null; // floating quote button (lazily created, recycled)
19
+ let _snap = null; // selection snapshot captured when the button appears
20
+ let _pending = null; // mouseup validation timer
21
+
22
+ // ── DOM helpers ──────────────────────────────────────────────────────────
23
+ // Nearest message root: .msg-user-wrap (user) or .msg-assistant. Returns
24
+ // null when the node is outside any quotable message (e.g. composer, modal).
25
+ function _msgRoot(node) {
26
+ if (!node || node.nodeType === Node.DOCUMENT_NODE) return null;
27
+ const el = node.nodeType === Node.ELEMENT_NODE ? node : node.parentElement;
28
+ if (!el) return null;
29
+ return el.closest(".msg-user-wrap, .msg-assistant");
30
+ }
31
+
32
+ // Message role + 1-based chat index, counted among quoted messages only
33
+ // (user / assistant), so index stays consistent with rendered history.
34
+ function _msgMeta(root) {
35
+ const items = root.parentElement.querySelectorAll(
36
+ ":scope > .msg-user-wrap, :scope > .msg-assistant"
37
+ );
38
+ return {
39
+ role: root.classList.contains("msg-user-wrap") ? "user" : "assistant",
40
+ index: Array.prototype.indexOf.call(items, root) + 1,
41
+ };
42
+ }
43
+
44
+ // ── Text cleaning ────────────────────────────────────────────────────────
45
+ // Normalize raw selection text into quote lines: strip leading/trailing
46
+ // blank lines, collapse consecutive blanks inside, keep each line's
47
+ // trailing whitespace trimmed.
48
+ function _cleanLines(raw) {
49
+ const lines = String(raw || "").replace(/\r\n/g, "\n").split("\n");
50
+ while (lines.length && !lines[0].trim()) lines.shift();
51
+ while (lines.length && !lines[lines.length - 1].trim()) lines.pop();
52
+ const out = [];
53
+ for (const line of lines) {
54
+ if (!line.trim()) {
55
+ if (!out.length || out[out.length - 1] !== "") out.push("");
56
+ } else {
57
+ out.push(line.replace(/\s+$/, ""));
58
+ }
59
+ }
60
+ return out;
61
+ }
62
+
63
+ // Build the markdown blockquote inserted into the composer (方案 B).
64
+ function _buildBlock(snap) {
65
+ const roleWord = snap.role === "user"
66
+ ? I18n.t("chat.quote.roleUser")
67
+ : I18n.t("chat.quote.roleAssistant");
68
+ const sig = I18n.t("chat.quote.sig", { role: roleWord, index: snap.index });
69
+ const body = _cleanLines(snap.text).map(line => {
70
+ if (!line) return ">"; // keep paragraph gaps inside the quote
71
+ return line.startsWith(">") ? line : "> " + line;
72
+ });
73
+ if (!body.length) return "";
74
+ return "> [" + sig + "]\n" + body.join("\n");
75
+ }
76
+
77
+ // ── Button lifecycle ─────────────────────────────────────────────────────
78
+ function _createBtn() {
79
+ const btn = document.createElement("button");
80
+ btn.type = "button";
81
+ btn.className = "quote-select-btn";
82
+ btn.disabled = false;
83
+ btn.innerHTML =
84
+ `<svg xmlns="http://www.w3.org/2000/svg" width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.2" stroke-linecap="round" stroke-linejoin="round"><path d="M17 6H3"/><path d="M21 12H8"/><path d="M21 18H8"/><path d="M3 12v6"/></svg>` +
85
+ `<span>${escapeHtml(I18n.t("chat.quote.btn"))}</span>`;
86
+ // Keep the textarea focus / selection alive: a bare mousedown on a button
87
+ // collapses the selection before click fires.
88
+ btn.addEventListener("mousedown", e => e.preventDefault());
89
+ btn.addEventListener("click", _onQuoteClick);
90
+ document.body.appendChild(btn);
91
+ return btn;
92
+ }
93
+
94
+ function _show(rect) {
95
+ if (!_btn) _btn = _createBtn();
96
+ // The button is a single recycled element — when it was first created it
97
+ // baked in the language of that moment. Sync the label with the current
98
+ // language on every show so switching en/zh mid-session stays consistent.
99
+ // (textContent assignment, no escaping needed.)
100
+ const labelQ = _btn.querySelector("span");
101
+ if (labelQ) labelQ.textContent = I18n.t("chat.quote.btn");
102
+ // The button is display:none until visible — measuring its rect while
103
+ // hidden returns 0×0, which would misplace it by its own height. Make it
104
+ // visible first, then measure and position (all synchronous, no flicker).
105
+ _btn.classList.add("visible");
106
+ const btnRect = _btn.getBoundingClientRect();
107
+ let top = rect.top - btnRect.height - 10; // 10px above the selection
108
+ if (top < 8) top = rect.bottom + 10; // flip below when clipped at top
109
+ _btn.style.top = top + "px";
110
+ // Horizontal center of the selection; CSS translateX(-50%) finishes it.
111
+ _btn.style.left = (rect.left + rect.width / 2) + "px";
112
+ }
113
+
114
+ function _hide() {
115
+ if (_pending) { clearTimeout(_pending); _pending = null; }
116
+ if (_btn) _btn.classList.remove("visible");
117
+ _snap = null;
118
+ }
119
+
120
+ // ── Quote action ─────────────────────────────────────────────────────────
121
+ function _onQuoteClick(e) {
122
+ e.preventDefault();
123
+ e.stopPropagation();
124
+ if (!_snap) return _hide();
125
+ const block = _buildBlock(_snap);
126
+ _hide();
127
+ if (block) _insert(block);
128
+ }
129
+
130
+ // Append the blockquote to the composer: keep any existing draft and
131
+ // mention chips (we append — never overwrite), ensure a blank line separates
132
+ // it, leave the caret on a fresh line after the quote.
133
+ // Composer is a contenteditable div since v1.5.13 (Clacky.Composer); the
134
+ // textarea branch is a fallback for older builds / custom templates.
135
+ // Append plain text to the composer's end, after any existing content and
136
+ // mention chips (chips are contenteditable=false nodes — appending at the
137
+ // very end naturally preserves them). Newlines become <br>. Works without a
138
+ // focused caret, so it is deterministic in headless environments where
139
+ // execCommand / focus-based insertion silently no-ops.
140
+ function _appendToComposer(el, str) {
141
+ const range = document.createRange();
142
+ range.selectNodeContents(el);
143
+ range.collapse(false);
144
+ const frag = document.createDocumentFragment();
145
+ String(str).split("\n").forEach((line, i) => {
146
+ if (i > 0) frag.appendChild(document.createElement("br"));
147
+ if (line !== "") frag.appendChild(document.createTextNode(line));
148
+ });
149
+ range.insertNode(frag);
150
+ }
151
+
152
+ function _insert(block) {
153
+ const el = document.getElementById("user-input");
154
+ if (!el) return;
155
+ const api = (typeof Clacky !== "undefined" && Clacky.Composer) || null;
156
+
157
+ if (api) {
158
+ const cur = api.text(el);
159
+ let prefix = "";
160
+ if (cur.length > 0 && !cur.endsWith("\n\n")) {
161
+ prefix = cur.endsWith("\n") ? "\n" : "\n\n";
162
+ }
163
+ _appendToComposer(el, prefix + block + "\n");
164
+ api.focusEnd(el);
165
+ el.dispatchEvent(new Event("input", { bubbles: true }));
166
+ return;
167
+ }
168
+
169
+ if (el.tagName === "TEXTAREA") {
170
+ let v = el.value || "";
171
+ if (v.length > 0 && !v.endsWith("\n\n")) {
172
+ v += v.endsWith("\n") ? "\n" : "\n\n";
173
+ }
174
+ v += block + "\n";
175
+ el.value = v;
176
+ el.focus();
177
+ el.setSelectionRange(v.length, v.length);
178
+ }
179
+ }
180
+
181
+ // ── Selection validation ─────────────────────────────────────────────────
182
+ // Runs 60ms after mouseup/touchend so the browser has finished updating the
183
+ // selection (dbl-click word selection, drag end, etc.).
184
+ function _onMouseUp() {
185
+ if (_pending) clearTimeout(_pending);
186
+ _pending = setTimeout(_validate, 60);
187
+ }
188
+
189
+ function _validate() {
190
+ _pending = null;
191
+ const sel = window.getSelection();
192
+ if (!sel || sel.isCollapsed || sel.rangeCount < 1) return _hide();
193
+ const range = sel.getRangeAt(0);
194
+ const r1 = _msgRoot(range.startContainer);
195
+ const r2 = _msgRoot(range.endContainer);
196
+ if (!r1 || r1 !== r2) return _hide(); // cross-message selection: no unique provenance
197
+ const messages = document.getElementById("messages");
198
+ if (!messages || !messages.contains(r1)) return _hide();
199
+
200
+ const text = sel.toString().trim();
201
+ if (!text) return _hide();
202
+
203
+ _snap = Object.assign({ text }, _msgMeta(r1), { rect: range.getBoundingClientRect() });
204
+ _show(_snap.rect);
205
+ }
206
+
207
+ function _onDocMouseDown(e) {
208
+ if (_btn && e.target !== _btn && !_btn.contains(e.target)) _hide();
209
+ }
210
+
211
+ // ── Init ─────────────────────────────────────────────────────────────────
212
+ let _inited = false; // guard against double init (listener stacking)
213
+ function init() {
214
+ if (_inited) return;
215
+ _inited = true;
216
+ document.addEventListener("mouseup", _onMouseUp);
217
+ document.addEventListener("touchend", _onMouseUp);
218
+ document.addEventListener("mousedown", _onDocMouseDown);
219
+ document.addEventListener("scroll", _hide, true); // any scroll collides with the anchor
220
+ document.addEventListener("keydown", e => { if (e.key === "Escape") _hide(); });
221
+ window.addEventListener("resize", _hide);
222
+
223
+ // Streaming rewrites message DOM wholesale; a detached selection cannot
224
+ // anchor the button anymore, so hide whenever the message list mutates.
225
+ const messages = document.getElementById("messages");
226
+ if (messages && typeof MutationObserver !== "undefined") {
227
+ new MutationObserver(() => { if (_snap) _hide(); }).observe(messages, {
228
+ childList: true,
229
+ subtree: true,
230
+ });
231
+ }
232
+ }
233
+
234
+ return { init };
235
+ })();