openclacky 0.8.2 → 0.8.3

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 534d725368218baaf47a39be9b5ce86005b53ebec0f3a2c81d01d754232b56d6
4
- data.tar.gz: 0d4cfa47f16a72ddc2cc35e4c7a714ef3bee1d961be5d135aa110804b692e9f1
3
+ metadata.gz: 473dceae1c35bc7eeb4095df2f35ea1403f6013168a3daf3318a709df4ce40ec
4
+ data.tar.gz: 9b7b97d917e13bf43ffff8fe50fda4f1eade74cd530e45959af46d9dbd313043
5
5
  SHA512:
6
- metadata.gz: 2d4250858cbb68fd49f0e25cadde03352ffd40566f05957d7de23b386e0e4a13668e02ae00858a0039ce3346eeff69a6bf2cba40da9e8074f3f954c22cc564c8
7
- data.tar.gz: 4a794d9b85a3a0a6f64e70e18e047aadc4d69b64780965f97a5c827403fff75536068937e7b7f824064f4bb0efbd6e793abcf4eb0975a1523d3e21fe27633624
6
+ metadata.gz: 6717eccea955e82d4dfa4381cb99707785d6ac36c3875ac622d51e854007d2997b16155af3e48d2b644aae3635d3989152a03e6f069bf525d8a8630049b6dfb0
7
+ data.tar.gz: 58cd3940c885363ae315e0430a318bb181713cdd0e5d1d4f7308343ce357f44075fd06cb8c4882f75824f857211c85c231b3d8b0cb2babe71ef6c76b5f21371c
data/CHANGELOG.md CHANGED
@@ -7,6 +7,19 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
7
7
 
8
8
  ## [Unreleased]
9
9
 
10
+ ## [0.8.3] - 2026-03-09
11
+
12
+ ### Added
13
+ - **Slash command skill injection**: skill content is now injected as an assistant message for all `/skill-name` commands, giving the agent full context of the skill instructions at invocation time
14
+ - **Collapsible `<think>` blocks** in web UI: model reasoning enclosed in `<think>…</think>` tags is rendered as a collapsible "Thinking…" section instead of raw text
15
+
16
+ ### Improved
17
+ - **Web UI settings panel**: refined layout and styles for the settings modal
18
+ - **Session state restored on page refresh**: "Thinking…" progress indicator and error messages are now restored from session status after a page reload instead of disappearing
19
+
20
+ ### Fixed
21
+ - **AgentConfig shallow-copy bug**: switching models in Settings no longer pollutes existing sessions — `deep_copy` (JSON round-trip) is now used everywhere instead of `dup` to prevent shared `@models` hash mutation across sessions
22
+
10
23
  ## [0.8.2] - 2026-03-09
11
24
 
12
25
  ### Added
@@ -52,6 +52,11 @@ module Clacky
52
52
  })
53
53
  end
54
54
  end
55
+
56
+ # Rebuild and refresh the system prompt so any newly installed skills
57
+ # (or other configuration changes since the session was saved) are
58
+ # reflected immediately — without requiring the user to create a new session.
59
+ refresh_system_prompt
55
60
  end
56
61
 
57
62
  # Generate session data for saving
@@ -174,6 +179,10 @@ module Clacky
174
179
  ui.show_user_message(display_text, created_at: msg[:created_at])
175
180
 
176
181
  round[:events].each do |ev|
182
+ # Skip system-injected messages (e.g. synthetic skill content, memory prompts)
183
+ # — they are internal scaffolding and must not be shown to the user.
184
+ next if ev[:system_injected]
185
+
177
186
  case ev[:role].to_s
178
187
  when "assistant"
179
188
  # Text content
@@ -210,6 +219,28 @@ module Clacky
210
219
 
211
220
  private
212
221
 
222
+ # Replace the system message in @messages with a freshly built system prompt.
223
+ # Called after restore_session so newly installed skills and any other
224
+ # configuration changes since the session was saved take effect immediately.
225
+ # If no system message exists yet (shouldn't happen in practice), a new one
226
+ # is prepended so the conversation stays well-formed.
227
+ def refresh_system_prompt
228
+ # Reload skills from disk to pick up anything installed since the session was saved
229
+ @skill_loader.load_all
230
+
231
+ fresh_prompt = build_system_prompt
232
+ system_index = @messages.index { |m| m[:role] == "system" }
233
+
234
+ if system_index
235
+ @messages[system_index] = { role: "system", content: fresh_prompt }
236
+ else
237
+ @messages.unshift({ role: "system", content: fresh_prompt })
238
+ end
239
+ rescue StandardError => e
240
+ # Log and continue — a stale system prompt is better than a broken restore
241
+ Clacky::Logger.warn("refresh_system_prompt failed during session restore: #{e.message}")
242
+ end
243
+
213
244
  # Extract text from message content (handles string and array formats)
214
245
  # @param content [String, Array, Object] Message content
215
246
  # @return [String] Extracted text
@@ -128,6 +128,47 @@ module Clacky
128
128
  context
129
129
  end
130
130
 
131
+ # Inject a synthetic assistant message containing the skill content for slash
132
+ # commands (e.g. /pptx, /onboard).
133
+ #
134
+ # When a user types "/skill-name [arguments]", we immediately expand the skill
135
+ # content and inject it as an assistant message so the LLM receives the full
136
+ # instructions and acts on them — no waiting for the LLM to discover and call
137
+ # invoke_skill on its own.
138
+ #
139
+ # Message structure after injection:
140
+ # user: "/pptx write a deck about X"
141
+ # assistant: "[full skill content]" <- injected here
142
+ # (LLM continues from here)
143
+ #
144
+ # Fires when:
145
+ # 1. Input starts with "/"
146
+ # 2. The named skill exists and is user-invocable
147
+ #
148
+ # @param user_input [String] Raw user input
149
+ # @param task_id [Integer] Current task ID (for message tagging)
150
+ # @return [void]
151
+ def inject_skill_command_as_assistant_message(user_input, task_id)
152
+ parsed = parse_skill_command(user_input)
153
+ return unless parsed
154
+
155
+ skill = parsed[:skill]
156
+ arguments = parsed[:arguments]
157
+
158
+ # Expand skill content (substitutes $ARGUMENTS if present)
159
+ expanded_content = skill.process_content(arguments, template_context: build_template_context)
160
+
161
+ # Inject as a synthetic assistant message so the LLM treats it as already read
162
+ @messages << {
163
+ role: "assistant",
164
+ content: expanded_content,
165
+ task_id: task_id,
166
+ system_injected: true
167
+ }
168
+
169
+ @ui&.log("Injected skill content for /#{skill.identifier}", level: :info)
170
+ end
171
+
131
172
  private
132
173
 
133
174
  # Filter skills by the agent profile name using the skill's own `agent:` field.
data/lib/clacky/agent.rb CHANGED
@@ -177,6 +177,11 @@ module Clacky
177
177
  @messages << { role: "user", content: user_content, task_id: task_id, created_at: Time.now.to_f }
178
178
  @total_tasks += 1
179
179
 
180
+ # If the user typed a slash command targeting a skill with disable-model-invocation: true,
181
+ # inject the skill content as a synthetic assistant message so the LLM can act on it.
182
+ # Skills already in the system prompt (model_invocation_allowed?) are skipped.
183
+ inject_skill_command_as_assistant_message(user_input, task_id)
184
+
180
185
  @hooks.trigger(:on_start, user_input)
181
186
 
182
187
  begin
@@ -671,7 +676,7 @@ module Clacky
671
676
  # @return [Agent] New subagent instance
672
677
  def fork_subagent(model: nil, forbidden_tools: [], system_prompt_suffix: nil)
673
678
  # Clone config to avoid affecting parent
674
- subagent_config = @config.dup
679
+ subagent_config = @config.deep_copy
675
680
 
676
681
  # Switch to specified model if provided
677
682
  if model
@@ -226,6 +226,16 @@ module Clacky
226
226
  end
227
227
 
228
228
  # Save configuration to file
229
+ # Deep copy — models array contains mutable Hashes, so a shallow dup would
230
+ # let the copy share the same Hash objects with the original, causing
231
+ # Settings changes to silently mutate already-running session configs.
232
+ # JSON round-trip is the cleanest approach since @models is pure JSON-able data.
233
+ def deep_copy
234
+ copy = dup
235
+ copy.instance_variable_set(:@models, JSON.parse(JSON.generate(@models)))
236
+ copy
237
+ end
238
+
229
239
  def save(config_file = CONFIG_FILE)
230
240
  config_dir = File.dirname(config_file)
231
241
  FileUtils.mkdir_p(config_dir)
@@ -1115,7 +1115,7 @@ module Clacky
1115
1115
  session_id = @registry.create(name: name, working_dir: working_dir)
1116
1116
 
1117
1117
  client = @client_factory.call
1118
- config = @agent_config.dup
1118
+ config = @agent_config.deep_copy
1119
1119
  config.permission_mode = permission_mode
1120
1120
  broadcaster = method(:broadcast)
1121
1121
  ui = WebUIController.new(session_id, broadcaster)
@@ -1142,7 +1142,7 @@ module Clacky
1142
1142
  session_id: original_id)
1143
1143
 
1144
1144
  client = @client_factory.call
1145
- config = @agent_config.dup
1145
+ config = @agent_config.deep_copy
1146
1146
  broadcaster = method(:broadcast)
1147
1147
  ui = WebUIController.new(session_id, broadcaster)
1148
1148
  agent = Clacky::Agent.from_session(client, config, session_data, ui: ui, profile: "general")
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Clacky
4
- VERSION = "0.8.2"
4
+ VERSION = "0.8.3"
5
5
  end
@@ -401,6 +401,7 @@ body {
401
401
  #chat-title { font-weight: 600; font-size: 15px; }
402
402
  .status-idle { font-size: 11px; color: #8b949e; }
403
403
  .status-running { font-size: 11px; color: #3fb950; }
404
+ .status-error { font-size: 11px; color: #f85149; }
404
405
 
405
406
  /* Delete session button in chat header */
406
407
  .btn-delete-session {
@@ -500,6 +501,52 @@ body {
500
501
  .tool-item-status.err { color: #f85149; }
501
502
  .tool-item-status.running { color: #58a6ff; animation: pulse 1.2s infinite; }
502
503
 
504
+ /* ── Thinking block (collapsible <think>...</think> sections) ─────────────── */
505
+ .thinking-block {
506
+ margin: 0 0 6px 0;
507
+ background: transparent;
508
+ overflow: hidden;
509
+ }
510
+
511
+ .thinking-summary {
512
+ display: inline-flex;
513
+ align-items: center;
514
+ gap: 5px;
515
+ padding: 0;
516
+ cursor: pointer;
517
+ user-select: none;
518
+ list-style: none;
519
+ color: #6e7681;
520
+ font-size: 12px;
521
+ font-style: italic;
522
+ transition: color 0.15s;
523
+ }
524
+ .thinking-summary::-webkit-details-marker { display: none; }
525
+ .thinking-summary:hover { color: #8b949e; }
526
+
527
+ .thinking-icon { display: none; }
528
+ .thinking-label { font-weight: 400; }
529
+ .thinking-chevron {
530
+ font-size: 12px;
531
+ color: inherit;
532
+ transition: transform 0.2s;
533
+ line-height: 1;
534
+ display: inline-block;
535
+ }
536
+ .thinking-block[open] .thinking-chevron { transform: rotate(90deg); }
537
+
538
+ .thinking-body {
539
+ margin-top: 2px;
540
+ margin-bottom: 10px;
541
+ padding-left: 14px;
542
+ font-size: 12px;
543
+ line-height: 1.5;
544
+ color: #6e7681;
545
+ white-space: pre-wrap;
546
+ word-break: break-word;
547
+ font-style: italic;
548
+ }
549
+
503
550
  /* Inline image thumbnails inside user message bubbles */
504
551
  .msg-image-thumb {
505
552
  display: block;
@@ -828,6 +875,7 @@ body {
828
875
  display: flex;
829
876
  flex-direction: column;
830
877
  gap: 14px;
878
+ margin-bottom: 16px;
831
879
  }
832
880
  .model-card-header {
833
881
  display: flex;
@@ -958,6 +1006,27 @@ body {
958
1006
  font-size: 13px;
959
1007
  padding: 6px 18px;
960
1008
  }
1009
+ .btn-set-default {
1010
+ font-size: 13px;
1011
+ padding: 6px 14px;
1012
+ background: #238636;
1013
+ color: #fff;
1014
+ border: none;
1015
+ border-radius: 6px;
1016
+ cursor: pointer;
1017
+ }
1018
+ .btn-set-default:hover {
1019
+ background: #2ea043;
1020
+ }
1021
+ .btn-set-default:disabled {
1022
+ background: #484f58;
1023
+ cursor: not-allowed;
1024
+ }
1025
+ .model-card-actions-row {
1026
+ display: flex;
1027
+ gap: 8px;
1028
+ align-items: center;
1029
+ }
961
1030
 
962
1031
  /* ── Modals ──────────────────────────────────────────────────────────────── */
963
1032
  .modal-overlay {
@@ -23,6 +23,59 @@ const Sessions = (() => {
23
23
  let _pendingRunTaskId = null; // session_id waiting to send "run_task" after subscribe
24
24
  let _pendingMessage = null; // { session_id, content } — slash command to send after subscribe
25
25
 
26
+ // ── Thinking block parser ──────────────────────────────────────────────
27
+ //
28
+ // Converts <think>...</think> blocks in assistant messages into collapsible
29
+ // "Thinking" sections. The block is collapsed by default and can be
30
+ // expanded by clicking the header.
31
+
32
+ function _parseThinkingBlocks(rawHtml) {
33
+ // rawHtml is already HTML-escaped text (via escapeHtml). We need to detect
34
+ // the escaped versions of <think> and </think>.
35
+ const OPEN = "&lt;think&gt;";
36
+ const CLOSE = "&lt;/think&gt;";
37
+
38
+ // Fast path: no thinking block present
39
+ if (!rawHtml.includes(OPEN)) return rawHtml;
40
+
41
+ let result = "";
42
+ let rest = rawHtml;
43
+
44
+ while (rest.includes(OPEN)) {
45
+ const openIdx = rest.indexOf(OPEN);
46
+ const closeIdx = rest.indexOf(CLOSE, openIdx + OPEN.length);
47
+
48
+ // Prepend any text before the <think> block
49
+ result += rest.slice(0, openIdx);
50
+
51
+ if (closeIdx === -1) {
52
+ // Unclosed <think> — treat remainder as plain text
53
+ result += rest.slice(openIdx);
54
+ rest = "";
55
+ break;
56
+ }
57
+
58
+ const thinkContent = rest.slice(openIdx + OPEN.length, closeIdx);
59
+ result += _buildThinkingBlock(thinkContent);
60
+ // Strip leading newlines after </think> to avoid blank space from pre-wrap
61
+ rest = rest.slice(closeIdx + CLOSE.length).replace(/^\n+/, "");
62
+ }
63
+
64
+ result += rest;
65
+ return result;
66
+ }
67
+
68
+ // Build the collapsible thinking block HTML for a given (already-escaped) content string.
69
+ function _buildThinkingBlock(escapedContent) {
70
+ return `<details class="thinking-block">` +
71
+ `<summary class="thinking-summary">` +
72
+ `<span class="thinking-chevron">›</span>` +
73
+ `<span class="thinking-label">Thought for a moment</span>` +
74
+ `</summary>` +
75
+ `<div class="thinking-body">${escapedContent}</div>` +
76
+ `</details>`;
77
+ }
78
+
26
79
  // ── Private helpers ────────────────────────────────────────────────────
27
80
 
28
81
  function _cacheActiveMessages() {
@@ -151,7 +204,7 @@ const Sessions = (() => {
151
204
  if (historyCtx.group) { _collapseToolGroup(historyCtx.group); historyCtx.group = null; }
152
205
  const el = document.createElement("div");
153
206
  el.className = "msg msg-assistant";
154
- el.innerHTML = escapeHtml(ev.content || "");
207
+ el.innerHTML = _parseThinkingBlocks(escapeHtml(ev.content || ""));
155
208
  container.appendChild(el);
156
209
  break;
157
210
  }
@@ -241,6 +294,21 @@ const Sessions = (() => {
241
294
  messages.appendChild(frag);
242
295
  messages.scrollTop = messages.scrollHeight;
243
296
  }
297
+
298
+ // Restore transient UI state based on session status after initial load
299
+ // (not prepend, which is scroll-up pagination — no need to re-restore then)
300
+ if (!prepend) {
301
+ const session = _sessions.find(s => s.id === id);
302
+ if (session) {
303
+ if (session.status === "running") {
304
+ // Agent is still running (e.g. page was refreshed mid-task)
305
+ Sessions.showProgress("Thinking…");
306
+ } else if (session.status === "error" && session.error) {
307
+ // Show the stored error message at the end of history
308
+ Sessions.appendMsg("error", session.error);
309
+ }
310
+ }
311
+ }
244
312
  }
245
313
  } finally {
246
314
  state.loading = false;
@@ -401,7 +469,13 @@ const Sessions = (() => {
401
469
 
402
470
  updateStatusBar(status) {
403
471
  $("chat-status").textContent = status || "idle";
404
- $("chat-status").className = status === "running" ? "status-running" : "status-idle";
472
+ if (status === "running") {
473
+ $("chat-status").className = "status-running";
474
+ } else if (status === "error") {
475
+ $("chat-status").className = "status-error";
476
+ } else {
477
+ $("chat-status").className = "status-idle";
478
+ }
405
479
  $("btn-interrupt").style.display = status === "running" ? "" : "none";
406
480
  },
407
481
 
@@ -447,7 +521,8 @@ const Sessions = (() => {
447
521
  const messages = $("messages");
448
522
  const el = document.createElement("div");
449
523
  el.className = `msg msg-${type}`;
450
- el.innerHTML = html;
524
+ // Parse thinking blocks out of assistant messages
525
+ el.innerHTML = type === "assistant" ? _parseThinkingBlocks(html) : html;
451
526
  messages.appendChild(el);
452
527
  messages.scrollTop = messages.scrollHeight;
453
528
  },
@@ -107,7 +107,10 @@ const Settings = (() => {
107
107
 
108
108
  <div class="model-card-footer">
109
109
  <span class="model-test-result" data-index="${index}"></span>
110
- <button class="btn-save-model btn-primary" data-index="${index}">Save</button>
110
+ <div class="model-card-actions-row">
111
+ ${!isDefault ? `<button class="btn-set-default" data-index="${index}" title="Set as default model">Set as Default</button>` : ""}
112
+ <button class="btn-save-model btn-primary" data-index="${index}">Save</button>
113
+ </div>
111
114
  </div>
112
115
  `;
113
116
 
@@ -143,6 +146,12 @@ const Settings = (() => {
143
146
  if (removeBtn) {
144
147
  removeBtn.addEventListener("click", () => _removeModel(index));
145
148
  }
149
+
150
+ // Set as default model
151
+ const setDefaultBtn = card.querySelector(".btn-set-default");
152
+ if (setDefaultBtn) {
153
+ setDefaultBtn.addEventListener("click", () => _setAsDefault(index));
154
+ }
146
155
  }
147
156
 
148
157
  // ── Read form values from a card ────────────────────────────────────────────
@@ -233,16 +242,69 @@ const Settings = (() => {
233
242
  el.className = `model-test-result ${ok ? "result-ok" : "result-fail"}`;
234
243
  }
235
244
 
245
+ // ── Set as Default Model ───────────────────────────────────────────────────
246
+
247
+ async function _setAsDefault(index) {
248
+ const btn = document.querySelector(`.btn-set-default[data-index="${index}"]`);
249
+ if (!btn) return;
250
+
251
+ btn.disabled = true;
252
+ btn.textContent = "Setting…";
253
+
254
+ // Set the selected one as "default", others as null (not just delete)
255
+ // Using null ensures the server explicitly updates/removes the type field
256
+ const updatedModels = _models.map((m, i) => {
257
+ const model = { ...m };
258
+ if (i === index) {
259
+ model.type = "default";
260
+ } else {
261
+ model.type = null;
262
+ }
263
+ return model;
264
+ });
265
+
266
+ try {
267
+ const res = await fetch("/api/config", {
268
+ method: "POST",
269
+ headers: { "Content-Type": "application/json" },
270
+ body: JSON.stringify({ models: updatedModels })
271
+ });
272
+ const data = await res.json();
273
+
274
+ if (data.ok) {
275
+ btn.textContent = "Done ✓";
276
+ // Reload to refresh the UI
277
+ setTimeout(_load, 800);
278
+ } else {
279
+ btn.textContent = "Set as Default";
280
+ btn.disabled = false;
281
+ alert(data.error || "Failed to set default model");
282
+ }
283
+ } catch (e) {
284
+ btn.textContent = "Set as Default";
285
+ btn.disabled = false;
286
+ alert("Error: " + e.message);
287
+ }
288
+ }
289
+
236
290
  // ── Add / Remove model ───────────────────────────────────────────────────────
237
291
 
238
292
  function _addModel() {
293
+ // When adding a new model, automatically set it as default.
294
+ // Set all existing models' type to null (not just delete) so server updates them.
295
+ _models = _models.map(m => {
296
+ const model = { ...m };
297
+ model.type = null;
298
+ return model;
299
+ });
300
+
239
301
  _models.push({
240
302
  index: _models.length,
241
303
  model: "",
242
304
  base_url: "",
243
305
  api_key_masked: "",
244
306
  anthropic_format: false,
245
- type: null
307
+ type: "default" // New model automatically becomes default
246
308
  });
247
309
  _renderCards();
248
310
  // Scroll to the new card
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: openclacky
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.8.2
4
+ version: 0.8.3
5
5
  platform: ruby
6
6
  authors:
7
7
  - windy