openclacky 1.5.13 → 1.5.15

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 (87) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +96 -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 +53 -98
  6. data/lib/clacky/agent.rb +477 -297
  7. data/lib/clacky/agent_config.rb +5 -3
  8. data/lib/clacky/billing/billing_store.rb +2 -2
  9. data/lib/clacky/billing/platform_billing.rb +7 -0
  10. data/lib/clacky/brand_config.rb +27 -19
  11. data/lib/clacky/cli.rb +92 -12
  12. data/lib/clacky/cli_guidance.rb +48 -0
  13. data/lib/clacky/client.rb +2 -2
  14. data/lib/clacky/default_extensions/ext-studio/agents/ext-developer/system_prompt.md +70 -134
  15. data/lib/clacky/default_extensions/ext-studio/api/handler.rb +4 -1
  16. data/lib/clacky/default_extensions/ext-studio/panels/studio/view.js +307 -98
  17. data/lib/clacky/default_extensions/ext-studio/skills/ext-develop/SKILL.md +179 -577
  18. data/lib/clacky/default_extensions/git/panels/git/view.js +34 -14
  19. data/lib/clacky/default_extensions/preview/ext.yml +20 -0
  20. data/lib/clacky/default_extensions/preview/panels/preview/view.js +475 -0
  21. data/lib/clacky/default_extensions/time_machine/panels/time_machine/view.js +1 -10
  22. data/lib/clacky/extension/api_extension.rb +18 -6
  23. data/lib/clacky/extension/verifier.rb +1 -1
  24. data/lib/clacky/json_ui_controller.rb +4 -2
  25. data/lib/clacky/media/openai_compat.rb +70 -30
  26. data/lib/clacky/message_format/bedrock.rb +6 -1
  27. data/lib/clacky/message_format/open_ai.rb +22 -5
  28. data/lib/clacky/message_format/open_ai_responses.rb +7 -3
  29. data/lib/clacky/plain_ui_controller.rb +1 -1
  30. data/lib/clacky/prompts/base.md +1 -1
  31. data/lib/clacky/providers.rb +44 -13
  32. data/lib/clacky/rich_ui/components/composer_guidance.rb +49 -0
  33. data/lib/clacky/rich_ui/rich_ui_controller.rb +24 -4
  34. data/lib/clacky/rich_ui/shell/rich_agent_shell.rb +13 -0
  35. data/lib/clacky/search_config.rb +3 -3
  36. data/lib/clacky/server/channel/channel_manager.rb +25 -7
  37. data/lib/clacky/server/channel/channel_ui_controller.rb +1 -1
  38. data/lib/clacky/server/dir_picker.rb +67 -4
  39. data/lib/clacky/server/git_panel.rb +10 -2
  40. data/lib/clacky/server/http_server.rb +391 -75
  41. data/lib/clacky/server/preview.rb +351 -0
  42. data/lib/clacky/server/session_registry.rb +4 -0
  43. data/lib/clacky/server/web_ui_controller.rb +10 -5
  44. data/lib/clacky/session_manager.rb +52 -1
  45. data/lib/clacky/skill.rb +48 -0
  46. data/lib/clacky/tools/browser.rb +176 -19
  47. data/lib/clacky/tools/web_search.rb +91 -8
  48. data/lib/clacky/ui2/components/command_suggestions.rb +3 -1
  49. data/lib/clacky/ui2/components/input_area.rb +37 -4
  50. data/lib/clacky/ui2/components/modal_component.rb +35 -6
  51. data/lib/clacky/ui2/screen_buffer.rb +1 -0
  52. data/lib/clacky/ui2/ui_controller.rb +72 -12
  53. data/lib/clacky/ui_interface.rb +1 -1
  54. data/lib/clacky/utils/file_processor.rb +19 -7
  55. data/lib/clacky/utils/mac_app_detector.rb +186 -0
  56. data/lib/clacky/utils/model_pricing.rb +118 -68
  57. data/lib/clacky/utils/windows_app_detector.rb +334 -0
  58. data/lib/clacky/version.rb +1 -1
  59. data/lib/clacky/web/app.css +998 -168
  60. data/lib/clacky/web/app.js +19 -0
  61. data/lib/clacky/web/components/chat-navigator.js +489 -202
  62. data/lib/clacky/web/components/code-editor.js +191 -5
  63. data/lib/clacky/web/components/composer.js +3 -1
  64. data/lib/clacky/web/components/datepicker.js +19 -19
  65. data/lib/clacky/web/components/mentions.js +11 -14
  66. data/lib/clacky/web/components/model-picker.js +66 -23
  67. data/lib/clacky/web/components/quote-select.js +341 -0
  68. data/lib/clacky/web/core/aside.js +157 -8
  69. data/lib/clacky/web/core/ext.js +16 -0
  70. data/lib/clacky/web/features/billing/view.js +75 -21
  71. data/lib/clacky/web/features/extensions/store.js +4 -1
  72. data/lib/clacky/web/features/model-tester/store.js +37 -4
  73. data/lib/clacky/web/features/skills/store.js +18 -1
  74. data/lib/clacky/web/features/skills/view.js +128 -7
  75. data/lib/clacky/web/features/workspace/store.js +80 -7
  76. data/lib/clacky/web/features/workspace/view.js +474 -43
  77. data/lib/clacky/web/i18n.js +158 -23
  78. data/lib/clacky/web/index.html +57 -29
  79. data/lib/clacky/web/sessions.js +517 -127
  80. data/lib/clacky/web/settings.js +155 -6
  81. data/lib/clacky/web/utils.js +34 -0
  82. data/lib/clacky/web/vendor/codemirror/codemirror.min.js +24 -19
  83. data/lib/clacky/web/vendor/codemirror/entry.js +130 -0
  84. data/lib/clacky/web/vendor/codemirror/package.json +29 -0
  85. data/lib/clacky/web/ws-dispatcher.js +161 -4
  86. data/lib/clacky.rb +2 -0
  87. metadata +13 -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);
@@ -124,8 +124,10 @@ const Composer = (() => {
124
124
 
125
125
  // Extract mention chips as plain data objects, in document order.
126
126
  // file/directory → { type, name, path }; session → { type, name, session_id }.
127
+ // Quote chips ride on the same chip mechanics (atomic, skipped by text()) but
128
+ // are not mentions — quote-select.js reads them off the DOM itself.
127
129
  function chips(el) {
128
- return Array.from(el.querySelectorAll("." + CHIP_CLASS)).map((c) => {
130
+ return Array.from(el.querySelectorAll("." + CHIP_CLASS + ":not(.quote-chip)")).map((c) => {
129
131
  const type = c.dataset.mentionType || "";
130
132
  const out = { type: type, name: c.dataset.name || "" };
131
133
  if (type === "session") {
@@ -45,12 +45,12 @@ const DatePicker = (() => {
45
45
  function _ensurePopup() {
46
46
  if (_popup) return;
47
47
  _popup = document.createElement("div");
48
- _popup.className = "dp-popup";
48
+ _popup.className = "dpk-popup";
49
49
  _popup.hidden = true;
50
50
  document.body.appendChild(_popup);
51
51
 
52
52
  _popup.addEventListener("click", (e) => {
53
- const nav = e.target.closest(".dp-nav");
53
+ const nav = e.target.closest(".dpk-nav");
54
54
  if (nav) {
55
55
  e.stopPropagation();
56
56
  _month += parseInt(nav.dataset.dir);
@@ -61,10 +61,10 @@ const DatePicker = (() => {
61
61
  }
62
62
  const day = e.target.closest("[data-date]");
63
63
  if (day) { _pick(day.dataset.date); return; }
64
- if (e.target.closest(".dp-btn-clear")) {
64
+ if (e.target.closest(".dpk-btn-clear")) {
65
65
  clear(_anchor); _dispatch(); _close(); return;
66
66
  }
67
- if (e.target.closest(".dp-btn-today")) {
67
+ if (e.target.closest(".dpk-btn-today")) {
68
68
  const t = new Date();
69
69
  _pick(_toISO(t.getFullYear(), t.getMonth(), t.getDate()));
70
70
  }
@@ -80,31 +80,31 @@ const DatePicker = (() => {
80
80
  const firstWeekday = new Date(_year, _month, 1).getDay();
81
81
  const daysInMonth = new Date(_year, _month + 1, 0).getDate();
82
82
 
83
- let html = `<div class="dp-header">
84
- <button class="dp-nav" data-dir="-12" type="button">&#8810;</button>
85
- <button class="dp-nav" data-dir="-1" type="button">&#8249;</button>
86
- <span class="dp-title">${loc.title(_year, _month, loc.months)}</span>
87
- <button class="dp-nav" data-dir="1" type="button">&#8250;</button>
88
- <button class="dp-nav" data-dir="12" type="button">&#8811;</button>
83
+ let html = `<div class="dpk-header">
84
+ <button class="dpk-nav" data-dir="-12" type="button">&#8810;</button>
85
+ <button class="dpk-nav" data-dir="-1" type="button">&#8249;</button>
86
+ <span class="dpk-title">${loc.title(_year, _month, loc.months)}</span>
87
+ <button class="dpk-nav" data-dir="1" type="button">&#8250;</button>
88
+ <button class="dpk-nav" data-dir="12" type="button">&#8811;</button>
89
89
  </div>
90
- <div class="dp-grid">`;
90
+ <div class="dpk-grid">`;
91
91
 
92
- loc.days.forEach(d => { html += `<div class="dp-dow">${d}</div>`; });
92
+ loc.days.forEach(d => { html += `<div class="dpk-dow">${d}</div>`; });
93
93
  for (let i = 0; i < firstWeekday; i++) html += `<div></div>`;
94
94
 
95
95
  for (let d = 1; d <= daysInMonth; d++) {
96
96
  const iso = _toISO(_year, _month, d);
97
- const cls = ["dp-day",
98
- iso === selVal ? "dp-day--selected" : "",
99
- (_year === tY && _month === tM && d === tD) ? "dp-day--today" : "",
97
+ const cls = ["dpk-day",
98
+ iso === selVal ? "dpk-day--selected" : "",
99
+ (_year === tY && _month === tM && d === tD) ? "dpk-day--today" : "",
100
100
  ].filter(Boolean).join(" ");
101
101
  html += `<button class="${cls}" data-date="${iso}" type="button">${d}</button>`;
102
102
  }
103
103
 
104
104
  html += `</div>
105
- <div class="dp-footer">
106
- <button class="dp-btn-clear" type="button">${loc.clear}</button>
107
- <button class="dp-btn-today" type="button">${loc.today}</button>
105
+ <div class="dpk-footer">
106
+ <button class="dpk-btn-clear" type="button">${loc.clear}</button>
107
+ <button class="dpk-btn-today" type="button">${loc.today}</button>
108
108
  </div>`;
109
109
 
110
110
  _popup.innerHTML = html;
@@ -171,7 +171,7 @@ const DatePicker = (() => {
171
171
  _open(trigger);
172
172
  return;
173
173
  }
174
- if (_popup && !_popup.hidden && !e.target.closest(".dp-popup")) _close();
174
+ if (_popup && !_popup.hidden && !e.target.closest(".dpk-popup")) _close();
175
175
  });
176
176
 
177
177
  window.addEventListener("scroll", _position, true);
@@ -528,18 +528,15 @@ const MentionAC = (() => {
528
528
 
529
529
  // ── show / hide ────────────────────────────────────────────────────────
530
530
 
531
- function _createOverlay() {
532
- _removeOverlay();
533
- const overlay = document.createElement("div");
534
- overlay.id = "mention-overlay";
535
- overlay.style.cssText = "position: fixed; inset: 0; z-index: 998; background: transparent;";
536
- overlay.addEventListener("click", hide);
537
- document.body.appendChild(overlay);
538
- }
539
-
540
- function _removeOverlay() {
541
- const overlay = document.getElementById("mention-overlay");
542
- if (overlay) overlay.remove();
531
+ // Dismiss on outside press. A DOM overlay would have to out-stack the menu,
532
+ // which breaks whenever an ancestor forms a stacking context (the mobile
533
+ // #welcome.new-session-page is position:fixed), so listen on document.
534
+ function _onOutsidePointerDown(e) {
535
+ const menu = _menuEl();
536
+ const input = _inputEl();
537
+ if (menu && menu.contains(e.target)) return;
538
+ if (input && input.contains(e.target)) return;
539
+ hide();
543
540
  }
544
541
 
545
542
  function open(mode) {
@@ -552,7 +549,7 @@ const MentionAC = (() => {
552
549
  else if (_mode === "files") _renderFiles();
553
550
  else if (_mode === "chats") _renderChats();
554
551
  _visible = true;
555
- _createOverlay();
552
+ document.addEventListener("pointerdown", _onOutsidePointerDown, true);
556
553
  }
557
554
 
558
555
  function hide() {
@@ -564,7 +561,7 @@ const MentionAC = (() => {
564
561
  const menu = _menuEl();
565
562
  if (menu) menu.style.display = "none";
566
563
  _unwrapAtMarker();
567
- _removeOverlay();
564
+ document.removeEventListener("pointerdown", _onOutsidePointerDown, true);
568
565
  }
569
566
 
570
567
  function _toggle() {
@@ -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" },
@@ -67,6 +68,21 @@ const ModelPicker = (() => {
67
68
  return el;
68
69
  }
69
70
 
71
+ // Disclosure caret shared by the clickable status-bar fields (model name,
72
+ // working dir, reasoning effort). Those render as plain text, so a hover
73
+ // tint alone stays invisible to anyone who does not already know they open
74
+ // a picker — this is the only persistent affordance.
75
+ function caret() {
76
+ const el = document.createElement("span");
77
+ el.className = "sib-caret";
78
+ el.setAttribute("aria-hidden", "true");
79
+ el.innerHTML =
80
+ '<svg viewBox="0 0 16 16" width="9" height="9" aria-hidden="true">' +
81
+ '<path d="M4 6.5L8 10.5L12 6.5" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>' +
82
+ '</svg>';
83
+ return el;
84
+ }
85
+
70
86
  // Fetches ratios for names not yet cached. Rows simply stay blank when
71
87
  // the request fails (offline, unknown model).
72
88
  async function loadPriceRatios(names) {
@@ -89,6 +105,20 @@ const ModelPicker = (() => {
89
105
  return _priceCache.get(name) || null;
90
106
  }
91
107
 
108
+ // Provider label for a row: the preset name resolved server-side (same
109
+ // rules the rest of the app uses), localised when the preset ships a
110
+ // name_key. Returns "" for endpoints with no known provider — those rows
111
+ // fall back to the raw host.
112
+ function providerLabel(m) {
113
+ if (!m || !m.provider_name) return "";
114
+ const key = m.provider_name_key;
115
+ if (key && typeof I18n !== "undefined") {
116
+ const translated = I18n.t(key);
117
+ if (translated && translated !== key) return translated;
118
+ }
119
+ return m.provider_name;
120
+ }
121
+
92
122
  // Render one latency cell based on a cached result.
93
123
  // undefined → empty slot
94
124
  // { ok:true } → "812ms" in green/amber/red per threshold
@@ -239,7 +269,7 @@ const ModelPicker = (() => {
239
269
  panel.style.visibility = "hidden";
240
270
 
241
271
  const dropRect = container.getBoundingClientRect();
242
- const btnRect = btn.getBoundingClientRect();
272
+ const btnRect = (btn || anchorRow).getBoundingClientRect();
243
273
  const panelRect = panel.getBoundingClientRect();
244
274
  const gap = 6;
245
275
  const margin = 8;
@@ -265,7 +295,16 @@ const ModelPicker = (() => {
265
295
 
266
296
  _activeSubmodelAnchor = anchorRow;
267
297
  anchorRow.classList.add("submodel-open");
268
- btn.setAttribute("aria-expanded", "true");
298
+ if (btn) btn.setAttribute("aria-expanded", "true");
299
+ }
300
+
301
+ // Open the quick-switch panel anchored to any element. The session info bar
302
+ // caret uses this to reach the same panel without opening the full picker.
303
+ function toggleSubmodelPanel(anchorEl, subInfo, onSwitchSubModel) {
304
+ const panel = document.getElementById("sib-submodel-panel");
305
+ if (!panel) return false;
306
+ _toggleSubmodelPanel(anchorEl, anchorEl, null, subInfo, onSwitchSubModel);
307
+ return panel.style.display !== "none";
269
308
  }
270
309
 
271
310
  // ── Benchmark runner ────────────────────────────────────────────────────
@@ -396,11 +435,6 @@ const ModelPicker = (() => {
396
435
  }
397
436
 
398
437
  // Model rows.
399
- const _nameCounts = models.reduce((acc, m) => {
400
- acc[m.model] = (acc[m.model] || 0) + 1;
401
- return acc;
402
- }, {});
403
-
404
438
  models.forEach(m => {
405
439
  const opt = document.createElement("div");
406
440
  opt.className = "sib-model-option";
@@ -436,22 +470,27 @@ const ModelPicker = (() => {
436
470
  nameLine.appendChild(vis);
437
471
  }
438
472
 
439
- // Distinguish rows that would otherwise look identical.
473
+ // Provider line: which service the model runs through (plus the user's own
474
+ // remark). Rendered on every row so two rows with the same model name are
475
+ // never ambiguous. The endpoint host and the masked key are technical
476
+ // details: they stay out of the line and live in the tooltip instead.
440
477
  const remark = (m.remark || "").trim();
441
- if (remark || _nameCounts[m.model] > 1) {
478
+ const host = (() => {
479
+ try { return new URL(m.base_url).host; } catch { return m.base_url || ""; }
480
+ })();
481
+ const provider = providerLabel(m);
482
+ // Unknown endpoints (no matching provider preset) fall back to the host
483
+ // so a row never renders an empty second line.
484
+ const identityBits = [provider || host, remark].filter(Boolean);
485
+ if (identityBits.length) {
442
486
  left.classList.add("has-sub");
443
- const host = (() => {
444
- try { return new URL(m.base_url).host; } catch { return m.base_url || ""; }
445
- })();
446
- const subBits = [remark, host, m.api_key_masked].filter(Boolean);
447
- if (subBits.length) {
448
- const subLine = document.createElement("span");
449
- subLine.className = "sib-model-name-sub";
450
- subLine.textContent = subBits.join(" · ");
451
- left.appendChild(subLine);
452
- opt.title = `${m.model} · ${subBits.join(" · ")}`;
453
- }
487
+ const subLine = document.createElement("span");
488
+ subLine.className = "sib-model-name-sub";
489
+ subLine.textContent = identityBits.join(" · ");
490
+ left.appendChild(subLine);
454
491
  }
492
+ const tipBits = [provider, host, remark, m.api_key_masked].filter(Boolean);
493
+ if (tipBits.length) opt.title = `${m.model} · ${tipBits.join(" · ")}`;
455
494
 
456
495
  opt.appendChild(left);
457
496
 
@@ -468,10 +507,12 @@ const ModelPicker = (() => {
468
507
  right.appendChild(check);
469
508
  }
470
509
 
471
- if (m.type === "default") {
510
+ if (m.type === "default" || m.type === "lite") {
472
511
  const badge = document.createElement("span");
473
512
  badge.className = `model-badge ${m.type}`;
474
- badge.textContent = m.type;
513
+ // Reuses the Settings badge labels so both surfaces stay in sync.
514
+ const labelKey = `settings.models.badge.${m.type}`;
515
+ badge.textContent = (typeof I18n !== "undefined") ? I18n.t(labelKey) : m.type;
475
516
  right.appendChild(badge);
476
517
  }
477
518
 
@@ -519,9 +560,11 @@ const ModelPicker = (() => {
519
560
 
520
561
  return {
521
562
  vendorBadge,
563
+ caret,
522
564
  loadPriceRatios,
523
565
  getPrice,
524
566
  populate,
525
567
  closeSubmodelPanel,
568
+ toggleSubmodelPanel,
526
569
  };
527
570
  })();