openclacky 1.3.3 → 1.3.4

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 (58) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +26 -0
  3. data/docs/rich_ui_guide.md +277 -0
  4. data/docs/rich_ui_refactor_plan.md +396 -0
  5. data/lib/clacky/agent/llm_caller.rb +10 -4
  6. data/lib/clacky/agent/session_serializer.rb +3 -2
  7. data/lib/clacky/agent.rb +3 -2
  8. data/lib/clacky/agent_config.rb +2 -14
  9. data/lib/clacky/api_extension.rb +262 -0
  10. data/lib/clacky/api_extension_loader.rb +156 -0
  11. data/lib/clacky/cli.rb +93 -3
  12. data/lib/clacky/client.rb +38 -13
  13. data/lib/clacky/default_agents/_panels/git/panel.js +1 -1
  14. data/lib/clacky/default_agents/_panels/time_machine/panel.js +1 -1
  15. data/lib/clacky/default_skills/media-gen/SKILL.md +9 -6
  16. data/lib/clacky/idle_compression_timer.rb +3 -1
  17. data/lib/clacky/locales/en.rb +26 -0
  18. data/lib/clacky/locales/i18n.rb +26 -0
  19. data/lib/clacky/locales/zh.rb +26 -0
  20. data/lib/clacky/rich_ui/components/base_component.rb +50 -0
  21. data/lib/clacky/rich_ui/components/dialogs/approval_dialog.rb +142 -0
  22. data/lib/clacky/rich_ui/components/dialogs/config_menu_dialog.rb +106 -0
  23. data/lib/clacky/rich_ui/components/dialogs/form_dialog.rb +128 -0
  24. data/lib/clacky/rich_ui/components/sidebar.rb +119 -0
  25. data/lib/clacky/rich_ui/components/sidebar_panels.rb +134 -0
  26. data/lib/clacky/rich_ui/components/status_view.rb +58 -0
  27. data/lib/clacky/rich_ui/components/thinking_live_view.rb +79 -0
  28. data/lib/clacky/rich_ui/entry_tracker.rb +56 -0
  29. data/lib/clacky/rich_ui/layout_adapter.rb +16 -0
  30. data/lib/clacky/rich_ui/progress_handle_adapter.rb +24 -0
  31. data/lib/clacky/rich_ui/rich_ui_controller.rb +868 -0
  32. data/lib/clacky/rich_ui/shell/rich_agent_shell.rb +184 -0
  33. data/lib/clacky/rich_ui/view_renderer.rb +291 -0
  34. data/lib/clacky/rich_ui.rb +57 -0
  35. data/lib/clacky/rich_ui_controller.rb +3 -1549
  36. data/lib/clacky/server/api_extension_dispatcher.rb +120 -0
  37. data/lib/clacky/server/http_server.rb +150 -103
  38. data/lib/clacky/server/session_registry.rb +1 -1
  39. data/lib/clacky/shell_hook_loader.rb +1 -1
  40. data/lib/clacky/tools/edit.rb +14 -2
  41. data/lib/clacky/ui2/ui_controller.rb +7 -0
  42. data/lib/clacky/version.rb +1 -1
  43. data/lib/clacky/web/app.css +56 -59
  44. data/lib/clacky/web/app.js +65 -7
  45. data/lib/clacky/web/components/onboard.js +18 -2
  46. data/lib/clacky/web/core/aside.js +8 -3
  47. data/lib/clacky/web/core/ext.js +1 -1
  48. data/lib/clacky/web/features/skills/store.js +30 -2
  49. data/lib/clacky/web/features/skills/view.js +32 -1
  50. data/lib/clacky/web/features/workspace/view.js +1 -1
  51. data/lib/clacky/web/i18n.js +32 -20
  52. data/lib/clacky/web/index.html +9 -17
  53. data/lib/clacky/web/sessions.js +286 -28
  54. data/lib/clacky/web/settings.js +109 -111
  55. data/lib/clacky/web/ws-dispatcher.js +7 -3
  56. data/lib/clacky.rb +17 -2
  57. metadata +38 -2
  58. data/lib/clacky/media/output_dir.rb +0 -43
@@ -526,7 +526,7 @@ body {
526
526
  /* The sidebar sits on the outer frame (bg-primary) so it's visibly a layer
527
527
  BEHIND the chat surface (bg-secondary). Subtle right border separates them. */
528
528
  #sidebar {
529
- --sidebar-width: 15rem;
529
+ --sidebar-width: 16rem;
530
530
  width: var(--sidebar-width);
531
531
  min-width: var(--sidebar-width);
532
532
  background: var(--color-bg-primary);
@@ -1938,7 +1938,7 @@ body {
1938
1938
  the slot has no panels for the current agent it stays empty and the whole
1939
1939
  column collapses to zero width. */
1940
1940
  #session-aside {
1941
- --session-aside-width: 360px;
1941
+ --session-aside-width: 16rem;
1942
1942
  width: var(--session-aside-width);
1943
1943
  flex-shrink: 0;
1944
1944
  position: relative;
@@ -1976,7 +1976,7 @@ body {
1976
1976
  transition: background-color var(--transition-base);
1977
1977
  }
1978
1978
  .session-aside-resize:hover::after,
1979
- .session-aside-resize.active::after { background: var(--color-accent-primary); }
1979
+ .session-aside-resize.active::after { background: transparent; }
1980
1980
 
1981
1981
  .session-aside-collapse {
1982
1982
  position: absolute;
@@ -2271,13 +2271,16 @@ body {
2271
2271
  /* Sit above the next message in case text would otherwise clip beneath it. */
2272
2272
  z-index: 1;
2273
2273
  }
2274
- .msg:hover .msg-time {
2274
+ .msg:hover .msg-time,
2275
+ .msg-user-wrap:hover .msg-time {
2275
2276
  opacity: 1;
2276
2277
  transform: translateY(0);
2277
2278
  }
2278
2279
  /* Time color / alignment: anchor to the bubble's own side, let width be
2279
2280
  driven by content — prevents overflow on narrow bubbles. */
2280
- .msg-user .msg-time { display: none; }
2281
+ /* User timestamp: appended to .msg-user-wrap (not bubble), sits below action
2282
+ buttons, right-aligned. */
2283
+ .msg-user-wrap > .msg-time { color: var(--color-text-secondary); top: auto; bottom: 0.2rem; right: 0; left: auto; padding-right: 0.25rem; }
2281
2284
  .msg-assistant .msg-time { color: var(--color-text-secondary); left: 0; right: auto; padding-left: 0.25rem; }
2282
2285
 
2283
2286
  .msg-user { background: var(--color-accent-soft); color: var(--color-text-primary); white-space: pre-wrap; }
@@ -2289,11 +2292,11 @@ body {
2289
2292
  position: relative;
2290
2293
  display: flex;
2291
2294
  justify-content: flex-end;
2292
- padding-bottom: 1.8rem;
2295
+ padding-bottom: 3rem;
2293
2296
  }
2294
2297
  .msg-user-actions {
2295
2298
  position: absolute;
2296
- top: calc(100% - 1.5rem + 2px);
2299
+ bottom: 1.1rem;
2297
2300
  right: 0;
2298
2301
  display: flex;
2299
2302
  gap: 0.125rem;
@@ -2305,6 +2308,7 @@ body {
2305
2308
  .msg-user-wrap:hover .msg-user-actions { opacity: 1; pointer-events: auto; }
2306
2309
  .msg-user-wrap:has(.msg-user.editing) { padding-bottom: 0; }
2307
2310
  .msg-user-wrap:has(.msg-user.editing) .msg-user-actions { display: none; }
2311
+ .msg-user-wrap:has(.msg-user.editing) .msg-time { display: none; }
2308
2312
 
2309
2313
  .msg-user-action-btn {
2310
2314
  display: inline-flex;
@@ -4583,7 +4587,6 @@ body {
4583
4587
  font-size: 0.8125rem;
4584
4588
  color: var(--color-text-primary);
4585
4589
  cursor: pointer;
4586
- margin-top: 0.5rem;
4587
4590
  }
4588
4591
  .model-field-checkbox input[type="checkbox"] {
4589
4592
  width: 15px;
@@ -4780,7 +4783,7 @@ body {
4780
4783
  /* ── Model Cards Grid Container ─────────────────────────────────────────────── */
4781
4784
  #model-cards {
4782
4785
  display: grid;
4783
- grid-template-columns: repeat(auto-fill, minmax(280px, 1fr));
4786
+ grid-template-columns: repeat(auto-fill, minmax(300px, 1fr));
4784
4787
  gap: 1rem;
4785
4788
  }
4786
4789
 
@@ -6698,24 +6701,14 @@ body {
6698
6701
  white-space: nowrap;
6699
6702
  }
6700
6703
  .brand-skill-version.installed {
6701
- background: #1f3a5f;
6702
- color: #7eb8f7;
6703
- border: 1px solid #1d4070;
6704
+ background: color-mix(in srgb, var(--color-accent-primary) 15%, transparent);
6705
+ color: var(--color-accent-primary);
6706
+ border: 1px solid color-mix(in srgb, var(--color-accent-primary) 35%, transparent);
6704
6707
  }
6705
6708
  .brand-skill-version.latest {
6706
- background: #1f4e9e;
6707
- color: #bfdbfe;
6708
- border: 1px solid #1d4070;
6709
- }
6710
- [data-theme="light"] .brand-skill-version.installed {
6711
- background: #eff6ff;
6712
- color: #1d4ed8;
6713
- border: 1px solid #bfdbfe;
6714
- }
6715
- [data-theme="light"] .brand-skill-version.latest {
6716
- background: #1d4ed8;
6709
+ background: var(--color-accent-primary);
6717
6710
  color: #ffffff;
6718
- border: 1px solid #1e40af;
6711
+ border: 1px solid var(--color-accent-primary);
6719
6712
  }
6720
6713
  .brand-skill-update-arrow {
6721
6714
  color: var(--color-text-secondary);
@@ -6795,17 +6788,11 @@ body {
6795
6788
  border-color: var(--color-accent-primary);
6796
6789
  }
6797
6790
  .btn-brand-update {
6798
- background: #1f4e9e;
6799
- color: #bfdbfe;
6800
- border: 1px solid #1d4070;
6801
- }
6802
- .btn-brand-update:hover { background: #1a3f85; }
6803
- [data-theme="light"] .btn-brand-update {
6804
- background: #1d4ed8;
6791
+ background: var(--color-accent-primary);
6805
6792
  color: #ffffff;
6806
- border: 1px solid #1e40af;
6793
+ border: 1px solid var(--color-accent-primary);
6807
6794
  }
6808
- [data-theme="light"] .btn-brand-update:hover { background: #1e40af; }
6795
+ .btn-brand-update:hover { background: var(--color-accent-hover); }
6809
6796
  .btn-brand-install:disabled,
6810
6797
  .btn-brand-update:disabled {
6811
6798
  opacity: 0.5;
@@ -6846,6 +6833,22 @@ body {
6846
6833
  border-color: var(--color-error-border, #f5c6c6);
6847
6834
  background: var(--color-error-bg, #fff0f0);
6848
6835
  }
6836
+ .btn-skill-edit {
6837
+ background: transparent;
6838
+ border: 1px solid transparent;
6839
+ border-radius: 6px;
6840
+ color: var(--color-text-tertiary);
6841
+ cursor: pointer;
6842
+ padding: 0.25rem;
6843
+ display: inline-flex;
6844
+ align-items: center;
6845
+ transition: color .15s, border-color .15s, background .15s;
6846
+ }
6847
+ .btn-skill-edit:hover {
6848
+ color: var(--color-text-primary);
6849
+ border-color: var(--color-border-primary);
6850
+ background: var(--color-bg-hover);
6851
+ }
6849
6852
 
6850
6853
 
6851
6854
  /* ── CodeEditor (CodeMirror 6) Modal ────────────────────────────────────── */
@@ -7396,28 +7399,35 @@ body.setup-mode[data-theme="dark"] {
7396
7399
  transform: translateY(1px);
7397
7400
  }
7398
7401
  #setup-device-error {
7399
- margin-top: 0.5rem;
7402
+ margin-top: 0;
7403
+ min-height: 0;
7400
7404
  }
7401
7405
  #setup-device-error.result-fail { color: var(--color-error); }
7402
7406
 
7403
7407
  /* Manual-config collapsible (secondary path) */
7404
- #setup-manual-details {
7405
- margin-top: 0.25rem;
7406
- }
7407
- #setup-manual-summary {
7408
+ #setup-manual-toggle {
7409
+ display: block;
7410
+ width: 100%;
7411
+ margin-top: 0.5rem;
7412
+ padding: 0.5rem 0;
7413
+ background: none;
7414
+ border: none;
7408
7415
  cursor: pointer;
7409
- font-size: 0.75rem;
7416
+ font-size: 0.8rem;
7410
7417
  color: var(--color-text-secondary);
7411
7418
  text-align: center;
7412
- list-style: none;
7413
- padding: 0.5rem 0;
7419
+ text-decoration: underline;
7420
+ text-underline-offset: 3px;
7414
7421
  user-select: none;
7422
+ transition: color 0.15s;
7415
7423
  }
7416
- #setup-manual-summary::-webkit-details-marker { display: none; }
7417
- #setup-manual-summary:hover { color: var(--color-text-primary); }
7418
- #setup-manual-details[open] #setup-manual-summary {
7419
- margin-bottom: 0.5rem;
7420
- border-bottom: 1px solid var(--color-border-primary);
7424
+ #setup-manual-toggle:hover {
7425
+ color: var(--color-text-primary);
7426
+ }
7427
+ #setup-manual-section {
7428
+ margin-top: 0.75rem;
7429
+ padding-top: 0.75rem;
7430
+ border-top: 1px solid var(--color-border-primary);
7421
7431
  }
7422
7432
 
7423
7433
  /* Bottom action row: [← Back] [Test & Continue →] */
@@ -12113,16 +12123,3 @@ body.setup-mode[data-theme="dark"] {
12113
12123
  }
12114
12124
  }
12115
12125
 
12116
- /* ════ Media output directory subsection (nested under #media-section) ════ */
12117
- #media-output-dir-section.settings-subsection {
12118
- display: flex;
12119
- flex-direction: column;
12120
- gap: 0.5rem;
12121
- max-width: 48rem;
12122
- }
12123
- #media-output-dir-section .settings-subsection-desc {
12124
- font-size: 0.8125rem;
12125
- color: var(--color-text-secondary);
12126
- line-height: 1.5;
12127
- margin: 0;
12128
- }
@@ -10,6 +10,14 @@
10
10
  // ── DOM helper (shared by all modules loaded after this) ──────────────────
11
11
  const $ = id => document.getElementById(id);
12
12
 
13
+ // ── Inject X-Lang header into every fetch request ─────────────────────────
14
+ const _nativeFetch = window.fetch;
15
+ window.fetch = function(input, init = {}) {
16
+ const headers = new Headers(init.headers || {});
17
+ if (!headers.has("X-Lang")) headers.set("X-Lang", I18n.lang());
18
+ return _nativeFetch.call(this, input, { ...init, headers });
19
+ };
20
+
13
21
  // ── Utilities (shared) ────────────────────────────────────────────────────
14
22
  function escapeHtml(str) {
15
23
  return String(str)
@@ -325,13 +333,53 @@ const Modal = (() => {
325
333
  /** Show a yes/no confirmation dialog. Returns a Promise<boolean>. */
326
334
  function confirm(message) {
327
335
  return new Promise(resolve => {
336
+ const overlay = $("modal-overlay");
337
+ $("modal-message").textContent = message;
338
+ $("modal-skip-label").style.display = "none";
339
+ $("modal-skip-cb").checked = false;
340
+ if (overlay.parentNode !== document.body || overlay.nextSibling) {
341
+ document.body.appendChild(overlay);
342
+ }
343
+ overlay.style.display = "flex";
344
+
345
+ const cleanup = (result) => {
346
+ overlay.style.display = "none";
347
+ $("modal-yes").onclick = null;
348
+ $("modal-no").onclick = null;
349
+ resolve(result);
350
+ };
351
+ $("modal-yes").onclick = () => cleanup(true);
352
+ $("modal-no").onclick = () => cleanup(false);
353
+ });
354
+ }
355
+
356
+ /**
357
+ * Like confirm(), but shows a "don't show again" checkbox.
358
+ * If localStorage[storageKey] === "1", resolves true immediately.
359
+ * Returns Promise<boolean>.
360
+ */
361
+ function confirmOnce(storageKey, message, skipLabel) {
362
+ if (localStorage.getItem(storageKey) === "1") return Promise.resolve(true);
363
+
364
+ return new Promise(resolve => {
365
+ const overlay = $("modal-overlay");
328
366
  $("modal-message").textContent = message;
329
- $("modal-overlay").style.display = "flex";
367
+ $("modal-skip-text").textContent = skipLabel;
368
+ $("modal-skip-cb").checked = false;
369
+ $("modal-skip-label").style.display = "flex";
370
+ if (overlay.parentNode !== document.body || overlay.nextSibling) {
371
+ document.body.appendChild(overlay);
372
+ }
373
+ overlay.style.display = "flex";
330
374
 
331
375
  const cleanup = (result) => {
332
- $("modal-overlay").style.display = "none";
376
+ overlay.style.display = "none";
377
+ $("modal-skip-label").style.display = "none";
333
378
  $("modal-yes").onclick = null;
334
379
  $("modal-no").onclick = null;
380
+ if (result && $("modal-skip-cb").checked) {
381
+ localStorage.setItem(storageKey, "1");
382
+ }
335
383
  resolve(result);
336
384
  };
337
385
  $("modal-yes").onclick = () => cleanup(true);
@@ -342,10 +390,15 @@ const Modal = (() => {
342
390
  /** Show a text input prompt dialog. Returns a Promise<string|null>. */
343
391
  function prompt(message, defaultValue = "") {
344
392
  return new Promise(resolve => {
393
+ const overlay = $("prompt-modal-overlay");
345
394
  $("prompt-modal-message").textContent = message;
346
395
  const input = $("prompt-modal-input");
347
396
  input.value = defaultValue;
348
- $("prompt-modal-overlay").style.display = "flex";
397
+ // Re-attach to <body> end so it stacks above dynamically-appended overlays.
398
+ if (overlay.parentNode !== document.body || overlay.nextSibling) {
399
+ document.body.appendChild(overlay);
400
+ }
401
+ overlay.style.display = "flex";
349
402
 
350
403
  // Auto-focus and select all text
351
404
  setTimeout(() => {
@@ -375,10 +428,15 @@ const Modal = (() => {
375
428
  /** Show a rename dialog. Returns a Promise<string|null>. */
376
429
  function rename(currentName = "") {
377
430
  return new Promise(resolve => {
431
+ const overlay = $("rename-modal-overlay");
378
432
  const input = $("rename-modal-input");
379
433
  input.value = currentName;
380
434
  input.classList.remove("input-error");
381
- $("rename-modal-overlay").style.display = "flex";
435
+ // Re-attach to <body> end so it stacks above dynamically-appended overlays.
436
+ if (overlay.parentNode !== document.body || overlay.nextSibling) {
437
+ document.body.appendChild(overlay);
438
+ }
439
+ overlay.style.display = "flex";
382
440
 
383
441
  setTimeout(() => {
384
442
  input.focus();
@@ -423,7 +481,7 @@ const Modal = (() => {
423
481
  });
424
482
  }
425
483
 
426
- return { confirm, prompt, rename };
484
+ return { confirm, confirmOnce, prompt, rename };
427
485
  })();
428
486
 
429
487
  // ── Toast helper ──────────────────────────────────────────────────────────
@@ -550,7 +608,7 @@ $("sidebar-overlay").addEventListener("click", _closeSidebar);
550
608
  let startW = 0;
551
609
 
552
610
  // Restore saved width
553
- const saved = localStorage.getItem("sidebar-width");
611
+ const saved = localStorage.getItem("clacky-sidebar-width");
554
612
  if (saved) {
555
613
  const w = parseFloat(saved);
556
614
  if (w >= MIN_W && w <= MAX_W) {
@@ -583,7 +641,7 @@ $("sidebar-overlay").addEventListener("click", _closeSidebar);
583
641
  handle.classList.remove("active");
584
642
  document.body.style.cursor = "";
585
643
  document.body.style.userSelect = "";
586
- localStorage.setItem("sidebar-width", _getWidth());
644
+ localStorage.setItem("clacky-sidebar-width", _getWidth());
587
645
  });
588
646
  })();
589
647
 
@@ -110,6 +110,11 @@ const Onboard = (() => {
110
110
  $("setup-phase-key").style.display = step === "key" ? "" : "none";
111
111
  $("setup-dot-1").className = "setup-step" + (step === "lang" ? " active" : " done");
112
112
  $("setup-dot-2").className = "setup-step" + (step === "key" ? " active" : "");
113
+ if (step === "key") {
114
+ $("setup-device-block").style.display = "";
115
+ $("setup-manual-toggle").style.display = "";
116
+ $("setup-manual-section").style.display = "none";
117
+ }
113
118
  }
114
119
 
115
120
  // Step 2 — API key setup
@@ -367,9 +372,20 @@ const Onboard = (() => {
367
372
 
368
373
  $("setup-btn-test").addEventListener("click", _testAndSave);
369
374
 
370
- // Back to Step 1
375
+ $("setup-manual-toggle").addEventListener("click", () => {
376
+ $("setup-device-block").style.display = "none";
377
+ $("setup-manual-toggle").style.display = "none";
378
+ $("setup-manual-section").style.display = "";
379
+ });
380
+
371
381
  $("setup-btn-back").addEventListener("click", () => {
372
- _showSetupStep("lang");
382
+ if ($("setup-manual-section").style.display !== "none") {
383
+ $("setup-device-block").style.display = "";
384
+ $("setup-manual-toggle").style.display = "";
385
+ $("setup-manual-section").style.display = "none";
386
+ } else {
387
+ _showSetupStep("lang");
388
+ }
373
389
  });
374
390
 
375
391
  _bindDeviceStep();
@@ -16,7 +16,7 @@
16
16
  (() => {
17
17
  const WIDTH_KEY = "clacky.aside.width";
18
18
  const OPEN_KEY = "clacky.aside.open";
19
- const MIN_W = 280;
19
+ const MIN_W = 256;
20
20
  const MAX_W = 720;
21
21
 
22
22
  const $ = (id) => document.getElementById(id);
@@ -31,8 +31,11 @@
31
31
  const opener = $("btn-aside-open");
32
32
  const overlay = $("workspace-overlay");
33
33
  if (!aside) return;
34
- let open = true;
35
- try { open = localStorage.getItem(OPEN_KEY) !== "0"; } catch (_e) { /* ignore */ }
34
+ let open = false;
35
+ try {
36
+ const stored = localStorage.getItem(OPEN_KEY);
37
+ open = stored === null ? false : stored !== "0";
38
+ } catch (_e) { /* ignore */ }
36
39
  const empty = slotEmpty();
37
40
  aside.classList.toggle("collapsed", !open);
38
41
  if (opener) opener.style.display = (!open && !empty) ? "" : "none";
@@ -64,6 +67,7 @@
64
67
  startX = e.clientX;
65
68
  startW = parseFloat(getComputedStyle(aside).getPropertyValue("--session-aside-width"));
66
69
  handle.classList.add("active");
70
+ aside.style.transition = "none";
67
71
  document.body.style.cursor = "col-resize";
68
72
  document.body.style.userSelect = "none";
69
73
  });
@@ -79,6 +83,7 @@
79
83
  if (!dragging) return;
80
84
  dragging = false;
81
85
  handle.classList.remove("active");
86
+ aside.style.transition = "";
82
87
  document.body.style.cursor = "";
83
88
  document.body.style.userSelect = "";
84
89
  const w = parseFloat(getComputedStyle(aside).getPropertyValue("--session-aside-width"));
@@ -313,7 +313,7 @@ Clacky.ext = (() => {
313
313
  btn.type = "button";
314
314
  btn.setAttribute("data-tab", id);
315
315
  const label = document.createElement("span");
316
- label.textContent = entry.tab.label || id;
316
+ label.textContent = (typeof entry.tab.label === "function" ? entry.tab.label() : entry.tab.label) || id;
317
317
  btn.appendChild(label);
318
318
  if (entry.tab.badge != null) _setTabBadge(btn, entry.tab.badge);
319
319
  btn.addEventListener("click", () => activate(id));
@@ -268,6 +268,35 @@ const SkillsStore = (() => {
268
268
  return _openSessionWith(message || "/skill-creator");
269
269
  },
270
270
 
271
+ /** Fetch a custom skill's SKILL.md content. Returns { ok, content, path, error }. */
272
+ async fetchSkillContent(name) {
273
+ try {
274
+ const res = await fetch(`/api/skills/${encodeURIComponent(name)}/content`);
275
+ const data = await res.json();
276
+ if (!data.ok) throw new Error(data.error || "Load failed");
277
+ return { ok: true, content: data.content || "", path: data.path };
278
+ } catch (e) {
279
+ return { ok: false, error: e.message };
280
+ }
281
+ },
282
+
283
+ /** Update a custom skill's SKILL.md content. Returns { ok, error }. */
284
+ async updateSkillContent(name, content) {
285
+ try {
286
+ const res = await fetch(`/api/skills/${encodeURIComponent(name)}/content`, {
287
+ method: "PUT",
288
+ headers: { "Content-Type": "application/json" },
289
+ body: JSON.stringify({ content })
290
+ });
291
+ const data = await res.json();
292
+ if (!res.ok || !data.ok) throw new Error(data.error || "Save failed");
293
+ await Skills.load();
294
+ return { ok: true };
295
+ } catch (e) {
296
+ return { ok: false, error: e.message };
297
+ }
298
+ },
299
+
271
300
  /** Import a skill: validate url/path, open a session, run /skill-add. */
272
301
  async importSkill(url) {
273
302
  const trimmed = (url || "").trim();
@@ -286,8 +315,7 @@ const SkillsStore = (() => {
286
315
 
287
316
  // ── Cross-feature state resets (called by settings.js) ───────────────────
288
317
 
289
- /** Reset to My Skills and clear brand state after license unbind. */
290
- resetAfterUnbind() {
318
+ resetBrandState() {
291
319
  _brandSkills = [];
292
320
  _brandActivated = false;
293
321
  _activeTab = "my-skills";
@@ -114,6 +114,15 @@ const SkillsView = (() => {
114
114
  </svg>
115
115
  </button>`;
116
116
 
117
+ const editButtonHtml = isSystem
118
+ ? ""
119
+ : `<button class="btn-skill-edit" data-name="${escapeHtml(skill.name)}" title="${I18n.t("skills.btn.edit")}">
120
+ <svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
121
+ <path d="M12 20h9"/>
122
+ <path d="M16.5 3.5a2.121 2.121 0 1 1 3 3L7 19l-4 1 1-4L16.5 3.5z"/>
123
+ </svg>
124
+ </button>`;
125
+
117
126
  card.innerHTML = `
118
127
  <div class="skill-card-main">
119
128
  <div class="skill-card-info">
@@ -131,6 +140,7 @@ const SkillsView = (() => {
131
140
  <span class="skill-toggle-track"></span>
132
141
  </label>
133
142
  ${useButtonHtml}
143
+ ${editButtonHtml}
134
144
  ${deleteButtonHtml}
135
145
  </div>
136
146
  </div>
@@ -157,6 +167,12 @@ const SkillsView = (() => {
157
167
  const useBtn = card.querySelector(".btn-skill-use");
158
168
  if (useBtn) useBtn.addEventListener("click", () => Skills.useInstalledSkill(skill.name));
159
169
 
170
+ const editBtn = card.querySelector(".btn-skill-edit");
171
+ if (editBtn) editBtn.addEventListener("click", (e) => {
172
+ e.stopPropagation();
173
+ _editSkill(skill);
174
+ });
175
+
160
176
  const deleteBtn = card.querySelector(".btn-skill-delete");
161
177
  if (deleteBtn) deleteBtn.addEventListener("click", (e) => {
162
178
  e.stopPropagation();
@@ -511,12 +527,27 @@ const SkillsView = (() => {
511
527
  },
512
528
 
513
529
  resetAfterUnbind() {
514
- SkillsStore.resetAfterUnbind();
530
+ SkillsStore.resetBrandState();
515
531
  const panel = $("skills-panel");
516
532
  if (panel && panel.style.display !== "none") _applyTab("my-skills");
517
533
  },
518
534
  };
519
535
 
536
+ async function _editSkill(skill) {
537
+ const res = await Skills.fetchSkillContent(skill.name);
538
+ if (!res.ok) { alert(I18n.t("skills.editFail") + ": " + res.error); return; }
539
+
540
+ CodeEditor.open({
541
+ content: res.content,
542
+ title: skill.name,
543
+ language: "markdown",
544
+ onSave: async (newContent) => {
545
+ const r = await Skills.updateSkillContent(skill.name, newContent);
546
+ if (!r.ok) throw new Error(r.error);
547
+ }
548
+ });
549
+ }
550
+
520
551
  async function _doImportFromBar() {
521
552
  const input = $("skill-import-input");
522
553
  const bar = $("skill-import-bar");
@@ -294,7 +294,7 @@ const WorkspaceView = (() => {
294
294
  if (window.Clacky && Clacky.ext) {
295
295
  Clacky.ext.ui.mountBuiltin("session.aside", (ctx) => WorkspaceView.renderFilesTab(ctx), {
296
296
  order: 40,
297
- tab: { id: "files", label: (typeof I18n !== "undefined" ? I18n.t("workspace.title") : "Files") },
297
+ tab: { id: "files", label: () => (typeof I18n !== "undefined" ? I18n.t("workspace.title") : "Files") },
298
298
  });
299
299
  }
300
300