openclacky 1.5.11 → 1.5.13

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 (82) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +82 -0
  3. data/lib/clacky/access_key.rb +59 -0
  4. data/lib/clacky/agent/cost_tracker.rb +2 -0
  5. data/lib/clacky/agent/message_compressor_helper.rb +53 -2
  6. data/lib/clacky/agent/session_serializer.rb +72 -19
  7. data/lib/clacky/agent/time_machine.rb +10 -1
  8. data/lib/clacky/agent.rb +107 -26
  9. data/lib/clacky/agent_config.rb +40 -0
  10. data/lib/clacky/agent_profile.rb +5 -5
  11. data/lib/clacky/billing/billing_record.rb +3 -0
  12. data/lib/clacky/billing/billing_store.rb +31 -5
  13. data/lib/clacky/billing/platform_billing.rb +231 -0
  14. data/lib/clacky/brand_config.rb +4 -3
  15. data/lib/clacky/cancel_flag.rb +23 -0
  16. data/lib/clacky/cli.rb +4 -2
  17. data/lib/clacky/client.rb +107 -0
  18. data/lib/clacky/default_extensions/advisor/advisors/general.md +23 -17
  19. data/lib/clacky/default_extensions/advisor/hooks/advisor.rb +104 -5
  20. data/lib/clacky/default_extensions/advisor/panels/advisor/view.js +92 -75
  21. data/lib/clacky/default_extensions/ext-studio/api/handler.rb +31 -0
  22. data/lib/clacky/default_extensions/ext-studio/panels/studio/view.js +74 -24
  23. data/lib/clacky/default_extensions/ext-studio/skills/ext-develop/SKILL.md +16 -37
  24. data/lib/clacky/default_extensions/git/ext.yml +1 -1
  25. data/lib/clacky/default_extensions/git/panels/git/view.js +483 -67
  26. data/lib/clacky/default_extensions/meeting/ext.yml +2 -1
  27. data/lib/clacky/default_extensions/time_machine/ext.yml +1 -1
  28. data/lib/clacky/default_extensions/time_machine/panels/time_machine/view.js +186 -208
  29. data/lib/clacky/extension/packager.rb +4 -2
  30. data/lib/clacky/extension/scaffold/templates/full/panels/dashboard/view.js.erb +2 -3
  31. data/lib/clacky/extension/verifier.rb +14 -1
  32. data/lib/clacky/fanout.rb +49 -2
  33. data/lib/clacky/json_ui_controller.rb +3 -2
  34. data/lib/clacky/message_format/open_ai.rb +5 -1
  35. data/lib/clacky/message_format/open_ai_responses.rb +409 -0
  36. data/lib/clacky/message_history.rb +43 -1
  37. data/lib/clacky/openai_responses_stream_aggregator.rb +294 -0
  38. data/lib/clacky/plain_ui_controller.rb +15 -5
  39. data/lib/clacky/providers.rb +24 -7
  40. data/lib/clacky/rich_ui/rich_ui_controller.rb +2 -2
  41. data/lib/clacky/server/channel/channel_manager.rb +93 -18
  42. data/lib/clacky/server/channel/channel_ui_controller.rb +16 -3
  43. data/lib/clacky/server/dir_picker.rb +154 -0
  44. data/lib/clacky/server/git_panel.rb +61 -9
  45. data/lib/clacky/server/http_server.rb +366 -42
  46. data/lib/clacky/server/web_ui_controller.rb +7 -5
  47. data/lib/clacky/session_manager.rb +74 -17
  48. data/lib/clacky/tools/ask_user.rb +3 -2
  49. data/lib/clacky/tools/terminal.rb +30 -3
  50. data/lib/clacky/ui2/markdown_renderer.rb +15 -12
  51. data/lib/clacky/ui2/strings_cjk_patch.rb +132 -0
  52. data/lib/clacky/ui2/themes/hacker_theme.rb +1 -1
  53. data/lib/clacky/ui2/themes/minimal_theme.rb +1 -1
  54. data/lib/clacky/ui2/ui_controller.rb +2 -2
  55. data/lib/clacky/ui_interface.rb +2 -2
  56. data/lib/clacky/utils/model_pricing.rb +92 -4
  57. data/lib/clacky/version.rb +1 -1
  58. data/lib/clacky/web/app.css +915 -142
  59. data/lib/clacky/web/components/code-editor.js +33 -1
  60. data/lib/clacky/web/components/composer.js +315 -0
  61. data/lib/clacky/web/components/custom-select.js +136 -0
  62. data/lib/clacky/web/components/mentions.js +655 -0
  63. data/lib/clacky/web/components/model-picker.js +527 -0
  64. data/lib/clacky/web/components/onboard.js +29 -55
  65. data/lib/clacky/web/core/aside.js +3 -3
  66. data/lib/clacky/web/core/ext.js +38 -0
  67. data/lib/clacky/web/features/billing/store.js +2 -6
  68. data/lib/clacky/web/features/billing/view.js +32 -16
  69. data/lib/clacky/web/features/extensions/view.js +22 -10
  70. data/lib/clacky/web/features/new-session/store.js +15 -1
  71. data/lib/clacky/web/features/new-session/view.js +80 -34
  72. data/lib/clacky/web/features/trash/view.js +31 -19
  73. data/lib/clacky/web/features/workspace/store.js +1 -0
  74. data/lib/clacky/web/features/workspace/view.js +766 -80
  75. data/lib/clacky/web/i18n.js +107 -17
  76. data/lib/clacky/web/index.html +125 -45
  77. data/lib/clacky/web/sessions.js +704 -635
  78. data/lib/clacky/web/settings.js +164 -190
  79. data/lib/clacky/web/skills.js +11 -17
  80. data/lib/clacky/web/ws-dispatcher.js +11 -33
  81. data/lib/clacky.rb +4 -0
  82. metadata +12 -1
@@ -1,12 +1,15 @@
1
1
  // ── Advisor recommendations bar + settings toggle ─────────────────────
2
2
  //
3
- // session.composer slot: recommendation card above the input box. The ✕
4
- // button dismisses the card for the rest of this page lifetime — purely
5
- // local, no server call, so a page refresh brings recommendations back.
6
- // The ⏻ button turns the advisor off globally (persisted to advisor.yml);
7
- // a toast reminds the user it can be re-enabled in Settings. Clicking an
8
- // option fills the input box so the user can review (or edit) before
9
- // sending. Nothing is executed automatically.
3
+ // session.composer slot: recommendation card above the input box. Card
4
+ // state is keyed by session id - each session's recommendations live in
5
+ // their own bucket, so switching sessions never leaks one session's card
6
+ // into another (and switching back restores it). The ✕ button dismisses
7
+ // the card for the rest of the current round only - the next round's
8
+ // pending event resets the flag, so recommendations return without a
9
+ // page refresh. The ⏻ button turns the advisor off globally (persisted to
10
+ // advisor.yml); a toast reminds the user it can be re-enabled in Settings.
11
+ // Clicking an option fills the input box so the user can review (or edit)
12
+ // before sending. Nothing is executed automatically.
10
13
  //
11
14
  // settings.tabs + settings.body: an "Advisor" tab with a master on/off
12
15
  // switch, persisted to ~/.clacky/advisor.yml via /api/ext/advisor/*.
@@ -85,7 +88,6 @@
85
88
  .advisor-option-send { position: absolute; right: 6px; top: 50%; transform: translateY(-50%); width: 22px; height: 22px; display: inline-flex; align-items: center; justify-content: center; border: none; border-radius: var(--radius-sm); background: transparent; color: var(--color-text-secondary); cursor: pointer; opacity: 0; transition: opacity 0.12s; padding: 0; }
86
89
  .advisor-option:hover .advisor-option-send { opacity: 1; }
87
90
  .advisor-option-send:hover { background: var(--color-accent-primary); color: #fff; }
88
- .advisor-card-text { font-size: 12px; color: var(--color-text-secondary); white-space: pre-wrap; }
89
91
  .advisor-error { font-size: 12px; color: var(--color-error, #c0392b); background: var(--color-error-bg, #fef2f2); padding: 6px 8px; border-radius: var(--radius-sm); white-space: pre-wrap; }
90
92
  .advisor-pending { font-size: 12px; color: var(--color-text-tertiary); display: flex; align-items: center; gap: 8px; }
91
93
  .advisor-spinner { width: 12px; height: 12px; flex: none; border: 2px solid var(--color-border-strong, #c9c9c2); border-top-color: transparent; border-radius: 50%; animation: advisor-spin 0.8s linear infinite; }
@@ -95,31 +97,17 @@
95
97
  document.head.appendChild(style);
96
98
  }
97
99
 
98
- function parseOptions(content) {
99
- const options = [];
100
- (content || "").split("\n").forEach((line) => {
101
- const m = line.match(/^\s*[-*]\s*\[([^\]]+)\]\s*(.*)$/);
102
- if (m) {
103
- options.push({ action: m[1].trim(), reason: m[2].trim() });
104
- } else if (line.trim()) {
105
- // Tolerate models that skip the [action] wrapper — the whole line
106
- // still becomes a clickable option.
107
- options.push({ action: line.trim(), reason: "" });
108
- }
109
- });
110
- return options;
100
+ function normalizeOptions(list) {
101
+ if (!Array.isArray(list)) return [];
102
+ return list
103
+ .filter((o) => o && typeof o.action === "string" && o.action.trim())
104
+ .map((o) => ({ action: o.action.trim(), reason: (o.reason || "").trim() }));
111
105
  }
112
106
 
113
107
  function fillInput(text) {
114
108
  const input = document.getElementById("user-input");
115
- if (!input) return;
116
- input.value = text;
117
- // Match the host's auto-grow behaviour (sessions.js input handler): a raw
118
- // value assignment never fires the input event, so multi-line fills would
119
- // otherwise stay at one-line height with the rest clipped.
120
- input.style.height = "auto";
121
- input.style.height = Math.min(input.scrollHeight, 200) + "px";
122
- input.focus();
109
+ if (!input || !Clacky.Composer) return;
110
+ Clacky.Composer.setText(input, text);
123
111
  }
124
112
 
125
113
  // Fill the composer and send immediately. _sendMessage clears the input
@@ -127,9 +115,8 @@
127
115
  function sendNow(text) {
128
116
  const input = document.getElementById("user-input");
129
117
  const btn = document.getElementById("btn-send");
130
- if (!input || !btn) return;
131
- input.value = text;
132
- input.style.height = "auto";
118
+ if (!input || !btn || !Clacky.Composer) return;
119
+ Clacky.Composer.setText(input, text);
133
120
  btn.click();
134
121
  }
135
122
 
@@ -179,26 +166,46 @@
179
166
  });
180
167
  }
181
168
 
182
- // Module-level state, shared by the card and the settings switch.
183
- // dismissed: the user hit ✕ — hide the card for this page lifetime and
184
- // ignore incoming events until a refresh (or the settings switch turns
185
- // the advisor back on).
186
- const state = { mode: "hidden", options: [], fallback: "", errorMessage: "", dismissed: false };
169
+ // Module-level state for the global advisor switch. advisor.yml is a
170
+ // global config, so `off` applies to every session for the rest of this
171
+ // page lifetime (⏻ button or settings switch).
172
+ const state = { off: false };
173
+
174
+ // Per-session card state, keyed by sessionId. The bus mirrors every ext.*
175
+ // event regardless of the active session, so each session's card state must
176
+ // live in its own bucket: session A's recommendations stay in A while you
177
+ // view B, and come back when you return to A. roundDismissed: the user hit ✕
178
+ // this round - hide the card and ignore events until the next round's
179
+ // pending event resets the flag.
180
+ //
181
+ // The host's per-session `create(ctx)` runtime can't hold this - it is only
182
+ // allocated during a render pass, and background sessions receive events
183
+ // while they have no render context.
184
+ const sessionStates = new Map();
185
+
186
+ function stateFor(sessionId) {
187
+ let s = sessionStates.get(sessionId);
188
+ if (!s) {
189
+ s = { mode: "hidden", options: [], errorMessage: "", roundDismissed: false };
190
+ sessionStates.set(sessionId, s);
191
+ }
192
+ return s;
193
+ }
187
194
 
188
195
  function dismiss() {
189
- state.dismissed = true;
190
- state.mode = "hidden";
196
+ const s = sessionStates.get(Clacky.ext.context.sessionId);
197
+ if (!s) return;
198
+ s.roundDismissed = true;
199
+ s.mode = "hidden";
191
200
  redraw();
192
201
  }
193
-
194
202
  // Global off: POST the disable endpoint (persisted in advisor.yml) and
195
203
  // hide the card; the toast tells the user where to re-enable.
196
204
  function turnOff() {
197
205
  fetch("/api/ext/advisor/disable", { method: "POST" })
198
206
  .then((r) => { if (!r.ok) throw new Error("HTTP " + r.status); return r.json(); })
199
207
  .then((d) => {
200
- state.dismissed = true;
201
- state.mode = "hidden";
208
+ state.off = true;
202
209
  redraw();
203
210
  if (window.Clacky && Clacky.Modal && Clacky.Modal.toast) {
204
211
  Clacky.Modal.toast(t("advisor.offHint", "已关闭工作建议,可在设置页重新打开"), {
@@ -224,10 +231,15 @@
224
231
  });
225
232
  }
226
233
 
227
- // Build the current card from `state`. Returns null when hidden — the host
228
- // treats a null result as "render nothing", which clears the slot.
229
- function renderContent() {
230
- if (state.dismissed || state.mode === "hidden") return null;
234
+ // Build the current card for the render context's session. Returns null
235
+ // when hidden - the host treats a null result as "render nothing", which
236
+ // clears the slot.
237
+ function renderContent(ctx) {
238
+ if (!ctx || !ctx.sessionId || state.off) return null;
239
+ // Read-only lookup: a session with no advisor event yet has no bucket and
240
+ // renders nothing, so rendering never allocates state.
241
+ const s = sessionStates.get(ctx.sessionId);
242
+ if (!s || s.mode === "hidden") return null;
231
243
 
232
244
  const slot = document.createElement("div");
233
245
  slot.className = "advisor-card";
@@ -264,7 +276,7 @@
264
276
  head.appendChild(actions);
265
277
  slot.appendChild(head);
266
278
 
267
- if (state.mode === "pending") {
279
+ if (s.mode === "pending") {
268
280
  const row = document.createElement("div");
269
281
  row.className = "advisor-pending";
270
282
 
@@ -277,21 +289,16 @@
277
289
  return slot;
278
290
  }
279
291
 
280
- if (state.mode === "error") {
292
+ if (s.mode === "error") {
281
293
  const row = document.createElement("div");
282
294
  row.className = "advisor-error";
283
- row.textContent = state.errorMessage;
295
+ row.textContent = s.errorMessage;
284
296
  slot.appendChild(row);
285
297
  return slot;
286
298
  }
287
299
 
288
- if (state.options.length > 0) {
289
- renderOptions(slot, state.options);
290
- } else {
291
- const text = document.createElement("div");
292
- text.className = "advisor-card-text";
293
- text.textContent = state.fallback;
294
- slot.appendChild(text);
300
+ if (s.options.length > 0) {
301
+ renderOptions(slot, s.options);
295
302
  }
296
303
  return slot;
297
304
  }
@@ -307,7 +314,9 @@
307
314
  }
308
315
  // The card pushes the message stream up; scroll to the bottom so the
309
316
  // last message stays visible instead of being hidden under it.
310
- if (!state.dismissed && state.mode !== "hidden") {
317
+ const active = Clacky.ext.context.sessionId;
318
+ const s = active ? sessionStates.get(active) : null;
319
+ if (!state.off && s && s.mode !== "hidden") {
311
320
  requestAnimationFrame(() => {
312
321
  const m = document.getElementById("messages");
313
322
  if (m) m.scrollTop = m.scrollHeight;
@@ -315,35 +324,43 @@
315
324
  }
316
325
  }
317
326
 
318
- Clacky.ext.subscribe("ext.advisor.pending", () => {
319
- if (state.dismissed) return;
320
- state.mode = "pending";
321
- redraw();
327
+ Clacky.ext.subscribe("ext.advisor.pending", (payload) => {
328
+ const sid = payload && payload.sessionId;
329
+ if (!sid || state.off) return;
330
+ const s = stateFor(sid);
331
+ s.roundDismissed = false;
332
+ s.mode = "pending";
333
+ if (sid === Clacky.ext.context.sessionId) redraw();
322
334
  });
323
335
  Clacky.ext.subscribe("ext.advisor.recommendations", (payload) => {
324
- if (state.dismissed) return;
325
- const content = (payload && (payload.content || payload.advice)) || "";
326
- const opts = parseOptions(content);
327
- state.mode = "options";
328
- state.options = opts;
329
- state.fallback = opts.length > 0 ? "" : content;
330
- redraw();
336
+ const sid = payload && payload.sessionId;
337
+ if (!sid || state.off) return;
338
+ const s = stateFor(sid);
339
+ if (s.roundDismissed) return;
340
+ const opts = normalizeOptions(payload && payload.options);
341
+ if (opts.length === 0) return;
342
+ s.mode = "options";
343
+ s.options = opts;
344
+ if (sid === Clacky.ext.context.sessionId) redraw();
331
345
  });
332
346
  Clacky.ext.subscribe("ext.advisor.done", (payload) => {
333
- if (state.dismissed) return;
347
+ const sid = payload && payload.sessionId;
348
+ if (!sid || state.off) return;
349
+ const s = stateFor(sid);
350
+ if (s.roundDismissed) return;
334
351
  const reason = payload && payload.reason;
335
352
  if (reason === "error" || reason === "empty") {
336
- state.mode = "error";
337
- state.errorMessage = (reason === "error" && payload && payload.message)
353
+ s.mode = "error";
354
+ s.errorMessage = (reason === "error" && payload && payload.message)
338
355
  ? t("advisor.error", "建议生成失败") + ":" + payload.message
339
356
  : t("advisor.empty", "未能生成建议,请稍后重试");
340
357
  } else {
341
- state.mode = "hidden";
358
+ s.mode = "hidden";
342
359
  }
343
- redraw();
360
+ if (sid === Clacky.ext.context.sessionId) redraw();
344
361
  });
345
362
 
346
- Clacky.ext.ui.mount("session.composer", () => renderContent(), { order: 200 });
363
+ Clacky.ext.ui.mount("session.composer", (container, ctx) => renderContent(ctx), { order: 200 });
347
364
 
348
365
  // ── Settings tab: master on/off switch ─────────────────────────────────
349
366
 
@@ -419,7 +436,7 @@
419
436
  .then((d) => {
420
437
  checkbox.checked = !!d.enabled;
421
438
  // Reflect the change on the open composer card immediately.
422
- state.dismissed = !d.enabled;
439
+ state.off = !d.enabled;
423
440
  redraw();
424
441
  })
425
442
  .catch((err) => {
@@ -189,6 +189,33 @@ class ExtStudioExt < Clacky::ApiExtension
189
189
  json(ok: true, ext_id: ext_id)
190
190
  end
191
191
 
192
+ # POST /api/ext/ext-studio/set_disabled
193
+ # body: { ext_id, disabled }
194
+ # Disables/enables a local extension. A disabled extension stays discoverable
195
+ # but stops contributing units (panels, api, skills, agents, ...) until it is
196
+ # re-enabled.
197
+ post "/set_disabled" do
198
+ ext_id = require_ext_id!
199
+ disabled = json_body["disabled"] == true
200
+
201
+ result = Clacky::ExtensionLoader.load_all(force: false)
202
+ container = Array(result.containers).find { |id, _| id == ext_id }&.last
203
+ error!("extension not found: #{ext_id}", status: 404) unless container
204
+ error!("not a local extension", status: 422) unless container[:layer] == :local
205
+
206
+ if disabled
207
+ Clacky::ExtensionLoader.disable!(ext_id)
208
+ else
209
+ Clacky::ExtensionLoader.enable!(ext_id)
210
+ end
211
+
212
+ # Reload the API registry so a disabled extension's routes unmount (and an
213
+ # enabled one remounts) without restarting the server.
214
+ Clacky::ApiExtensionLoader.load_all
215
+
216
+ json(ok: true, ext_id: ext_id, disabled: disabled)
217
+ end
218
+
192
219
  # POST /api/ext/ext-studio/unpublish
193
220
  # body: { ext_id }
194
221
  post "/unpublish" do
@@ -317,6 +344,9 @@ class ExtStudioExt < Clacky::ApiExtension
317
344
  ext_id = require_ext_id!
318
345
  version = presence(json_body["version"])
319
346
  error!("version required", status: 422) unless version
347
+ unless Clacky::ExtensionVerifier.valid_version?(version)
348
+ error!("version must use three numeric segments (for example, 1.0.0)", status: 422)
349
+ end
320
350
 
321
351
  result = Clacky::ExtensionLoader.load_all(force: false)
322
352
  container = Array(result.containers).find { |id, _| id == ext_id }&.last
@@ -470,6 +500,7 @@ class ExtStudioExt < Clacky::ApiExtension
470
500
  version: raw["version"],
471
501
  origin: container[:origin],
472
502
  layer: container[:layer].to_s,
503
+ disabled: container[:disabled] == true,
473
504
  dir: dir,
474
505
  mtime: File.mtime(File.join(dir, "ext.yml")).to_i,
475
506
  units: ext_units.map { |u| serialize_unit(u) },
@@ -144,7 +144,10 @@
144
144
  "extlist.btn.packing": "Packing…",
145
145
  "extlist.btn.unpublish": "Unpublish",
146
146
  "extlist.btn.iterate": "Iterate",
147
+ "extlist.btn.disable": "Disable",
148
+ "extlist.btn.enable": "Enable",
147
149
  "extlist.btn.delete": "Delete",
150
+ "extlist.badge.disabled": "Disabled",
148
151
  "extlist.delete.confirm": "Delete local extension \"{{id}}\"? This cannot be undone.",
149
152
  "extlist.iterate.seed": "Iterate on extension {{id}}",
150
153
  "extlist.changelog.prompt": "Changelog (optional):",
@@ -339,7 +342,10 @@
339
342
  "extlist.btn.packing": "打包中…",
340
343
  "extlist.btn.unpublish": "下架",
341
344
  "extlist.btn.iterate": "迭代",
345
+ "extlist.btn.disable": "禁止",
346
+ "extlist.btn.enable": "启用",
342
347
  "extlist.btn.delete": "删除",
348
+ "extlist.badge.disabled": "已禁用",
343
349
  "extlist.delete.confirm": "确定要删除本地扩展「{{id}}」吗?此操作不可恢复。",
344
350
  "extlist.iterate.seed": "迭代扩展 {{id}}",
345
351
  "extlist.changelog.prompt": "更新说明(可选):",
@@ -621,8 +627,12 @@
621
627
  }
622
628
  }
623
629
 
630
+ function normalizeVersion(v) {
631
+ return String(v || "").replace(/[^0-9.]/g, "").split(".").slice(0, 3).join(".");
632
+ }
633
+
624
634
  function isSemver(v) {
625
- return /^\d+\.\d+\.\d+$/.test(v);
635
+ return /^[0-9]+\.[0-9]+\.[0-9]+$/.test(v);
626
636
  }
627
637
 
628
638
  // Unified publish flow: one modal that walks the creator from a release form
@@ -636,23 +646,14 @@
636
646
  const titleNew = isBrand ? t("pub.brand.title.new") : t("pub.title.new");
637
647
  const titleUpdate = isBrand ? t("pub.brand.title.update") : t("pub.title.update");
638
648
  const introText = isBrand ? t("pub.brand.intro", { name: ext.name }) : t("pub.intro", { name: ext.name });
639
- let currentVersion = ext.version || "";
649
+ let currentVersion = normalizeVersion(ext.version);
640
650
  let isUpdate = false;
641
651
 
642
652
  const verField = el("div", { class: "studio-field" });
643
653
  verField.appendChild(el("label", { class: "studio-label", text: t("pub.version.label") }));
644
- const verInput = el("input", { class: "studio-input", type: "text", value: currentVersion, placeholder: "1.0.0" });
645
- verInput.addEventListener("input", () => {
646
- currentVersion = verInput.value.trim();
647
- const valid = isSemver(currentVersion);
648
- verInput.classList.toggle("studio-input-error", !!currentVersion && !valid);
649
- publishBtn.disabled = !valid;
650
- publishBtn.textContent = valid ? t("pub.btn.publish", { ver: currentVersion }) : t("pub.btn.publish", { ver: currentVersion || "?" });
651
- if (currentVersion && !valid) {
652
- setProgress(t("err.invalid_version"), true);
653
- } else {
654
- status.style.display = "none";
655
- }
654
+ const verInput = el("input", {
655
+ class: "studio-input", type: "text", value: currentVersion,
656
+ placeholder: "1.0.0", inputmode: "decimal", autocomplete: "off", spellcheck: "false",
656
657
  });
657
658
  verField.appendChild(verInput);
658
659
 
@@ -688,7 +689,8 @@
688
689
 
689
690
  const publishBtn = modal.footer.querySelector(".btn-primary");
690
691
  const cancelBtn = modal.footer.querySelector(".btn-secondary");
691
- if (!currentVersion) publishBtn.disabled = true;
692
+ verInput.addEventListener("input", () => syncVersionState({ normalize: true }));
693
+ syncVersionState();
692
694
 
693
695
  Promise.resolve(prevVersionOrPromise).then((prevVersion) => {
694
696
  if (done || !prevVersion) return;
@@ -696,10 +698,8 @@
696
698
  const titleEl = modal.overlay.querySelector(".modal-title");
697
699
  if (titleEl) titleEl.textContent = titleUpdate;
698
700
  if (prevVersion !== currentVersion) {
699
- currentVersion = prevVersion;
700
- verInput.value = currentVersion;
701
- publishBtn.disabled = false;
702
- publishBtn.textContent = t("pub.btn.publish", { ver: currentVersion });
701
+ verInput.value = normalizeVersion(prevVersion);
702
+ syncVersionState();
703
703
  }
704
704
  });
705
705
 
@@ -709,9 +709,34 @@
709
709
  status.className = "studio-modal-status" + (isError ? " studio-modal-status-error" : "");
710
710
  }
711
711
 
712
+ function syncVersionState({ normalize = false, updateFeedback = true } = {}) {
713
+ if (normalize) {
714
+ const raw = verInput.value;
715
+ const cursor = verInput.selectionStart;
716
+ const normalized = normalizeVersion(raw);
717
+ if (normalized !== raw) {
718
+ verInput.value = normalized;
719
+ if (cursor != null) {
720
+ const normalizedCursor = normalizeVersion(raw.slice(0, cursor)).length;
721
+ verInput.setSelectionRange(normalizedCursor, normalizedCursor);
722
+ }
723
+ }
724
+ }
725
+
726
+ currentVersion = verInput.value.trim();
727
+ const valid = isSemver(currentVersion);
728
+ publishBtn.disabled = !valid;
729
+ publishBtn.textContent = t("pub.btn.publish", { ver: valid ? currentVersion : (currentVersion || "?") });
730
+
731
+ if (updateFeedback) {
732
+ verInput.classList.toggle("studio-input-error", !!currentVersion && !valid);
733
+ if (currentVersion && !valid) setProgress(t("err.invalid_version"), true);
734
+ else status.style.display = "none";
735
+ }
736
+ }
737
+
712
738
  function resetButtons() {
713
- publishBtn.disabled = !currentVersion;
714
- publishBtn.textContent = currentVersion ? t("pub.btn.publish", { ver: currentVersion }) : t("pub.btn.publish", { ver: "?" });
739
+ syncVersionState({ updateFeedback: false });
715
740
  cancelBtn.disabled = false;
716
741
  notes.disabled = false;
717
742
  verInput.disabled = false;
@@ -1582,9 +1607,15 @@
1582
1607
  const head = el("div", { class: "studio-skill-head" });
1583
1608
  head.appendChild(el("span", { class: "studio-skill-name", text: `${ext.name} (${ext.id})` }));
1584
1609
  head.appendChild(badge(published ? t("extlist.badge.published") : t("extlist.badge.local"), published ? "published" : "local"));
1585
- if (ext.error_count) head.appendChild(badge("✕ " + t("extlist.verify.errors", { n: ext.error_count }), "changed"));
1586
- else if (ext.warning_count) head.appendChild(badge("● " + t("extlist.verify.warnings", { n: ext.warning_count }), "changed"));
1587
- else head.appendChild(badge("✓ " + t("extlist.verify.ok"), "published"));
1610
+ if (ext.disabled) {
1611
+ head.appendChild(badge(t("extlist.badge.disabled"), "changed"));
1612
+ } else if (ext.error_count) {
1613
+ head.appendChild(badge("✕ " + t("extlist.verify.errors", { n: ext.error_count }), "changed"));
1614
+ } else if (ext.warning_count) {
1615
+ head.appendChild(badge("● " + t("extlist.verify.warnings", { n: ext.warning_count }), "changed"));
1616
+ } else {
1617
+ head.appendChild(badge("✓ " + t("extlist.verify.ok"), "published"));
1618
+ }
1588
1619
  card.appendChild(head);
1589
1620
  if (ext.description) card.appendChild(el("p", { class: "studio-skill-desc", text: ext.description }));
1590
1621
 
@@ -1616,6 +1647,13 @@
1616
1647
  const packBtn = el("button", { class: "studio-btn studio-btn-ghost", text: t("extlist.btn.pack") });
1617
1648
  packBtn.addEventListener("click", () => doPack(ext, packBtn));
1618
1649
  actions.appendChild(packBtn);
1650
+
1651
+ const toggleBtn = el("button", {
1652
+ class: "studio-btn studio-btn-ghost",
1653
+ text: ext.disabled ? t("extlist.btn.enable") : t("extlist.btn.disable"),
1654
+ });
1655
+ toggleBtn.addEventListener("click", () => doToggleDisabled(ext, toggleBtn));
1656
+ actions.appendChild(toggleBtn);
1619
1657
  card.appendChild(actions);
1620
1658
 
1621
1659
  if (!published) {
@@ -1638,6 +1676,18 @@
1638
1676
  }
1639
1677
  }
1640
1678
 
1679
+ async function doToggleDisabled(ext, btn) {
1680
+ const next = !ext.disabled;
1681
+ btn.disabled = true;
1682
+ try {
1683
+ await postJson("/set_disabled", { ext_id: ext.id, disabled: next });
1684
+ location.reload();
1685
+ } catch (e) {
1686
+ btn.disabled = false;
1687
+ alert(t("err.generic", { msg: e.message }));
1688
+ }
1689
+ }
1690
+
1641
1691
  async function doDelete(ext, btn) {
1642
1692
  if (!await Clacky.Modal.confirm(t("extlist.delete.confirm", { id: ext.id }))) return;
1643
1693
  btn.disabled = true;
@@ -104,9 +104,9 @@ Clacky.ext.api.register(name, fn) // expose a named data source; api.res
104
104
  - gets a host-owned `container` DOM element — append into it, **or** return a Node / HTML
105
105
  string and the host appends for you;
106
106
  - returning a **function** registers it as a teardown callback;
107
- - returning **`null`/`undefined` renders nothing** — but returning `null` from a wrong
108
- signature (e.g. `(ctx) => …` instead of `(container, ctx) => …`) is the #1 cause of a
109
- red "crashed" placeholder. Match the signature exactly.
107
+ - returning **`null`/`undefined` renders nothing** (safe) — a red "crashed" placeholder
108
+ means the render **threw**; the #1 cause is a wrong signature shifting every argument
109
+ (e.g. `(ctx) => …` instead of `(container, ctx) => …`). Match the signature exactly.
110
110
 
111
111
  `ctx` carries `{ sessionId, agentProfile }`. `opts`: `order` (lower renders first,
112
112
  default 100), `tab: { id, label, badge? }` (**required** for tabbed slots — `session.aside`
@@ -289,7 +289,7 @@ Rules:
289
289
 
290
290
  ## Scaffold
291
291
 
292
- Turn a plain-language idea into a working skeleton, then read the generated files.
292
+ Turn a plain-language idea into a working skeleton.
293
293
 
294
294
  ### 1 — Understand the idea
295
295
 
@@ -333,20 +333,10 @@ This creates `~/.clacky/ext/local/<id>/` with a working hello panel + handler:
333
333
  Use `--full` only when the user needs the kitchen-sink reference exercising all eight
334
334
  contributes types — it's a lot to read, so prefer the plain scaffold otherwise.
335
335
 
336
- ### 3 — Read what was generated
336
+ ### 3 — Reshape to the idea
337
337
 
338
- Always read the generated `ext.yml`, `view.js`, `handler.rb`, and `test/handler_test.rb`
339
- before editing. This is your starting point; you'll reshape it to match the idea.
340
-
341
- ### 4 — Reshape to the idea
342
-
343
- Before editing, re-read the contract for whatever the idea needs (panel / API / patch /
344
- hook) in **Reference: the contracts** above — don't guess field names, slot names, hook
345
- events, or the `Clacky.ext` surface.
346
-
347
- The scaffold ships a working "hello" panel that pings its backend. Turn it into the real
348
- feature by editing those three files. Below is a concrete before → after for a tiny
349
- "add a note" panel — use it as the shape to copy, not the literal content.
338
+ Below is a concrete before → after for a tiny "add a note" panel — use it as
339
+ the shape to copy, not the literal content.
350
340
 
351
341
  **`ext.yml`** — rename the panel id/view to the feature; add `skills:`/`agents:` only if needed:
352
342
 
@@ -385,8 +375,7 @@ Clacky.ext.ui.mount("session.aside", function (container, ctx) {
385
375
  ```
386
376
 
387
377
  **`api/handler.rb`** — stay a `Clacky::ApiExtension` subclass; add the route the panel
388
- calls. Persist user data with `data_path` (lands in `~/.clacky/ext-data/<id>/`, survives
389
- reloads, `gem update`, and uninstall/reinstall), never into the code dir:
378
+ calls. Persist user data with `data_path`, never into the code dir:
390
379
 
391
380
  ```ruby
392
381
  class <Prefix>Ext < Clacky::ApiExtension
@@ -403,21 +392,11 @@ Rules while reshaping:
403
392
  are the #1 cause of a `loader.error`.
404
393
  - Routes in `handler.rb` are **relative** to `/api/ext/<id>/`; the `view.js` `fetch` must
405
394
  match. A mismatch is a silent 404, not a verify error.
406
- - Persist state with `data_path(...)`, never by writing into the extension's code dir.
407
- - The `ui.mount` render signature is `(container, ctx, runtime)` — `container` is the first
408
- argument, not `ctx`. Writing a shorter `(ctx) => ...` signature shifts every argument, so
409
- session checks misbehave. Returning `null` is safe (renders nothing); only a **thrown
410
- exception** shows the red crashed-panel box.
411
- - **UI styling: default to host classes; anything the host has no class for,
412
- build freely.** The host covers `btn-*` buttons, `form-*` inputs, `modal-*`
413
- dialogs, `Clacky.Modal.toast/confirm` feedback, and `var(--color-*)` colors
414
- (raw hex breaks the dark theme). For exact definitions grep the host
415
- stylesheet `lib/clacky/web/app.css`; the scaffold templates show real usage.
416
- - A skill is a `SKILL.md` under `skills/<id>/`; an agent is a `system_prompt.md` that can
417
- reference `panels: [id]` and `skills: [id]`. Add those blocks to `ext.yml` only if the
418
- idea needs them.
419
-
420
- ### 5 — Write tests
395
+ - **UI styling: default to host classes; anything the host has no class for, build
396
+ freely.** Colors: only `var(--color-*)` — raw hex breaks the dark theme. For the
397
+ full class/variable list grep the host stylesheet `lib/clacky/web/app.css`.
398
+
399
+ ### 4 — Write tests
421
400
 
422
401
  Test the backend before you call it done. Rewrite the scaffolded `test/handler_test.rb`
423
402
  to exercise your real routes (parse the JSON response, assert its fields), then run it
@@ -432,7 +411,7 @@ ruby test/handler_test.rb
432
411
  - A handler that only returns a static string may keep the scaffold's hello test;
433
412
  anything that fetches, transforms, caches, or computes MUST have a real test.
434
413
 
435
- ### 6 — Confirm it loads
414
+ ### 5 — Confirm it loads
436
415
 
437
416
  Run `clacky ext verify` and confirm the new units resolve with no errors, then have the
438
417
  user reload the WebUI page. If verify reports problems, go to **Debug & verify**.
@@ -459,7 +438,7 @@ is structured with a `code`, `message`, the offending `file`, and a `hint`.
459
438
 
460
439
  | Symptom | Almost always | Fix |
461
440
  |---|---|---|
462
- | Red error box where the panel should be | `ui.mount` render signature is wrong / returned `null` | signature is `(container, ctx, runtime)` — not `(ctx)` |
441
+ | Red error box where the panel should be | `ui.mount` render threw — usually a wrong signature | signature is `(container, ctx, runtime)` — not `(ctx)` |
463
442
  | Panel doesn't appear at all | `slot` name typo (silent) or no `attach:` | use a valid slot; set `attach: ["*"]` or an agent id |
464
443
  | Frontend `fetch` gets 404 | route in `handler.rb` ≠ path in `view.js` fetch | routes are relative to `/api/ext/<id>/` |
465
444
  | `loader.error` on verify | `ext.yml` `view:` path ≠ the on-disk `view.js` path | make the two match exactly |
@@ -530,7 +509,7 @@ If verify is clean but a change isn't visible:
530
509
  console for a "unknown slot" warning); (2) check the panel's `attach:` (or the agent
531
510
  that references it via `panels: [id]`) — a panel with no `attach` and no referencing
532
511
  agent has nothing to mount onto; (3) a red error box means the render function threw
533
- or returned `null` from a wrong signature — open the console for the stack.
512
+ (usually a wrong signature) — open the console for the stack.
534
513
  - **API 404?** Routes are relative to `/api/ext/<ext_id>/`. Confirm the handler subclasses
535
514
  `Clacky::ApiExtension` and the route pattern matches what `view.js` fetches.
536
515
  - **Skill not triggering?** The AI selects skills by their `description`. Make the
@@ -1,7 +1,7 @@
1
1
  id: git
2
2
  name: Git
3
3
  display_name: Git
4
- display_name_zh: Git
4
+ display_name_zh: Git 管理
5
5
  description: Changes panel — a friendly view of what the AI changed in the working tree
6
6
  description_zh: 变更面板 — 直观查看 AI 在工作区的每一处修改
7
7
  version: "0.1.0"