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
@@ -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>
@@ -273,7 +273,10 @@ const ExtensionsStore = (() => {
273
273
  _error = null;
274
274
  _emit("extensions:loading");
275
275
  try {
276
- const res = await fetch("/api/store/extensions/brand");
276
+ const params = new URLSearchParams();
277
+ if (_sort) params.set("sort", _sort);
278
+ const qs = params.toString();
279
+ const res = await fetch("/api/store/extensions/brand" + (qs ? "?" + qs : ""));
277
280
  const data = await res.json();
278
281
  const all = data.extensions || [];
279
282
  const matched = _query
@@ -1,9 +1,9 @@
1
1
  // ── ModelTester · store — model connection test + save (shared helper) ────
2
2
  //
3
3
  // Network helpers shared by the onboarding wizard and the settings model modal:
4
- // test a model connection and persist a model config. No own panel, no state to
5
- // hold — it mirrors test/save outcomes onto the extension bus so extensions can
6
- // observe model-config changes.
4
+ // test a model connection, persist a model config, and list the models an
5
+ // endpoint advertises. No own panel, no state to hold — it mirrors test/save
6
+ // outcomes onto the extension bus so extensions can observe model-config changes.
7
7
  //
8
8
  // `ModelTester` stays the single public facade.
9
9
  //
@@ -72,7 +72,40 @@ window.ModelTester = (function () {
72
72
  }
73
73
  }
74
74
 
75
- return { testConnection, saveModel };
75
+ // Ask the server to query `<base_url>/models` for the ids the endpoint
76
+ // advertises. The key travels in the POST body (never the URL) because it
77
+ // is used for this one request only; a masked key is resolved server-side
78
+ // from the stored config via `id`/`index`, same as testConnection.
79
+ async function listModels({ base_url, api_key, api_format, index, id } = {}) {
80
+ const body = { base_url, api_key };
81
+ if (typeof id === "string" && id) body.id = id;
82
+ if (typeof index === "number") body.index = index;
83
+ if (api_format) body.api_format = api_format;
84
+
85
+ let data;
86
+ let status = 0;
87
+ try {
88
+ const res = await fetch("/api/config/models/list", {
89
+ method: "POST",
90
+ headers: { "Content-Type": "application/json" },
91
+ body: JSON.stringify(body)
92
+ });
93
+ status = res.status;
94
+ data = await res.json();
95
+ } catch (e) {
96
+ return { ok: false, message: e.message };
97
+ }
98
+
99
+ // Validation failures (400/422) carry `error` instead of `message`;
100
+ // never render an empty reason tail.
101
+ if (!data.ok) return { ok: false, message: data.message || data.error || ("HTTP " + status) };
102
+
103
+ const models = Array.isArray(data.models) ? data.models : [];
104
+ _emit("modeltester:listed", { base_url, count: models.length });
105
+ return { ok: true, models, message: data.message || "" };
106
+ }
107
+
108
+ return { testConnection, saveModel, listModels };
76
109
  })();
77
110
 
78
111
  const ModelTester = window.ModelTester;
@@ -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() {
@@ -13,6 +13,7 @@ const WorkspaceStore = (() => {
13
13
  let _sessionId = null;
14
14
  let _workingDir = null;
15
15
 
16
+ const _defaultAppCache = {};
16
17
  const _listeners = {};
17
18
 
18
19
  function _on(event, handler) {
@@ -30,7 +31,22 @@ const WorkspaceStore = (() => {
30
31
  }
31
32
 
32
33
  function _absPath(relPath) {
33
- return _workingDir.replace(/\/+$/, "") + "/" + relPath;
34
+ // Absolute input (POSIX, Windows drive letter, or ~-home) is used as-is so
35
+ // file:// links from chat can point outside the working directory. A
36
+ // leading ~ is expanded by the backend via File.expand_path.
37
+ if (/^([A-Za-z]:[\\/]|\/|~)/.test(relPath)) return relPath;
38
+ return (_workingDir || "").replace(/\/+$/, "") + "/" + relPath;
39
+ }
40
+
41
+ // Throw with the backend's error message when available so toasts show the
42
+ // real cause ("file not found", "unsupported OS", …) instead of "HTTP 404".
43
+ async function _respError(resp) {
44
+ let detail = `HTTP ${resp.status}`;
45
+ try {
46
+ const data = await resp.json();
47
+ if (data && data.error) detail = data.error;
48
+ } catch (_e) { /* non-JSON body */ }
49
+ throw new Error(detail);
34
50
  }
35
51
 
36
52
  const state = {
@@ -43,6 +59,9 @@ const WorkspaceStore = (() => {
43
59
  on: _on,
44
60
  state,
45
61
 
62
+ /** Resolve an entry-relative (or absolute) path to an absolute path. */
63
+ absPath(relPath) { return _absPath(relPath); },
64
+
46
65
  /** Update active session context. Returns { changed, hadSession }. */
47
66
  setSession(session) {
48
67
  const newId = session ? session.id : null;
@@ -55,11 +74,21 @@ const WorkspaceStore = (() => {
55
74
  return { changed, hadSession };
56
75
  },
57
76
 
77
+ /** ws-dispatcher signals a finished agent task; the view reloads the tree
78
+ * because the agent may have created/edited files under the working dir. */
79
+ notifyTaskCompleted(sessionId) {
80
+ _emit("workspace:taskCompleted", { sessionId });
81
+ },
82
+
58
83
  async fetchEntries(relPath) {
59
84
  const url = `/api/sessions/${encodeURIComponent(_sessionId)}/files?path=${encodeURIComponent(relPath || "")}`;
60
85
  const resp = await fetch(url);
61
- if (!resp.ok) throw new Error(`HTTP ${resp.status}`);
86
+ if (!resp.ok) await _respError(resp);
62
87
  const data = await resp.json();
88
+ // The root the server actually listed is the authoritative base for
89
+ // the relative entry paths it returns — keeps _absPath in sync with
90
+ // what the tree shows even if a session-context update lands stale.
91
+ if (data.root) _workingDir = data.root;
63
92
  return data.entries || [];
64
93
  },
65
94
 
@@ -69,7 +98,51 @@ const WorkspaceStore = (() => {
69
98
  headers: { "Content-Type": "application/json" },
70
99
  body: JSON.stringify({ path: _absPath(entry.path), action: "reveal" })
71
100
  });
72
- if (!resp.ok) throw new Error(`HTTP ${resp.status}`);
101
+ if (!resp.ok) await _respError(resp);
102
+ },
103
+
104
+ /** Installed applications that can open this file (macOS; [] elsewhere). */
105
+ async fetchOpenWithApps(entry) {
106
+ const resp = await fetch("/api/file/apps?path=" + encodeURIComponent(_absPath(entry.path)));
107
+ if (!resp.ok) return [];
108
+ const data = await resp.json();
109
+ return data.apps || [];
110
+ },
111
+
112
+ /** Default application the OS would use to open this file; null if none.
113
+ * Cached per extension so tab switches don't refetch. */
114
+ async fetchDefaultApp(entry) {
115
+ const ext = (entry.path.match(/\.([^./\\]+)$/) || [])[1];
116
+ if (!ext) return null;
117
+ const key = ext.toLowerCase();
118
+ if (Object.prototype.hasOwnProperty.call(_defaultAppCache, key)) {
119
+ return _defaultAppCache[key];
120
+ }
121
+ const resp = await fetch("/api/file/default-app?path=" + encodeURIComponent(_absPath(entry.path)));
122
+ const data = resp.ok ? await resp.json() : null;
123
+ const app = data && data.ok ? data.app || null : null;
124
+ _defaultAppCache[key] = app;
125
+ return app;
126
+ },
127
+
128
+ /** Open the file with one of the apps returned by fetchOpenWithApps. */
129
+ async openWithApp(entry, app) {
130
+ const resp = await fetch("/api/file-action", {
131
+ method: "POST",
132
+ headers: { "Content-Type": "application/json" },
133
+ body: JSON.stringify({ path: _absPath(entry.path), action: "open_with", app })
134
+ });
135
+ if (!resp.ok) await _respError(resp);
136
+ },
137
+
138
+ /** Open the file with the OS default application. */
139
+ async openWithDefault(entry) {
140
+ const resp = await fetch("/api/file-action", {
141
+ method: "POST",
142
+ headers: { "Content-Type": "application/json" },
143
+ body: JSON.stringify({ path: _absPath(entry.path), action: "open" })
144
+ });
145
+ if (!resp.ok) await _respError(resp);
73
146
  },
74
147
 
75
148
  async displayPath(entry) {
@@ -78,7 +151,7 @@ const WorkspaceStore = (() => {
78
151
  headers: { "Content-Type": "application/json" },
79
152
  body: JSON.stringify({ path: _absPath(entry.path), action: "display-path" })
80
153
  });
81
- if (!resp.ok) throw new Error(`HTTP ${resp.status}`);
154
+ if (!resp.ok) await _respError(resp);
82
155
  const data = await resp.json();
83
156
  return data.path;
84
157
  },
@@ -89,7 +162,7 @@ const WorkspaceStore = (() => {
89
162
  headers: { "Content-Type": "application/json" },
90
163
  body: JSON.stringify({ path: _absPath(entry.path), action: "download" })
91
164
  });
92
- if (!resp.ok) throw new Error(`HTTP ${resp.status}`);
165
+ if (!resp.ok) await _respError(resp);
93
166
  return resp.blob();
94
167
  },
95
168
 
@@ -99,7 +172,7 @@ const WorkspaceStore = (() => {
99
172
  headers: { "Content-Type": "application/json" },
100
173
  body: JSON.stringify({ path: _absPath(entry.path), action: "download" })
101
174
  });
102
- if (!resp.ok) throw new Error(`HTTP ${resp.status}`);
175
+ if (!resp.ok) await _respError(resp);
103
176
  return resp.text();
104
177
  },
105
178
 
@@ -109,7 +182,7 @@ const WorkspaceStore = (() => {
109
182
  headers: { "Content-Type": "application/json" },
110
183
  body: JSON.stringify({ path: _absPath(entry.path), action: "save", content })
111
184
  });
112
- if (!resp.ok) throw new Error(`HTTP ${resp.status}`);
185
+ if (!resp.ok) await _respError(resp);
113
186
  _emit("workspace:fileSaved", { sessionId: _sessionId, path: entry.path, name: entry.name });
114
187
  },
115
188
  };