openclacky 1.5.1 → 1.5.3

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 (40) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +48 -0
  3. data/lib/clacky/agent/llm_caller.rb +48 -0
  4. data/lib/clacky/agent/message_compressor_helper.rb +7 -0
  5. data/lib/clacky/agent/session_serializer.rb +42 -0
  6. data/lib/clacky/agent/skill_manager.rb +7 -0
  7. data/lib/clacky/agent.rb +58 -1
  8. data/lib/clacky/agent_config.rb +71 -1
  9. data/lib/clacky/brand_config.rb +88 -59
  10. data/lib/clacky/client.rb +13 -2
  11. data/lib/clacky/default_extensions/ext-studio/api/handler.rb +1 -1
  12. data/lib/clacky/default_extensions/ext-studio/panels/studio/view.js +15 -3
  13. data/lib/clacky/extension/packager.rb +17 -0
  14. data/lib/clacky/locales/en.rb +4 -2
  15. data/lib/clacky/locales/zh.rb +5 -3
  16. data/lib/clacky/message_format/anthropic.rb +56 -1
  17. data/lib/clacky/message_format/open_ai.rb +69 -4
  18. data/lib/clacky/message_history.rb +15 -1
  19. data/lib/clacky/providers.rb +73 -6
  20. data/lib/clacky/server/http_server.rb +61 -29
  21. data/lib/clacky/server/session_registry.rb +4 -2
  22. data/lib/clacky/skill.rb +4 -0
  23. data/lib/clacky/tools/invoke_skill.rb +21 -0
  24. data/lib/clacky/tools/security.rb +11 -13
  25. data/lib/clacky/ui_interface.rb +2 -0
  26. data/lib/clacky/utils/model_pricing.rb +8 -0
  27. data/lib/clacky/version.rb +1 -1
  28. data/lib/clacky/web/app.css +210 -21
  29. data/lib/clacky/web/app.js +8 -3
  30. data/lib/clacky/web/components/code-editor.js +50 -12
  31. data/lib/clacky/web/features/billing/view.js +7 -1
  32. data/lib/clacky/web/features/extensions/store.js +23 -7
  33. data/lib/clacky/web/features/extensions/view.js +8 -4
  34. data/lib/clacky/web/features/new-session/view.js +61 -0
  35. data/lib/clacky/web/features/workspace/store.js +9 -0
  36. data/lib/clacky/web/features/workspace/view.js +8 -1
  37. data/lib/clacky/web/i18n.js +14 -0
  38. data/lib/clacky/web/index.html +4 -2
  39. data/lib/clacky/web/settings.js +49 -14
  40. metadata +2 -2
@@ -2173,6 +2173,40 @@ body {
2173
2173
  margin-top: -0.625rem;
2174
2174
  padding-left: 0.125rem;
2175
2175
  }
2176
+
2177
+ /* ── Starter Prompts ── */
2178
+ .new-session-starter-prompts {
2179
+ margin-top: 0.25rem;
2180
+ }
2181
+ .ns-starter-label {
2182
+ font-size: 0.75rem;
2183
+ color: var(--color-text-tertiary);
2184
+ margin-bottom: 0.5rem;
2185
+ }
2186
+ .ns-starter-list {
2187
+ display: flex;
2188
+ flex-direction: column;
2189
+ gap: 0.375rem;
2190
+ }
2191
+ .ns-starter-item {
2192
+ display: block;
2193
+ width: 100%;
2194
+ text-align: left;
2195
+ padding: 0.5rem 0.75rem;
2196
+ border: 1px solid var(--color-border-primary);
2197
+ border-radius: 0.5rem;
2198
+ background: var(--color-bg-primary);
2199
+ color: var(--color-text-secondary);
2200
+ font-size: 0.8125rem;
2201
+ line-height: 1.45;
2202
+ cursor: pointer;
2203
+ transition: border-color 0.14s, background-color 0.14s, color 0.14s;
2204
+ }
2205
+ .ns-starter-item:hover {
2206
+ border-color: color-mix(in srgb, var(--color-accent-primary) 40%, var(--color-border-primary));
2207
+ background: var(--color-bg-hover);
2208
+ color: var(--color-text-primary);
2209
+ }
2176
2210
  .ns-panels-label {
2177
2211
  width: 100%;
2178
2212
  font-size: 0.75rem;
@@ -2637,7 +2671,6 @@ body {
2637
2671
  .session-banner { flex-shrink: 0; }
2638
2672
  .session-composer {
2639
2673
  flex-shrink: 0;
2640
- margin-bottom: -9px;
2641
2674
  display: flex;
2642
2675
  padding-inline: 24px;
2643
2676
  }
@@ -5343,7 +5376,7 @@ body {
5343
5376
  }
5344
5377
  .field-input-row .field-input { flex: 1; }
5345
5378
  .btn-toggle-key {
5346
- background: var(--color-border-secondary);
5379
+ background: var(--color-bg-primary);
5347
5380
  border: 1px solid var(--color-border-primary);
5348
5381
  border-radius: 6px;
5349
5382
  color: var(--color-text-secondary);
@@ -5351,8 +5384,19 @@ body {
5351
5384
  padding: 0.25rem 0.5rem;
5352
5385
  cursor: pointer;
5353
5386
  flex-shrink: 0;
5387
+ display: flex;
5388
+ align-items: center;
5389
+ justify-content: center;
5390
+ }
5391
+ .btn-toggle-key:hover {
5392
+ color: var(--color-text-primary);
5393
+ background: var(--color-bg-hover);
5394
+ }
5395
+ .btn-toggle-key:active {
5396
+ background: var(--color-bg-hover);
5397
+ filter: brightness(0.94);
5398
+ transform: scale(0.95);
5354
5399
  }
5355
- .btn-toggle-key:hover { color: var(--color-text-primary); }
5356
5400
  .field-checkbox {
5357
5401
  width: 1rem;
5358
5402
  height: 1rem;
@@ -5745,14 +5789,15 @@ body {
5745
5789
  max-height: 90vh;
5746
5790
  display: flex;
5747
5791
  flex-direction: column;
5748
- overflow: hidden;
5792
+ /* Do NOT use overflow:hidden here — it clips absolutely-positioned dropdowns
5793
+ inside the modal (e.g. the provider custom-select). Border-radius still
5794
+ applies because there is no overflow to clip. */
5795
+ overflow: visible;
5749
5796
  }
5750
5797
  .model-edit-modal-content {
5751
5798
  max-width: 500px;
5752
5799
  }
5753
- .model-edit-modal-content .modal-body {
5754
- overflow-y: auto;
5755
- }
5800
+ /* overflow override for model-edit modal is applied after .modal-content .modal-body below */
5756
5801
  .modal-content .modal-header {
5757
5802
  display: flex;
5758
5803
  align-items: center;
@@ -5790,6 +5835,12 @@ body {
5790
5835
  gap: 0.875rem;
5791
5836
  padding: 1.25rem;
5792
5837
  }
5838
+ /* model-edit modal: allow absolutely-positioned dropdowns to escape the body.
5839
+ Must come after .modal-content .modal-body to win the cascade. */
5840
+ .modal-content.model-edit-modal-content .modal-body {
5841
+ overflow: visible;
5842
+ padding-bottom: 0;
5843
+ }
5793
5844
  .modal-content .modal-footer {
5794
5845
  display: flex;
5795
5846
  align-items: center;
@@ -6173,6 +6224,9 @@ body {
6173
6224
  font-size: 0.75rem;
6174
6225
  flex: 1;
6175
6226
  }
6227
+ .model-test-result:empty {
6228
+ display: none;
6229
+ }
6176
6230
  .result-ok { color: var(--color-success); }
6177
6231
  .result-fail { color: var(--color-error); }
6178
6232
  .result-testing { color: var(--color-text-secondary); }
@@ -8137,6 +8191,127 @@ body {
8137
8191
  border-radius: 4px;
8138
8192
  }
8139
8193
 
8194
+ /* ── Markdown split-pane layout ─────────────────────────────────────────── */
8195
+ .code-editor-modal--md {
8196
+ max-width: 1280px;
8197
+ width: 98%;
8198
+ height: 90vh;
8199
+ max-height: 960px;
8200
+ }
8201
+ .code-editor-body--split {
8202
+ display: flex;
8203
+ flex-direction: row;
8204
+ overflow: hidden;
8205
+ }
8206
+ .code-editor-split-left {
8207
+ flex: 1;
8208
+ min-width: 0;
8209
+ overflow: hidden;
8210
+ border-right: 1px solid var(--color-border-primary);
8211
+ display: flex;
8212
+ flex-direction: column;
8213
+ }
8214
+ .code-editor-split-left .cm-editor {
8215
+ height: 100%;
8216
+ flex: 1;
8217
+ }
8218
+ .code-editor-split-left > .cm-editor {
8219
+ height: 100%;
8220
+ }
8221
+ .code-editor-split-divider {
8222
+ display: none;
8223
+ }
8224
+ .code-editor-split-right {
8225
+ flex: 1;
8226
+ min-width: 0;
8227
+ display: flex;
8228
+ flex-direction: column;
8229
+ overflow: hidden;
8230
+ }
8231
+ .code-editor-markdown-preview {
8232
+ flex: 1;
8233
+ overflow-y: auto;
8234
+ padding: 0 1.25rem;
8235
+ font-size: 0.875rem;
8236
+ line-height: 1.7;
8237
+ color: var(--color-text-primary);
8238
+ }
8239
+ .code-editor-markdown-preview h1,
8240
+ .code-editor-markdown-preview h2,
8241
+ .code-editor-markdown-preview h3,
8242
+ .code-editor-markdown-preview h4 {
8243
+ margin-top: 1.25em;
8244
+ margin-bottom: 0.5em;
8245
+ font-weight: 600;
8246
+ }
8247
+ .code-editor-markdown-preview h1:first-child,
8248
+ .code-editor-markdown-preview h2:first-child,
8249
+ .code-editor-markdown-preview h3:first-child { margin-top: 0; }
8250
+ .code-editor-markdown-preview h1 { font-size: 1.375rem; }
8251
+ .code-editor-markdown-preview h2 { font-size: 1.125rem; border-bottom: 1px solid var(--color-border); padding-bottom: 0.25em; }
8252
+ .code-editor-markdown-preview h3 { font-size: 1rem; }
8253
+ .code-editor-markdown-preview p { margin: 0.5em 0; }
8254
+ .code-editor-markdown-preview ul,
8255
+ .code-editor-markdown-preview ol { padding-left: 1.5em; margin: 0.5em 0; }
8256
+ .code-editor-markdown-preview li { margin: 0.2em 0; }
8257
+ .code-editor-markdown-preview code {
8258
+ font-family: var(--font-mono, 'SF Mono', 'Fira Code', monospace);
8259
+ font-size: 0.8125em;
8260
+ background: rgba(128, 128, 128, 0.15);
8261
+ border-radius: 4px;
8262
+ padding: 0.15em 0.4em;
8263
+ }
8264
+ .code-editor-markdown-preview pre {
8265
+ background: rgba(128, 128, 128, 0.15);
8266
+ border-radius: 8px;
8267
+ padding: 0.875em;
8268
+ overflow-x: auto;
8269
+ margin: 0.625em 0;
8270
+ }
8271
+ .code-editor-markdown-preview pre code {
8272
+ background: none;
8273
+ padding: 0;
8274
+ font-size: 0.8125rem;
8275
+ }
8276
+ .code-editor-markdown-preview blockquote {
8277
+ border-left: 3px solid var(--color-accent-primary, #7c3aed);
8278
+ margin: 0.75em 0;
8279
+ padding: 0.25em 1em;
8280
+ color: var(--color-text-secondary);
8281
+ }
8282
+ .code-editor-markdown-preview a {
8283
+ color: var(--color-accent-primary);
8284
+ text-decoration: none;
8285
+ }
8286
+ .code-editor-markdown-preview a:hover { text-decoration: underline; }
8287
+ .code-editor-markdown-preview img {
8288
+ display: inline;
8289
+ vertical-align: middle;
8290
+ max-width: 100%;
8291
+ height: auto;
8292
+ }
8293
+ .code-editor-markdown-preview hr {
8294
+ border: none;
8295
+ border-top: 1px solid var(--color-border-primary);
8296
+ margin: 1.25em 0;
8297
+ }
8298
+ .code-editor-markdown-preview table {
8299
+ border-collapse: collapse;
8300
+ width: 100%;
8301
+ margin: 0.75em 0;
8302
+ font-size: 0.8125rem;
8303
+ }
8304
+ .code-editor-markdown-preview th,
8305
+ .code-editor-markdown-preview td {
8306
+ border: 1px solid var(--color-border-primary);
8307
+ padding: 0.375em 0.75em;
8308
+ text-align: left;
8309
+ }
8310
+ .code-editor-markdown-preview th {
8311
+ background: rgba(128, 128, 128, 0.15);
8312
+ font-weight: 600;
8313
+ }
8314
+
8140
8315
  /* ── Skills sidebar section ──────────────────────────────────────────────── */
8141
8316
  #skill-list-items { padding: 0 0.5rem 0.5rem; display: flex; flex-direction: column; gap: 2px; }
8142
8317
 
@@ -8967,10 +9142,11 @@ body.setup-mode[data-theme="dark"] {
8967
9142
  padding-top: 0.75rem;
8968
9143
  border-top: 1px solid var(--color-border-primary);
8969
9144
  }
8970
- .badge-active { color: var(--color-success); }
8971
- .badge-expired { color: var(--color-error); }
8972
- .badge-expiring { color: var(--color-warning); }
8973
- .badge-inactive { color: var(--color-text-secondary); }
9145
+ .badge-active { color: var(--color-success); }
9146
+ .badge-expired { color: var(--color-error); }
9147
+ .badge-expiring { color: var(--color-warning); }
9148
+ .badge-unreachable { color: var(--color-text-secondary); }
9149
+ .badge-inactive { color: var(--color-text-secondary); }
8974
9150
 
8975
9151
  /* Support QR code */
8976
9152
  .brand-support-contact {
@@ -10908,6 +11084,19 @@ body.setup-mode[data-theme="dark"] {
10908
11084
  flex-wrap: wrap;
10909
11085
  }
10910
11086
 
11087
+ /* ── Disclaimer notice ──────────────────────────────────────────────── */
11088
+ .billing-disclaimer {
11089
+ display: flex;
11090
+ align-items: center;
11091
+ gap: 0.4rem;
11092
+ font-size: 0.75rem;
11093
+ color: var(--color-text-tertiary);
11094
+ padding: 0.4rem 0.75rem;
11095
+ background: var(--color-bg-secondary);
11096
+ border: 1px solid var(--color-border-primary);
11097
+ border-radius: 6px;
11098
+ }
11099
+
10911
11100
  /* ── Period Button Group ─────────────────────────────────────────────── */
10912
11101
  .billing-period-group {
10913
11102
  display: flex;
@@ -11008,7 +11197,7 @@ body.setup-mode[data-theme="dark"] {
11008
11197
  .billing-share-btn:hover {
11009
11198
  background: var(--color-bg-tertiary);
11010
11199
  color: var(--color-text-primary);
11011
- border-color: var(--color-text-secondary);
11200
+ border-color: var(--color-accent-primary);
11012
11201
  }
11013
11202
  .billing-clear-popup {
11014
11203
  position: absolute;
@@ -11084,10 +11273,10 @@ body.setup-mode[data-theme="dark"] {
11084
11273
  justify-content: center;
11085
11274
  flex-shrink: 0;
11086
11275
  }
11087
- .billing-stat-icon-cost { background: color-mix(in srgb, #10b981 10%, transparent); color: #10b981; }
11088
- .billing-stat-icon-tokens { background: color-mix(in srgb, #3b82f6 10%, transparent); color: #3b82f6; }
11276
+ .billing-stat-icon-cost { background: color-mix(in srgb, var(--color-accent-primary) 10%, transparent); color: var(--color-accent-primary); }
11277
+ .billing-stat-icon-tokens { background: color-mix(in srgb, #2075ffbf 10%, transparent); color: #2075ffbf; }
11089
11278
  .billing-stat-icon-requests { background: color-mix(in srgb, #f59e0b 10%, transparent); color: #f59e0b; }
11090
- .billing-stat-icon-cache { background: color-mix(in srgb, var(--color-accent-primary) 10%, transparent); color: var(--color-accent-primary); }
11279
+ .billing-stat-icon-cache { background: color-mix(in srgb, #10b981 10%, transparent); color: #10b981; }
11091
11280
  .billing-stat-icon svg {
11092
11281
  width: 18px;
11093
11282
  height: 18px;
@@ -11318,7 +11507,7 @@ body.setup-mode[data-theme="dark"] {
11318
11507
  align-items: stretch;
11319
11508
  }
11320
11509
  .billing-cache-hit {
11321
- background: #3b82f6;
11510
+ background: #2075ffbf;
11322
11511
  width: 100%;
11323
11512
  border-radius: 2px 2px 0 0;
11324
11513
  transition: height 0.2s ease;
@@ -11346,7 +11535,7 @@ body.setup-mode[data-theme="dark"] {
11346
11535
 
11347
11536
  /* Legend colors — indigo + emerald */
11348
11537
  .billing-legend-total { background: var(--color-accent-primary); }
11349
- .billing-legend-cache-hit { background: #3b82f6; }
11538
+ .billing-legend-cache-hit { background: #2075ffbf; }
11350
11539
  .billing-legend-cache-miss{ background: #f59e0b; }
11351
11540
  .billing-legend-output { background: #34d399; }
11352
11541
 
@@ -11397,7 +11586,7 @@ body.setup-mode[data-theme="dark"] {
11397
11586
  flex-shrink: 0;
11398
11587
  }
11399
11588
  .tooltip-total { background: var(--color-accent-primary); }
11400
- .tooltip-cache-hit { background: #3b82f6; }
11589
+ .tooltip-cache-hit { background: #2075ffbf; }
11401
11590
  .tooltip-cache-miss{ background: #f59e0b; }
11402
11591
  .tooltip-output { background: #34d399; }.tooltip-label {
11403
11592
  flex: 1;
@@ -11472,7 +11661,7 @@ body.setup-mode[data-theme="dark"] {
11472
11661
  background: #34d399;
11473
11662
  }
11474
11663
  .billing-bar-cache-read {
11475
- background: #3b82f6;
11664
+ background: #2075ffbf;
11476
11665
  }
11477
11666
  .billing-bar-cache-miss {
11478
11667
  background: #f59e0b;
@@ -11951,7 +12140,7 @@ body.setup-mode[data-theme="dark"] {
11951
12140
  }
11952
12141
 
11953
12142
  .swatch-indigo { background: #4f46e5; }
11954
- .swatch-aurora-blue { background: #3B82F6; }
12143
+ .swatch-aurora-blue { background: #2075ffbf; }
11955
12144
  .swatch-forest-green { background: #10B981; }
11956
12145
  .swatch-sunrise-orange { background: #F59E0B; }
11957
12146
  .swatch-rose-violet { background: #8B5CF6; }
@@ -12172,7 +12361,7 @@ body.setup-mode[data-theme="dark"] {
12172
12361
  color: var(--color-accent-primary);
12173
12362
  }
12174
12363
  .billing-cell-hit {
12175
- color: #3b82f6;
12364
+ color: #2075ffbf;
12176
12365
  }
12177
12366
  .billing-cell-miss {
12178
12367
  color: #f59e0b;
@@ -97,7 +97,11 @@ const Router = (() => {
97
97
  if (h === "mcp") return { view: "mcp", params: {} };
98
98
  if (h === "extensions") return { view: "extensions", params: {} };
99
99
  const mExtDetail = h.match(/^extensions\/(.+)$/);
100
- if (mExtDetail) return { view: "extensions", params: { detailId: mExtDetail[1] } };
100
+ if (mExtDetail) {
101
+ const [detailId, qs] = mExtDetail[1].split("?");
102
+ const source = new URLSearchParams(qs || "").get("source") || null;
103
+ return { view: "extensions", params: { detailId, source } };
104
+ }
101
105
  if (h === "trash") return { view: "trash", params: {} };
102
106
  if (h === "profile") return { view: "profile", params: {} };
103
107
  // Legacy: #memories redirects to #profile (memories are now merged into
@@ -256,8 +260,9 @@ const Router = (() => {
256
260
  case "extensions":
257
261
  $("extensions-panel").style.display = "flex";
258
262
  if (params.detailId) {
259
- _setHash(`extensions/${params.detailId}`);
260
- Extensions.onPanelShow({ detailId: params.detailId });
263
+ const sourceQs = params.source ? `?source=${encodeURIComponent(params.source)}` : "";
264
+ _setHash(`extensions/${params.detailId}${sourceQs}`);
265
+ Extensions.onPanelShow({ detailId: params.detailId, source: params.source });
261
266
  } else {
262
267
  _setHash("extensions");
263
268
  Extensions.onPanelShow();
@@ -1,4 +1,4 @@
1
- /* global CM */
1
+ /* global CM, marked */
2
2
  /**
3
3
  * CodeEditor — a reusable wrapper around CodeMirror 6.
4
4
  *
@@ -31,16 +31,20 @@
31
31
  }
32
32
 
33
33
  function _detectLanguage(filename) {
34
- if (!filename) return "markdown";
34
+ if (!filename) return null;
35
35
  const ext = filename.split(".").pop().toLowerCase();
36
36
  const map = { md: "markdown", markdown: "markdown" };
37
- return map[ext] || "markdown";
37
+ return map[ext] || null;
38
38
  }
39
39
 
40
40
  function _isDark() {
41
41
  return document.documentElement.getAttribute("data-theme") === "dark";
42
42
  }
43
43
 
44
+ function _isMarkdown(language) {
45
+ return language === "markdown" || language === "md";
46
+ }
47
+
44
48
  function _buildExtensions(opts) {
45
49
  const extensions = [
46
50
  CM.lineNumbers(),
@@ -86,9 +90,24 @@
86
90
  }]));
87
91
  }
88
92
 
93
+ if (opts.onChange) {
94
+ extensions.push(CM.EditorView.updateListener.of((update) => {
95
+ if (update.docChanged) opts.onChange(update.state.doc.toString());
96
+ }));
97
+ }
98
+
89
99
  return extensions;
90
100
  }
91
101
 
102
+ function _renderMarkdownHtml(text) {
103
+ if (typeof marked !== "undefined") {
104
+ try {
105
+ return marked.parse(text, { breaks: true, gfm: true });
106
+ } catch (_) { /* fall through */ }
107
+ }
108
+ return `<pre>${text.replace(/</g, "&lt;")}</pre>`;
109
+ }
110
+
92
111
  function open(opts) {
93
112
  const {
94
113
  content = "",
@@ -101,6 +120,7 @@
101
120
 
102
121
  const kind = opts.kind || (opts.filename ? _fileKind(opts.filename) : "text");
103
122
  const language = opts.language || _detectLanguage(opts.filename);
123
+ const isMd = _isMarkdown(language) && kind !== "image";
104
124
 
105
125
  let overlay = document.getElementById("code-editor-overlay");
106
126
  if (overlay) overlay.remove();
@@ -110,8 +130,8 @@
110
130
  overlay.className = "modal-overlay";
111
131
 
112
132
  const cancelLabel = I18n.t("modal.cancel");
113
- const closeLabel = I18n.t("modal.close");
114
- const saveLabel = I18n.t("modal.save");
133
+ const closeLabel = I18n.t("modal.close");
134
+ const saveLabel = I18n.t("modal.save");
115
135
 
116
136
  const isReadOnlyOrImage = readOnly || kind === "image";
117
137
  const footerActions = isReadOnlyOrImage
@@ -119,12 +139,14 @@
119
139
  : `<button class="btn btn-secondary code-editor-cancel">${cancelLabel}</button><button class="btn btn-primary code-editor-save">${saveLabel}</button>`;
120
140
 
121
141
  overlay.innerHTML = `
122
- <div class="code-editor-modal${kind === "image" ? " code-editor-modal--image" : ""}">
142
+ <div class="code-editor-modal${kind === "image" ? " code-editor-modal--image" : ""}${isMd ? " code-editor-modal--md" : ""}">
123
143
  <div class="code-editor-header">
124
144
  <h3 class="code-editor-title"></h3>
125
145
  <button class="code-editor-close" title="${closeLabel}">&times;</button>
126
146
  </div>
127
- <div class="code-editor-body"></div>
147
+ <div class="code-editor-body${isMd ? " code-editor-body--split" : ""}">
148
+ ${isMd ? `<div class="code-editor-split-left"></div><div class="code-editor-split-divider"></div><div class="code-editor-split-right"><div class="code-editor-markdown-preview"></div></div>` : ""}
149
+ </div>
128
150
  <div class="code-editor-footer">
129
151
  <span class="code-editor-status"></span>
130
152
  <div class="code-editor-actions">${footerActions}</div>
@@ -134,8 +156,8 @@
134
156
  document.body.appendChild(overlay);
135
157
  overlay.querySelector(".code-editor-title").textContent = title;
136
158
 
137
- const body = overlay.querySelector(".code-editor-body");
138
- const status = overlay.querySelector(".code-editor-status");
159
+ const body = overlay.querySelector(".code-editor-body");
160
+ const status = overlay.querySelector(".code-editor-status");
139
161
  const closeBtn = overlay.querySelector(".code-editor-close");
140
162
  const cancelBtn = overlay.querySelector(".code-editor-cancel");
141
163
  const saveBtn = overlay.querySelector(".code-editor-save");
@@ -147,7 +169,6 @@
147
169
 
148
170
  closeBtn.addEventListener("click", close);
149
171
  if (cancelBtn) cancelBtn.addEventListener("click", close);
150
- overlay.addEventListener("click", (e) => { if (e.target === overlay) close(); });
151
172
 
152
173
  if (kind === "image") {
153
174
  body.classList.add("code-editor-body--image");
@@ -159,7 +180,21 @@
159
180
  return { close };
160
181
  }
161
182
 
162
- const editorOpts = { language, readOnly, onSave: null, _getContent: null };
183
+ // Editor mount target: left pane for markdown split, body itself otherwise
184
+ const editorParent = isMd ? overlay.querySelector(".code-editor-split-left") : body;
185
+ const previewPane = isMd ? overlay.querySelector(".code-editor-markdown-preview") : null;
186
+
187
+ function updatePreview(text) {
188
+ if (previewPane) previewPane.innerHTML = _renderMarkdownHtml(text);
189
+ }
190
+
191
+ const editorOpts = {
192
+ language: language || "markdown",
193
+ readOnly,
194
+ onSave: null,
195
+ _getContent: null,
196
+ onChange: isMd ? updatePreview : null,
197
+ };
163
198
  const getContent = () => view.state.doc.toString();
164
199
  editorOpts._getContent = getContent;
165
200
  editorOpts.onSave = onSave ? () => doSave() : null;
@@ -169,9 +204,12 @@
169
204
  doc: content,
170
205
  extensions: _buildExtensions(editorOpts),
171
206
  }),
172
- parent: body,
207
+ parent: editorParent,
173
208
  });
174
209
 
210
+ // Render initial preview
211
+ if (isMd) updatePreview(content);
212
+
175
213
  async function doSave() {
176
214
  if (!onSave) return;
177
215
  if (saveBtn) saveBtn.disabled = true;
@@ -138,7 +138,8 @@ const BillingView = (() => {
138
138
  <div class="billing-period-group">${periodBtns}</div>
139
139
  <select id="billing-model-filter" class="billing-model-filter">${modelOptions}</select>
140
140
  <button id="billing-share-btn" class="billing-share-btn" title="${I18n.t('billing.share.tooltip') || 'Share scorecard'}">
141
- 📤 ${I18n.t('billing.share.btn') || 'Share scorecard'}
141
+ <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>
142
+ ${I18n.t('billing.share.btn') || 'Share scorecard'}
142
143
  </button>
143
144
  <div class="billing-clear-container">
144
145
  <button id="billing-clear-btn" class="billing-clear-btn" title="${I18n.t('billing.clearData') || 'Clear Data'}">
@@ -152,6 +153,11 @@ const BillingView = (() => {
152
153
  </div>
153
154
  </div>
154
155
 
156
+ <div class="billing-disclaimer">
157
+ <svg width="13" height="13" viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg"><circle cx="8" cy="8" r="7" stroke="currentColor" stroke-width="1.4"/><path d="M8 7v4" stroke="currentColor" stroke-width="1.4" stroke-linecap="round"/><circle cx="8" cy="5" r="0.7" fill="currentColor"/></svg>
158
+ ${I18n.t("billing.disclaimer")}
159
+ </div>
160
+
155
161
  <div class="billing-stats-row">
156
162
  <div class="billing-stat-card">
157
163
  <div class="billing-stat-icon billing-stat-icon-cost">
@@ -158,7 +158,11 @@ const ExtensionsStore = (() => {
158
158
  try {
159
159
  const res = await fetch("/api/store/extensions/brand");
160
160
  const data = await res.json();
161
- _extensions = data.extensions || [];
161
+ const all = data.extensions || [];
162
+ _extensions = _query
163
+ ? all.filter(e => [e.name, e.name_zh, e.description].some(
164
+ f => f && f.toLowerCase().includes(_query.toLowerCase())))
165
+ : all;
162
166
  _error = data.warning || null;
163
167
  _loading = false;
164
168
  _emit("extensions:changed", { extensions: _extensions, warning: _error });
@@ -174,14 +178,18 @@ const ExtensionsStore = (() => {
174
178
  },
175
179
 
176
180
  /** Open the detail view for one extension (fetches contributes + versions). */
177
- async loadDetail(id) {
181
+ async loadDetail(id, source) {
178
182
  if (!id) return;
179
183
  _detail = null;
180
184
  _detailLoading = true;
181
185
  _detailError = null;
182
186
  _emit("extensions:detail");
183
187
  try {
184
- const res = await fetch("/api/store/extension?id=" + encodeURIComponent(id));
188
+ const isBrand = source === "brand" || _filterBrand;
189
+ const url = isBrand
190
+ ? "/api/store/extension?id=" + encodeURIComponent(id) + "&source=brand"
191
+ : "/api/store/extension?id=" + encodeURIComponent(id);
192
+ const res = await fetch(url);
185
193
  const data = await res.json();
186
194
  if (res.ok && data.ok && data.extension) {
187
195
  _detail = data.extension;
@@ -243,10 +251,18 @@ const ExtensionsStore = (() => {
243
251
  async install(id) {
244
252
  if (!id) return;
245
253
  try {
246
- const detailRes = await fetch("/api/store/extension?id=" + encodeURIComponent(id));
247
- const detailData = await detailRes.json();
248
- if (!detailRes.ok || !detailData.ok) throw new Error(detailData.error || "fetch detail failed");
249
- const ext = detailData.extension;
254
+ // Prefer the already-loaded detail (avoids a second round-trip and correctly
255
+ // handles brand-private extensions whose detail was fetched with &source=brand).
256
+ let ext;
257
+ if (_detail && String(_detail.id) === String(id)) {
258
+ ext = _detail;
259
+ } else {
260
+ const source = _filterBrand ? "&source=brand" : "";
261
+ const detailRes = await fetch("/api/store/extension?id=" + encodeURIComponent(id) + source);
262
+ const detailData = await detailRes.json();
263
+ if (!detailRes.ok || !detailData.ok) throw new Error(detailData.error || "fetch detail failed");
264
+ ext = detailData.extension;
265
+ }
250
266
  const download_url = ext.download_url;
251
267
  if (!download_url) throw new Error("No download URL available");
252
268
  const res = await fetch("/api/store/extension/install", {
@@ -121,7 +121,8 @@ const ExtensionsView = (() => {
121
121
 
122
122
  const card = document.createElement("div");
123
123
  card.className = "extension-card extension-card-clickable";
124
- card.dataset.extId = ext.id != null ? String(ext.id) : (ext.name || "");
124
+ card.dataset.extId = ext.id != null ? String(ext.id) : (ext.name || "");
125
+ card.dataset.extOrigin = ext.origin || "";
125
126
  card.innerHTML = `
126
127
  <div class="extension-card-main">
127
128
  ${emojiHtml}
@@ -474,8 +475,11 @@ const ExtensionsView = (() => {
474
475
  const card = e.target.closest(".extension-card-clickable");
475
476
  if (card && card.dataset.extId) {
476
477
  const router = window.Clacky && window.Clacky.Router;
477
- if (router) router.navigate("extensions", { detailId: card.dataset.extId });
478
- else Extensions.loadDetail(card.dataset.extId);
478
+ // Use origin stored on the card; origin="self" means brand-private extension.
479
+ const isBrand = card.dataset.extOrigin === "self" || Extensions.state.filterBrand;
480
+ const source = isBrand ? "brand" : null;
481
+ if (router) router.navigate("extensions", { detailId: card.dataset.extId, source });
482
+ else Extensions.loadDetail(card.dataset.extId, source);
479
483
  }
480
484
  });
481
485
  }
@@ -506,7 +510,7 @@ const ExtensionsView = (() => {
506
510
  const detailId = opts && opts.detailId;
507
511
  if (detailId) {
508
512
  Extensions.load();
509
- Extensions.loadDetail(detailId);
513
+ Extensions.loadDetail(detailId, opts && opts.source);
510
514
  } else {
511
515
  Extensions.closeDetail();
512
516
  Extensions.load();