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
@@ -5,7 +5,12 @@
5
5
  // chrome so slot re-renders never disturb width or collapse state.
6
6
  //
7
7
  // - drag #session-aside-resize to change width (persisted)
8
+ // - panels ask for extra width via Clacky.Aside.requestWidth(); the clamp
9
+ // lives here so no caller can push the mobile drawer off-screen
8
10
  // - #btn-aside-collapse hides the column; #btn-aside-open brings it back
11
+ // - #btn-aside-fullscreen hides the chat column and lets the aside take
12
+ // the whole workspace (Esc or the button again to leave; collapsing the
13
+ // panel also leaves fullscreen)
9
14
  // - when the slot is empty (no panels for this agent) CSS collapses the
10
15
  // column on its own; the opener stays hidden in that case
11
16
  //
@@ -16,11 +21,106 @@
16
21
  (() => {
17
22
  const WIDTH_KEY = "clacky.aside.width";
18
23
  const OPEN_KEY = "clacky.aside.open";
24
+ const FULLSCREEN_KEY = "clacky.aside.fullscreen";
19
25
  const MIN_W = 256;
20
26
  const maxW = () => Math.max(MIN_W, Math.round(window.innerWidth * 0.8));
27
+ // Matches the CSS breakpoint where the aside becomes a fixed overlay drawer.
28
+ const isNarrow = () => window.matchMedia("(max-width: 768px)").matches;
29
+ const widthRequests = new Map();
30
+ let nextWidthRequestId = 1;
31
+ let widthBeforeRequests = null;
32
+ let appliedRequestWidth = null;
33
+ let manuallyResizedWhileRequested = false;
21
34
 
22
35
  const $ = (id) => document.getElementById(id);
23
36
 
37
+ // Custom properties resolve to the authored token, not px — the CSS default
38
+ // is 16rem, so it needs converting. Every width set here (and the persisted
39
+ // one) is already px.
40
+ function currentWidth() {
41
+ const aside = $("session-aside");
42
+ if (!aside) return 0;
43
+ let raw = "";
44
+ try {
45
+ raw = getComputedStyle(aside).getPropertyValue("--session-aside-width").trim();
46
+ } catch (_e) { return 0; }
47
+ const n = parseFloat(raw);
48
+ if (!isFinite(n)) return 0;
49
+ if (/rem$/.test(raw)) {
50
+ const root = parseFloat(getComputedStyle(document.documentElement).fontSize) || 16;
51
+ return n * root;
52
+ }
53
+ return n;
54
+ }
55
+
56
+ function setWidth(px) {
57
+ const aside = $("session-aside");
58
+ if (aside) aside.style.setProperty("--session-aside-width", px + "px");
59
+ }
60
+
61
+ // Reconcile temporary requests centrally so nested panels can close in any
62
+ // order. For example, closing a Files preview while an 860px Git diff is
63
+ // still open must not make the later Git release restore a stale 700px width.
64
+ function reconcileRequestedWidths() {
65
+ if (!$('session-aside')) return;
66
+ const width = currentWidth();
67
+
68
+ if (widthRequests.size === 0) {
69
+ if (!manuallyResizedWhileRequested && widthBeforeRequests !== null &&
70
+ appliedRequestWidth !== null && Math.round(width) === appliedRequestWidth) {
71
+ setWidth(widthBeforeRequests);
72
+ }
73
+ widthBeforeRequests = null;
74
+ appliedRequestWidth = null;
75
+ manuallyResizedWhileRequested = false;
76
+ return;
77
+ }
78
+
79
+ if (widthBeforeRequests === null) widthBeforeRequests = width;
80
+ if (manuallyResizedWhileRequested) return;
81
+ const requested = Math.min(maxW(), Math.max(...widthRequests.values()));
82
+ const desired = Math.max(widthBeforeRequests, requested);
83
+ if (Math.round(width) !== Math.round(desired)) setWidth(desired);
84
+ appliedRequestWidth = Math.round(desired);
85
+ }
86
+
87
+ // Panels that need horizontal room (diff, file preview) ask for a wider
88
+ // aside. Narrow layouts deliberately ignore temporary requests because the
89
+ // aside is a fixed mobile drawer there. Persistent requests remain available
90
+ // for legacy panels that intentionally update the user's preferred width.
91
+ function requestWidth(px, opts) {
92
+ const aside = $("session-aside");
93
+ if (!aside || isNarrow()) return null;
94
+ const requested = Number(px);
95
+ if (!Number.isFinite(requested) || requested <= 0) return null;
96
+ const target = Math.min(maxW(), Math.max(MIN_W, requested));
97
+ if (opts && opts.persist) {
98
+ const preferred = Math.max(currentWidth(), target);
99
+ setWidth(preferred);
100
+ try { localStorage.setItem(WIDTH_KEY, String(preferred)); } catch (_e) { /* non-fatal */ }
101
+ if (widthRequests.size > 0) widthBeforeRequests = preferred;
102
+ return null;
103
+ }
104
+
105
+ const id = nextWidthRequestId++;
106
+ widthRequests.set(id, target);
107
+ reconcileRequestedWidths();
108
+ let released = false;
109
+ return () => {
110
+ if (released) return;
111
+ released = true;
112
+ widthRequests.delete(id);
113
+ reconcileRequestedWidths();
114
+ };
115
+ }
116
+
117
+ function clampWidth() {
118
+ const w = currentWidth();
119
+ if (!w) return;
120
+ const max = maxW();
121
+ if (w > max) setWidth(max);
122
+ }
123
+
24
124
  function slotEmpty() {
25
125
  const slot = $("ext-slot-session-aside");
26
126
  return !slot || slot.childElementCount === 0;
@@ -40,6 +140,9 @@
40
140
  aside.classList.toggle("collapsed", !open);
41
141
  if (opener) opener.style.display = (!open && !empty) ? "" : "none";
42
142
  if (overlay) overlay.classList.toggle("active", open && !empty);
143
+ // A fullscreen class with an empty slot would show an empty column over
144
+ // the whole workspace (the fullscreen rule outranks the empty-slot one).
145
+ if (empty) setFullscreen(false);
43
146
  }
44
147
 
45
148
  function isOpen() {
@@ -51,9 +154,28 @@
51
154
 
52
155
  function setOpen(open) {
53
156
  try { localStorage.setItem(OPEN_KEY, open ? "1" : "0"); } catch (_e) { /* ignore */ }
157
+ if (!open) setFullscreen(false);
54
158
  applyOpenState();
55
159
  }
56
160
 
161
+ function isFullscreen() {
162
+ try { return localStorage.getItem(FULLSCREEN_KEY) === "1"; } catch (_e) { return false; }
163
+ }
164
+
165
+ function applyFullscreenState() {
166
+ const chatPanel = $("chat-panel");
167
+ if (chatPanel) chatPanel.classList.toggle("aside-fullscreen", isFullscreen());
168
+ const btn = $("btn-aside-fullscreen");
169
+ if (btn && window.Clacky && Clacky.I18n) {
170
+ btn.title = Clacky.I18n.t(isFullscreen() ? "aside.exitFullscreen" : "aside.fullscreen");
171
+ }
172
+ }
173
+
174
+ function setFullscreen(on) {
175
+ try { localStorage.setItem(FULLSCREEN_KEY, on ? "1" : "0"); } catch (_e) { /* ignore */ }
176
+ applyFullscreenState();
177
+ }
178
+
57
179
  function initResize() {
58
180
  const aside = $("session-aside");
59
181
  const handle = $("session-aside-resize");
@@ -72,7 +194,7 @@
72
194
  e.preventDefault();
73
195
  dragging = true;
74
196
  startX = e.clientX;
75
- startW = parseFloat(getComputedStyle(aside).getPropertyValue("--session-aside-width"));
197
+ startW = currentWidth();
76
198
  handle.classList.add("active");
77
199
  aside.style.transition = "none";
78
200
  document.body.style.cursor = "col-resize";
@@ -93,8 +215,13 @@
93
215
  aside.style.transition = "";
94
216
  document.body.style.cursor = "";
95
217
  document.body.style.userSelect = "";
96
- const w = parseFloat(getComputedStyle(aside).getPropertyValue("--session-aside-width"));
218
+ const w = currentWidth();
97
219
  try { localStorage.setItem(WIDTH_KEY, w); } catch (_e) { /* ignore */ }
220
+ if (widthRequests.size > 0) {
221
+ widthBeforeRequests = w;
222
+ appliedRequestWidth = null;
223
+ manuallyResizedWhileRequested = true;
224
+ }
98
225
  });
99
226
  }
100
227
 
@@ -102,25 +229,47 @@
102
229
  // Expose setOpen so external code (e.g. WS dispatcher) can open/close the
103
230
  // aside panel programmatically without duplicating localStorage logic here.
104
231
  // open() is a no-op when the panel is already open — safe to call unconditionally.
105
- if (window.Clacky) Clacky.Aside = {
232
+ const publicApi = () => ({
106
233
  open: () => { if (!isOpen()) setOpen(true); },
107
234
  close: () => setOpen(false),
108
- };
235
+ fullscreen: (on) => setFullscreen(on !== false),
236
+ requestWidth: requestWidth,
237
+ });
238
+ if (window.Clacky) Clacky.Aside = publicApi();
109
239
 
110
240
  function init() {
111
241
  // Re-assign in case Clacky was not yet defined when the IIFE ran.
112
- if (window.Clacky) Clacky.Aside = {
113
- open: () => { if (!isOpen()) setOpen(true); },
114
- close: () => setOpen(false),
115
- };
242
+ if (window.Clacky) Clacky.Aside = publicApi();
116
243
  const collapse = $("btn-aside-collapse");
117
244
  const opener = $("btn-aside-open");
118
245
  const overlay = $("workspace-overlay");
246
+ const fsBtn = $("btn-aside-fullscreen");
119
247
  if (collapse) collapse.addEventListener("click", () => setOpen(false));
120
248
  if (opener) opener.addEventListener("click", () => setOpen(true));
121
249
  if (overlay) overlay.addEventListener("click", () => setOpen(false));
250
+ if (fsBtn) fsBtn.addEventListener("click", () => setFullscreen(!isFullscreen()));
251
+ document.addEventListener("keydown", (e) => {
252
+ if (e.key !== "Escape" || !isFullscreen()) return;
253
+ // Let overlay panels (settings / skills) consume Esc first.
254
+ const blocked = ["settings-panel", "skills-panel"].some((id) => {
255
+ const el = document.getElementById(id);
256
+ return el && el.style.display && el.style.display !== "none";
257
+ });
258
+ if (!blocked) setFullscreen(false);
259
+ });
122
260
  initResize();
123
261
  applyOpenState();
262
+ applyFullscreenState();
263
+
264
+ // A width set while the window was wide survives into the narrow layout,
265
+ // where the aside is a fixed drawer — clamp it back on resize so it cannot
266
+ // sit off-screen. (Persisted widths are already filtered on load.)
267
+ let clampScheduled = false;
268
+ window.addEventListener("resize", () => {
269
+ if (clampScheduled) return;
270
+ clampScheduled = true;
271
+ requestAnimationFrame(() => { clampScheduled = false; clampWidth(); });
272
+ });
124
273
 
125
274
  // Re-evaluate opener visibility whenever the slot content changes (panels
126
275
  // re-render on session / agent switch).
@@ -48,6 +48,7 @@ Clacky.ext = (() => {
48
48
  const _dataSources = {}; // name => fn
49
49
  const _subscribers = {}; // event => [handler]
50
50
  const _slotRenderers = {}; // slot => [{ fn, extId, agents, panel, order, tab }]
51
+ const _slotTabActivators = {}; // tabbed slot => activate(tabId) from the latest render
51
52
  const _workspaces = {}; // id => { id, title, render, extId }
52
53
  let _currentExtId = null; // set while an extension file is loading
53
54
 
@@ -581,6 +582,9 @@ Clacky.ext = (() => {
581
582
  // render pass. The host chrome (resize / collapse) lives in the surrounding
582
583
  // DOM and is not touched here. Re-rendered wholesale on every session switch.
583
584
  function renderTabbedSlot(slot, container, ctx) {
585
+ // Invalidate any activator captured by a previous render of this slot;
586
+ // if this pass finds no renderers it must stay invalid.
587
+ _slotTabActivators[slot] = null;
584
588
  container.replaceChildren();
585
589
  const profile = (ctx && ctx.agentProfile) || context.agentProfile;
586
590
  const renderCtx = Object.assign({}, context, ctx || {});
@@ -607,6 +611,7 @@ Clacky.ext = (() => {
607
611
  const rendered = {};
608
612
 
609
613
  function activate(id) {
614
+ if (!tabBtns[id]) return false;
610
615
  Object.keys(tabBtns).forEach((k) => tabBtns[k].classList.toggle("active", k === id));
611
616
  Object.keys(bodyEls).forEach((k) => bodyEls[k].classList.toggle("active", k === id));
612
617
  if (!rendered[id]) {
@@ -624,7 +629,9 @@ Clacky.ext = (() => {
624
629
  else body.appendChild(node);
625
630
  }
626
631
  }
632
+ return true;
627
633
  }
634
+ _slotTabActivators[slot] = activate;
628
635
 
629
636
  renderers.forEach((entry) => {
630
637
  const id = entry.tab.id;
@@ -698,6 +705,14 @@ Clacky.ext = (() => {
698
705
  return Object.keys(_slotRenderers).filter((s) => _slotRenderers[s].length > 0);
699
706
  }
700
707
 
708
+ // Programmatically activate a tab in a tabbed slot (e.g. switch the session
709
+ // aside to the preview tab when the user clicks an HTML file or a localhost
710
+ // link in chat). Returns true when the slot is rendered and the tab exists.
711
+ function activateTab(slot, tabId) {
712
+ const activate = _slotTabActivators[slot];
713
+ return activate ? !!activate(tabId) : false;
714
+ }
715
+
701
716
  // Update the current session context (host calls this on every session
702
717
  // switch) and re-render all slots so panels match the new agent profile.
703
718
  // Runs the previous session's view-level teardowns first (event listeners /
@@ -756,6 +771,7 @@ Clacky.ext = (() => {
756
771
  emit,
757
772
  renderSlot,
758
773
  slots,
774
+ activateTab,
759
775
  // Host-only: look up a registered workspace by id. Returns
760
776
  // { id, title, render, extId } or undefined. Used by the Router when
761
777
  // handling the `ext-workspace` view — extensions never call this.
@@ -290,6 +290,37 @@ const BillingView = (() => {
290
290
 
291
291
  // ── Tooltips ─────────────────────────────────────────────────────────────────
292
292
 
293
+ // Measured while hidden so the real size is known before choosing a side.
294
+ // The clamp matters on narrow viewports: flipping a 200px-wide tooltip left of
295
+ // a cursor near the left edge would otherwise push it off-screen.
296
+ //
297
+ // Below MOBILE_TOOLTIP_MAX_WIDTH the tooltip is as wide as 58%-73% of the chart,
298
+ // so following the pointer covers the data no matter which side it flips to.
299
+ // There it docks just outside `chart` instead, keeping the plot fully visible.
300
+ const MOBILE_TOOLTIP_MAX_WIDTH = 768;
301
+
302
+ function _placeTooltip(tooltip, e, chart) {
303
+ tooltip.style.display = "block";
304
+ tooltip.style.visibility = "hidden";
305
+ const rect = tooltip.getBoundingClientRect();
306
+
307
+ if (chart && window.innerWidth <= MOBILE_TOOLTIP_MAX_WIDTH) {
308
+ const chartRect = chart.getBoundingClientRect();
309
+ const below = chartRect.bottom + 8;
310
+ const top = below + rect.height > window.innerHeight ? chartRect.top - 8 - rect.height : below;
311
+ tooltip.style.left = `${Math.max(8, Math.min(chartRect.left, window.innerWidth - 8 - rect.width))}px`;
312
+ tooltip.style.top = `${Math.max(8, top)}px`;
313
+ tooltip.style.visibility = "visible";
314
+ return;
315
+ }
316
+
317
+ const ovfX = e.clientX + 15 + rect.width - window.innerWidth;
318
+ const ovfY = e.clientY - 10 + rect.height - window.innerHeight;
319
+ tooltip.style.left = ovfX > 0 ? `${Math.max(8, e.clientX - 15 - rect.width)}px` : `${e.clientX + 15}px`;
320
+ tooltip.style.top = ovfY > 0 ? `${Math.max(8, e.clientY - 10 - rect.height)}px` : `${e.clientY - 10}px`;
321
+ tooltip.style.visibility = "visible";
322
+ }
323
+
293
324
  function _bindChartTooltip() {
294
325
  const container = document.getElementById("billing-chart-container");
295
326
  const tooltip = document.getElementById("billing-tooltip");
@@ -334,14 +365,7 @@ const BillingView = (() => {
334
365
  <span class="tooltip-value">${output}</span>
335
366
  </div>
336
367
  `;
337
- tooltip.style.display = "block";
338
- tooltip.style.visibility = "hidden";
339
- const rect = tooltip.getBoundingClientRect();
340
- const ovfX = e.clientX + 15 + rect.width - window.innerWidth;
341
- const ovfY = e.clientY - 10 + rect.height - window.innerHeight;
342
- tooltip.style.left = ovfX > 0 ? `${e.clientX - 15 - rect.width}px` : `${e.clientX + 15}px`;
343
- tooltip.style.top = ovfY > 0 ? `${e.clientY - 10 - rect.height}px` : `${e.clientY - 10}px`;
344
- tooltip.style.visibility = "visible";
368
+ _placeTooltip(tooltip, e, container);
345
369
  });
346
370
 
347
371
  container.addEventListener("mouseleave", () => {
@@ -376,27 +400,59 @@ const BillingView = (() => {
376
400
  <span class="tooltip-value">${cell.dataset.cost}</span>
377
401
  </div>
378
402
  `;
379
- tooltip.style.display = "block";
380
- tooltip.style.left = `${e.clientX + 15}px`;
381
- tooltip.style.top = `${e.clientY - 10}px`;
403
+ _placeTooltip(tooltip, e, grid);
382
404
  });
383
405
 
384
406
  grid.addEventListener("mouseleave", () => {
385
407
  tooltip.style.display = "none";
408
+ tooltip.style.visibility = "";
386
409
  });
387
410
  }
388
411
 
412
+ // The dots are r="3" in viewBox units, which the responsive SVG scales down to
413
+ // under 5px on a phone — far too small to hit. Snap to the nearest point on the
414
+ // x axis instead so the whole column width is clickable.
415
+ function _nearestTrendDot(svg, clientX, clientY) {
416
+ const count = parseInt(svg.dataset.count, 10);
417
+ const xStep = parseFloat(svg.dataset.xStep);
418
+ const plotLeft = parseFloat(svg.dataset.plotLeft);
419
+ if (!count || !xStep) return null;
420
+ const ctm = svg.getScreenCTM();
421
+ if (!ctm) return null;
422
+ const point = svg.createSVGPoint();
423
+ point.x = clientX;
424
+ point.y = clientY;
425
+ const x = point.matrixTransform(ctm.inverse()).x;
426
+ const index = Math.max(0, Math.min(count - 1, Math.round((x - plotLeft) / xStep)));
427
+ return svg.querySelector(`.billing-trend-dot[data-index="${index}"]`);
428
+ }
429
+
389
430
  function _bindTrendTooltip() {
390
431
  const svg = document.querySelector(".billing-trend-svg");
391
432
  const tooltip = document.getElementById("billing-tooltip");
392
433
  if (!svg || !tooltip) return;
393
434
 
435
+ const crosshairX = svg.querySelector(".billing-trend-crosshair-x");
436
+ const crosshairY = svg.querySelector(".billing-trend-crosshair-y");
437
+
394
438
  svg.addEventListener("mousemove", (e) => {
395
- const dot = e.target.closest(".billing-trend-dot");
439
+ const dot = _nearestTrendDot(svg, e.clientX, e.clientY);
396
440
  if (!dot) {
397
441
  tooltip.style.display = "none";
398
442
  return;
399
443
  }
444
+ svg.querySelectorAll(".billing-trend-dot.is-active").forEach(d => d.classList.remove("is-active"));
445
+ dot.classList.add("is-active");
446
+ if (crosshairX && crosshairY) {
447
+ const cx = dot.getAttribute("cx");
448
+ const cy = dot.getAttribute("cy");
449
+ crosshairX.setAttribute("x1", cx);
450
+ crosshairX.setAttribute("x2", cx);
451
+ crosshairY.setAttribute("y1", cy);
452
+ crosshairY.setAttribute("y2", cy);
453
+ crosshairX.classList.add("is-visible");
454
+ crosshairY.classList.add("is-visible");
455
+ }
400
456
  tooltip.innerHTML = `
401
457
  <div class="tooltip-header">
402
458
  <span class="tooltip-date">${dot.dataset.date}</span>
@@ -406,18 +462,14 @@ const BillingView = (() => {
406
462
  <span class="tooltip-value">${dot.dataset.cost}</span>
407
463
  </div>
408
464
  `;
409
- tooltip.style.display = "block";
410
- tooltip.style.visibility = "hidden";
411
- const rect = tooltip.getBoundingClientRect();
412
- const ovf = e.clientX + 15 + rect.width - window.innerWidth;
413
- tooltip.style.left = ovf > 0 ? `${e.clientX - 15 - rect.width}px` : `${e.clientX + 15}px`;
414
- tooltip.style.top = `${e.clientY - 10}px`;
415
- tooltip.style.visibility = "visible";
465
+ _placeTooltip(tooltip, e, svg);
416
466
  });
417
467
 
418
468
  svg.addEventListener("mouseleave", () => {
419
469
  tooltip.style.display = "none";
420
470
  tooltip.style.visibility = "";
471
+ svg.querySelectorAll(".billing-trend-dot.is-active").forEach(d => d.classList.remove("is-active"));
472
+ svg.querySelectorAll(".billing-trend-crosshair").forEach(l => l.classList.remove("is-visible"));
421
473
  });
422
474
  }
423
475
 
@@ -641,7 +693,7 @@ const BillingView = (() => {
641
693
  <h4>${I18n.t("billing.costTrend") || "Cost Trend"}</h4>
642
694
  </div>
643
695
  <div class="billing-trend-chart">
644
- <svg viewBox="0 0 ${w} ${h}" preserveAspectRatio="xMidYMid meet" class="billing-trend-svg">
696
+ <svg viewBox="0 0 ${w} ${h}" preserveAspectRatio="xMidYMid meet" class="billing-trend-svg" data-plot-left="${pad.left}" data-x-step="${xStep}" data-count="${costs.length}">
645
697
  ${yLabels.map(l => `
646
698
  <line x1="${pad.left}" y1="${l.y}" x2="${w - pad.right}" y2="${l.y}" class="billing-trend-grid-line" />
647
699
  <text x="${pad.left - 6}" y="${l.y + 4}" class="billing-trend-y-label">${currencySymbol}${_formatCost(l.val)}</text>
@@ -656,11 +708,13 @@ const BillingView = (() => {
656
708
  </linearGradient>
657
709
  </defs>
658
710
  <polygon points="${areaPoints}" fill="url(#billing-trend-grad)" class="billing-trend-area" />
711
+ <line class="billing-trend-crosshair billing-trend-crosshair-x" x1="0" y1="${pad.top}" x2="0" y2="${pad.top + plotH}" />
712
+ <line class="billing-trend-crosshair billing-trend-crosshair-y" x1="${pad.left}" y1="0" x2="${w - pad.right}" y2="0" />
659
713
  <polyline points="${points}" fill="none" class="billing-trend-line" />
660
714
  ${costs.map((c, i) => {
661
715
  const cx = pad.left + i * xStep;
662
716
  const cy = pad.top + plotH - ((c - minCost) / range) * plotH;
663
- return `<circle cx="${cx}" cy="${cy}" r="3" class="billing-trend-dot" data-date="${days[i].date}" data-cost="${currencySymbol}${_formatCost(c)}" />`;
717
+ return `<circle cx="${cx}" cy="${cy}" r="3" class="billing-trend-dot" data-index="${i}" data-date="${days[i].date}" data-cost="${currencySymbol}${_formatCost(c)}" />`;
664
718
  }).join("")}
665
719
  </svg>
666
720
  </div>
@@ -262,7 +262,7 @@ const SkillsStore = (() => {
262
262
  const res = await fetch(`/api/skills/${encodeURIComponent(name)}/content`);
263
263
  const data = await res.json();
264
264
  if (!data.ok) throw new Error(data.error || "Load failed");
265
- return { ok: true, content: data.content || "", path: data.path };
265
+ return { ok: true, content: data.content || "", path: data.path, fields: data.fields };
266
266
  } catch (e) {
267
267
  return { ok: false, error: e.message };
268
268
  }
@@ -285,6 +285,23 @@ const SkillsStore = (() => {
285
285
  }
286
286
  },
287
287
 
288
+ /** Update a custom skill's editable fields (name/name_zh/description/description_zh/body). */
289
+ async updateSkillFields(name, fields) {
290
+ try {
291
+ const res = await fetch(`/api/skills/${encodeURIComponent(name)}/content`, {
292
+ method: "PUT",
293
+ headers: { "Content-Type": "application/json" },
294
+ body: JSON.stringify({ fields })
295
+ });
296
+ const data = await res.json();
297
+ if (!res.ok || !data.ok) throw new Error(data.error || "Save failed");
298
+ await Skills.load();
299
+ return { ok: true };
300
+ } catch (e) {
301
+ return { ok: false, error: e.message };
302
+ }
303
+ },
304
+
288
305
  /** Import a skill: validate url/path, open a session, run /skill-add. */
289
306
  async importSkill(url) {
290
307
  const trimmed = (url || "").trim();
@@ -596,16 +596,137 @@ const SkillsView = (() => {
596
596
  async function _editSkill(skill) {
597
597
  const res = await Skills.fetchSkillContent(skill.name);
598
598
  if (!res.ok) { alert(I18n.t("skills.editFail") + ": " + res.error); return; }
599
+ _openSkillFormEditor(skill, res);
600
+ }
601
+
602
+ function _openSkillFormEditor(skill, res) {
603
+ const f = res.fields || {};
604
+
605
+ let overlay = document.getElementById("skill-editor-overlay");
606
+ if (overlay) overlay.remove();
607
+
608
+ overlay = document.createElement("div");
609
+ overlay.id = "skill-editor-overlay";
610
+ overlay.className = "modal-overlay";
611
+
612
+ const cancelLabel = I18n.t("modal.cancel");
613
+ const saveLabel = I18n.t("modal.save");
614
+
615
+ overlay.innerHTML = `
616
+ <div class="modal-box skill-editor-box">
617
+ <div class="modal-header">
618
+ <h3 class="modal-title"></h3>
619
+ <button class="modal-close-btn" title="${cancelLabel}"></button>
620
+ </div>
621
+ <div class="modal-body">
622
+ <div class="modal-field">
623
+ <label class="modal-label" for="se-name">${I18n.t("skills.edit.name")}</label>
624
+ <input type="text" id="se-name" class="modal-input skill-editor-name">
625
+ <span class="form-hint">${I18n.t("skills.edit.nameHint")}</span>
626
+ </div>
627
+ <div class="modal-field">
628
+ <label class="modal-label" for="se-name-zh">${I18n.t("skills.edit.nameZh")}</label>
629
+ <input type="text" id="se-name-zh" class="modal-input skill-editor-name-zh">
630
+ </div>
631
+ <div class="modal-field">
632
+ <label class="modal-label" for="se-desc">${I18n.t("skills.edit.description")}</label>
633
+ <textarea id="se-desc" class="modal-input skill-editor-description" rows="2"></textarea>
634
+ </div>
635
+ <div class="modal-field">
636
+ <label class="modal-label" for="se-desc-zh">${I18n.t("skills.edit.descriptionZh")}</label>
637
+ <textarea id="se-desc-zh" class="modal-input skill-editor-description-zh" rows="2"></textarea>
638
+ </div>
639
+ <div class="modal-field">
640
+ <label class="modal-label" for="se-body">${I18n.t("skills.edit.body")}</label>
641
+ <textarea id="se-body" class="modal-input skill-editor-body-input" rows="10"></textarea>
642
+ </div>
643
+ </div>
644
+ <div class="modal-footer">
645
+ <span class="skill-editor-status"></span>
646
+ <button class="btn-secondary skill-editor-cancel">${cancelLabel}</button>
647
+ <button class="btn-secondary skill-editor-markdown">${I18n.t("skills.edit.editMarkdown")}</button>
648
+ <button class="btn-primary skill-editor-save">${saveLabel}</button>
649
+ </div>
650
+ </div>`;
651
+
652
+ document.body.appendChild(overlay);
653
+ overlay.querySelector(".modal-title").textContent = I18n.t("skills.edit.title") + ": " + skill.name;
654
+
655
+ const nameInput = overlay.querySelector(".skill-editor-name");
656
+ const nameZhInput = overlay.querySelector(".skill-editor-name-zh");
657
+ const descInput = overlay.querySelector(".skill-editor-description");
658
+ const descZhInput = overlay.querySelector(".skill-editor-description-zh");
659
+ const bodyInput = overlay.querySelector(".skill-editor-body-input");
660
+ const status = overlay.querySelector(".skill-editor-status");
661
+ const closeBtn = overlay.querySelector(".modal-close-btn");
662
+ const cancelBtn = overlay.querySelector(".skill-editor-cancel");
663
+ const markdownBtn = overlay.querySelector(".skill-editor-markdown");
664
+ const saveBtn = overlay.querySelector(".skill-editor-save");
665
+
666
+ nameInput.value = f.name || skill.name || "";
667
+ nameZhInput.value = f.name_zh || "";
668
+ descInput.value = f.description || "";
669
+ descZhInput.value = f.description_zh || "";
670
+ bodyInput.value = f.body || "";
671
+
672
+ // Auto-grow textareas so the modal body is the only scroll container.
673
+ [descInput, descZhInput, bodyInput].forEach((ta) => {
674
+ const autoResize = () => {
675
+ ta.style.height = "auto";
676
+ ta.style.height = ta.scrollHeight + "px";
677
+ };
678
+ ta.addEventListener("input", autoResize);
679
+ autoResize();
680
+ });
599
681
 
600
- CodeEditor.open({
601
- content: res.content,
602
- title: skill.name,
603
- language: "markdown",
604
- onSave: async (newContent) => {
605
- const r = await Skills.updateSkillContent(skill.name, newContent);
606
- if (!r.ok) throw new Error(r.error);
682
+ function close() {
683
+ overlay.remove();
684
+ }
685
+
686
+ closeBtn.addEventListener("click", close);
687
+ cancelBtn.addEventListener("click", close);
688
+
689
+ markdownBtn.addEventListener("click", () => {
690
+ close();
691
+ CodeEditor.open({
692
+ content: res.content,
693
+ title: skill.name,
694
+ language: "markdown",
695
+ onSave: async (newContent) => {
696
+ const r = await Skills.updateSkillContent(skill.name, newContent);
697
+ if (!r.ok) throw new Error(r.error);
698
+ }
699
+ });
700
+ });
701
+
702
+ saveBtn.addEventListener("click", async () => {
703
+ const name = nameInput.value.trim();
704
+ if (!name) {
705
+ nameInput.classList.add("input-error");
706
+ nameInput.focus();
707
+ return;
607
708
  }
709
+
710
+ const fields = {
711
+ name: name,
712
+ name_zh: nameZhInput.value.trim(),
713
+ description: descInput.value.trim(),
714
+ description_zh: descZhInput.value.trim(),
715
+ body: bodyInput.value
716
+ };
717
+
718
+ saveBtn.disabled = true;
719
+ status.textContent = I18n.t("modal.saving");
720
+ status.className = "skill-editor-status";
721
+ const r = await Skills.updateSkillFields(skill.name, fields);
722
+ if (r.ok) { close(); return; }
723
+
724
+ status.textContent = I18n.t("skills.edit.saveFail") + (r.error ? ": " + r.error : "");
725
+ status.className = "skill-editor-status skill-editor-status-error";
726
+ saveBtn.disabled = false;
608
727
  });
728
+
729
+ setTimeout(() => nameInput.focus(), 50);
609
730
  }
610
731
 
611
732
  async function _doImportFromBar() {