openclacky 0.9.30 → 0.9.31

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: 85678c695dcd96512aecef3a290beefa0f08b605a351da12c2db6072d6852147
4
- data.tar.gz: 298820c8aabc0a13cb874fa577a5cf12757aa403b804c79d0fa804a5142ecbd0
3
+ metadata.gz: eb05bb9cc5c24901331584bde84b85b264536fa838001bcf113f41df5a96dbce
4
+ data.tar.gz: a8bde52bacb92f46a894582ad9b00afa2a325323b464194fbbd744a4cbbddc77
5
5
  SHA512:
6
- metadata.gz: 1d291319e545f25c169341a14e54aa740d17107e2cd97ce39d9285ba7820ba69e3f10f14c15e26e579bfe71d471023be15a6e7ff225131fd77aeb9b916cbcc79
7
- data.tar.gz: f89731bd9fc60ca68def237f4cbda3d998dfa306da9419e24b2c3f2dc6cf91c007a80e8bb658685a85d936dab58f246b55bbed7ead92637f0d2b857e645884af
6
+ metadata.gz: 3684277987068171be446db5ab6efa3fe33304714d963cd757cc2102477911a13dc2b68f7dec2952d020f1bfd6427d8510469214dcdc8c1e8666995c93ddb098
7
+ data.tar.gz: 7a84b5fc71f3beb237ed07475bd7a72dac6162c9b519e732e1aba2cafe0af05e3d8f1a4d90e3ec857a084a158113720213a873ea73c997ebb553eea39e61964a
data/CHANGELOG.md CHANGED
@@ -7,6 +7,26 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
7
7
 
8
8
  ## [Unreleased]
9
9
 
10
+ ## [0.9.31] - 2026-04-18
11
+
12
+ ### Added
13
+ - GLM (智谱) model provider support — select GLM models directly from the provider settings
14
+ - Claude Opus 4.7 model option in the built-in provider list
15
+ - Skill Creator UI — create and edit skills from the Web interface with a visual editor
16
+ - Interactive feedback cards — `request_user_feedback` now renders as a styled interactive card in all UIs (Web, UI2, plain), instead of plain text
17
+ - Brand deactivation — white-label brand can now be toggled on/off from the settings page
18
+ - Empty skill placeholder — shows a friendly message when no skills are installed yet
19
+
20
+ ### Improved
21
+ - Shell tool large output handling — when a shell command waits for input or times out with large output, the output is now properly truncated and saved to temp files so the agent can still read the full content
22
+ - Chinese UI translations expanded with new thinkverbose labels
23
+
24
+ ### Fixed
25
+ - Bedrock streaming truncation recovery — when a tool call's arguments are truncated by the API, the broken assistant message is now retracted from history and the agent retries cleanly instead of crashing
26
+ - First session scroll position in the Web UI sidebar
27
+ - Idle status indicator in UI2
28
+ - Channels page spacing and skill creator label alignment in Web UI
29
+
10
30
  ## [0.9.30] - 2026-04-16
11
31
 
12
32
  ### Added
@@ -414,7 +414,8 @@ module Clacky
414
414
  parts = []
415
415
  parts << "**Context:** #{context.strip}" << "" unless context.strip.empty?
416
416
  parts << "**Question:** #{question.strip}"
417
- if options && !options.empty?
417
+ # Guard: options must be an Array to iterate with each_with_index
418
+ if options.is_a?(Array) && !options.empty?
418
419
  parts << "" << "**Options:**"
419
420
  options.each_with_index { |opt, i| parts << " #{i + 1}. #{opt}" }
420
421
  end
data/lib/clacky/agent.rb CHANGED
@@ -698,11 +698,13 @@ module Clacky
698
698
  @ui&.update_todos(@todos.dup)
699
699
  end
700
700
 
701
- # Special handling for request_user_feedback: show directly as message
701
+ # Special handling for request_user_feedback: emit as interactive feedback card
702
702
  if call[:name] == "request_user_feedback"
703
- if result.is_a?(Hash) && result[:message]
704
- @ui&.show_assistant_message(result[:message], files: [])
705
- end
703
+ # Pass the raw call arguments to show_tool_call so the WebUI controller
704
+ # can extract question/context/options and emit a "request_feedback" event
705
+ # (renders as a clickable card in the browser).
706
+ # Fallback UIs (terminal, IM channels) receive the formatted text message.
707
+ @ui&.show_tool_call(call[:name], call[:arguments])
706
708
 
707
709
  if @config.permission_mode == :auto_approve
708
710
  # auto_approve means no human is watching (unattended/scheduled tasks).
@@ -734,6 +736,17 @@ module Clacky
734
736
  }
735
737
  Clacky::Logger.error("tool_execution_error", tool: call[:name], error: e)
736
738
 
739
+ # If arguments were malformed/truncated (e.g. Bedrock streaming truncation),
740
+ # retract the bad assistant message from history so the next LLM call gets a
741
+ # fresh context rather than re-reading a cached broken tool call.
742
+ # Also skip adding a tool_result — without the assistant message there is no
743
+ # tool_call to pair with, and sending an orphan tool_result breaks the API.
744
+ if e.is_a?(Utils::BadArgumentsError)
745
+ size_before = @history.size
746
+ @history.pop_while { |m| m[:role] == "assistant" && m[:tool_calls]&.any? { |tc| tc[:id] == call[:id] } }
747
+ next if @history.size < size_before # message was retracted, skip tool_result
748
+ end
749
+
737
750
  @hooks.trigger(:on_tool_error, call, e)
738
751
  @ui&.show_tool_error(e)
739
752
  # Use build_denied_result with system_injected=true so LLM knows it can retry
@@ -135,6 +135,29 @@ module Clacky
135
135
  FileUtils.chmod(0o600, BRAND_FILE)
136
136
  end
137
137
 
138
+ # Remove the local license binding and wipe all brand-related fields from disk.
139
+ # Brand skills installed from this license are also cleared.
140
+ # Returns { success: true }.
141
+ def deactivate!
142
+ clear_brand_skills!
143
+ FileUtils.rm_f(BRAND_FILE)
144
+ # Reset all in-memory state so this instance is clean after the call.
145
+ @product_name = nil
146
+ @package_name = nil
147
+ @logo_url = nil
148
+ @support_contact = nil
149
+ @support_qr_url = nil
150
+ @theme_color = nil
151
+ @homepage_url = nil
152
+ @license_key = nil
153
+ @license_activated_at = nil
154
+ @license_expires_at = nil
155
+ @license_last_heartbeat = nil
156
+ @license_user_id = nil
157
+ @device_id = nil
158
+ { success: true }
159
+ end
160
+
138
161
  # Activate the license against the OpenClacky Cloud API using HMAC proof.
139
162
  # Returns a result hash: { success: bool, message: String, data: Hash }
140
163
  def activate!(license_key)
@@ -41,21 +41,21 @@ Example (Chinese):
41
41
  ### 2. Ask the user to name the AI (card)
42
42
 
43
43
  Call `request_user_feedback` to let the user pick or type a name for their AI assistant.
44
- Offer a few fun suggestions as options, plus a free-text fallback.
44
+ Offer a few fun suggestions as options. The user can also ignore the options and type any name directly.
45
45
 
46
46
  If `lang == "zh"`, use:
47
47
  ```json
48
48
  {
49
- "question": "先来点有意思的 —— 你想叫我什么名字?可以选一个,也可以直接输入你喜欢的:",
50
- "options": ["🐟 摸鱼王", "📚 卷王", "🌟 小天才", "🐱 本喵", "🌅 拾光", "自己输入名字…"]
49
+ "question": "先来点有意思的 —— 你想叫我什么名字?",
50
+ "options": ["摸鱼王", "老六", "夜猫子", "话唠", "包打听", "碎碎念", "掌柜的"]
51
51
  }
52
52
  ```
53
53
 
54
54
  Otherwise (English):
55
55
  ```json
56
56
  {
57
- "question": "Let's start with something fun — what would you like to call me? Pick one or type your own:",
58
- "options": ["✨ Aria", "🤖 Max", "🌙 Luna", "⚡ Zap", "🎯 Ace", "Type your own name…"]
57
+ "question": "Let's start with something fun — what would you like to call me?",
58
+ "options": ["Nox", "Sable", "Remy", "Vex", "Pip", "Zola", "Bex"]
59
59
  }
60
60
  ```
61
61
 
@@ -108,7 +108,11 @@ Call `request_user_feedback` again. This is where we learn about the user themse
108
108
  If `lang == "zh"`, use:
109
109
  ```json
110
110
  {
111
- "question": "那你呢?随便聊聊自己吧 —— 全部可选,填多少都行:\n• 你的名字(我该怎么称呼你?)\n• 职业\n• 最希望用 AI 做什么\n• 社交 / 作品链接(GitHub、微博、个人网站等)—— 我会读取公开信息来更了解你",
111
+ "question": "那你呢?随便聊聊自己吧 —— 全部可选,填多少都行:
112
+ - 你的名字(我该怎么称呼你?)
113
+ - 职业
114
+ - 最希望用 AI 做什么
115
+ - 社交 / 作品链接(GitHub、微博、个人网站等)—— 我会读取公开信息来更了解你",
112
116
  "options": []
113
117
  }
114
118
  ```
@@ -116,7 +120,11 @@ If `lang == "zh"`, use:
116
120
  Otherwise (English):
117
121
  ```json
118
122
  {
119
- "question": "Now a bit about you — all optional, skip anything you like.\n• Your name (what should I call you?)\n• Occupation\n• What you want to use AI for most\n• Social / portfolio links (GitHub, Twitter/X, personal site…) — I'll read them to learn about you",
123
+ "question": "Now a bit about you — all optional, skip anything you like.
124
+ - Your name (what should I call you?)
125
+ - Occupation
126
+ - What you want to use AI for most
127
+ - Social / portfolio links (GitHub, Twitter/X, personal site…) — I'll read them to learn about you",
120
128
  "options": []
121
129
  }
122
130
  ```
@@ -23,6 +23,25 @@ module Clacky
23
23
 
24
24
  def show_tool_call(name, args)
25
25
  args_data = args.is_a?(String) ? (JSON.parse(args) rescue args) : args
26
+
27
+ # Special handling for request_user_feedback — display as a readable prompt
28
+ if name.to_s == "request_user_feedback"
29
+ question = args_data.is_a?(Hash) ? (args_data[:question] || args_data["question"]).to_s : ""
30
+ context = args_data.is_a?(Hash) ? (args_data[:context] || args_data["context"]).to_s : ""
31
+ options = args_data.is_a?(Hash) ? (args_data[:options] || args_data["options"]) : nil
32
+ options = Array(options) if options && !options.is_a?(Array)
33
+
34
+ parts = []
35
+ parts << "**Context:** #{context.strip}" if context && !context.strip.empty?
36
+ parts << "**Question:** #{question.strip}"
37
+ if options && !options.empty?
38
+ parts << "**Options:**"
39
+ options.each_with_index { |opt, i| parts << " #{i + 1}. #{opt}" }
40
+ end
41
+ puts_line(parts.join("\n"))
42
+ return
43
+ end
44
+
26
45
  display = case name
27
46
  when "shell", "safe_shell"
28
47
  cmd = args_data.is_a?(Hash) ? (args_data[:command] || args_data["command"]) : args_data
@@ -18,6 +18,7 @@ module Clacky
18
18
  "default_model" => "abs-claude-sonnet-4-5",
19
19
  "lite_model" => "abs-claude-haiku-4-5",
20
20
  "models" => [
21
+ "abs-claude-opus-4-7",
21
22
  "abs-claude-opus-4-6",
22
23
  "abs-claude-sonnet-4-6",
23
24
  "abs-claude-sonnet-4-5",
@@ -63,7 +64,7 @@ module Clacky
63
64
  "base_url" => "https://api.anthropic.com",
64
65
  "api" => "anthropic-messages",
65
66
  "default_model" => "claude-sonnet-4.6",
66
- "models" => ["claude-opus-4-6", "claude-sonnet-4.6", "claude-haiku-4.5"],
67
+ "models" => ["claude-opus-4-7", "claude-opus-4-6", "claude-sonnet-4.6", "claude-haiku-4.5"],
67
68
  "website_url" => "https://console.anthropic.com/settings/keys"
68
69
  }.freeze,
69
70
 
@@ -74,6 +75,7 @@ module Clacky
74
75
  "default_model" => "abs-claude-sonnet-4-5",
75
76
  "lite_model" => "abs-claude-haiku-4-5",
76
77
  "models" => [
78
+ "abs-claude-opus-4-7",
77
79
  "abs-claude-opus-4-6",
78
80
  "abs-claude-sonnet-4-6",
79
81
  "abs-claude-sonnet-4-5",
@@ -94,6 +96,15 @@ module Clacky
94
96
  "default_model" => "mimo-v2-pro",
95
97
  "models" => ["mimo-v2-pro", "mimo-v2-omni"],
96
98
  "website_url" => "https://platform.xiaomimimo.com/"
99
+ }.freeze,
100
+
101
+ "glm" => {
102
+ "name" => "GLM (ZhipuAI)",
103
+ "base_url" => "https://open.bigmodel.cn/api/paas/v4",
104
+ "api" => "openai-completions",
105
+ "default_model" => "glm-5.1",
106
+ "models" => ["glm-5.1", "glm-5", "glm-5-turbo", "glm-5v-turbo", "glm-4.7"],
107
+ "website_url" => "https://open.bigmodel.cn/usercenter/apikeys"
97
108
  }.freeze
98
109
 
99
110
  }.freeze
@@ -281,7 +281,7 @@ module Clacky
281
281
 
282
282
  server.mount_proc("/") do |req, res|
283
283
  if req.path == "/" || req.path == "/index.html"
284
- product_name = Clacky::BrandConfig.load.product_name || "Clacky"
284
+ product_name = Clacky::BrandConfig.load.product_name || "OpenClacky"
285
285
  html = File.read(index_html_path).gsub("{{BRAND_NAME}}", product_name)
286
286
  res.status = 200
287
287
  res["Content-Type"] = "text/html; charset=utf-8"
@@ -375,6 +375,7 @@ module Clacky
375
375
  when ["GET", "/api/store/skills"] then api_store_skills(res)
376
376
  when ["GET", "/api/brand/status"] then api_brand_status(res)
377
377
  when ["POST", "/api/brand/activate"] then api_brand_activate(req, res)
378
+ when ["DELETE", "/api/brand/license"] then api_brand_deactivate(res)
378
379
  when ["GET", "/api/brand/skills"] then api_brand_skills(res)
379
380
  when ["GET", "/api/brand"] then api_brand_info(res)
380
381
  when ["GET", "/api/creator/skills"] then api_creator_skills(res)
@@ -680,6 +681,17 @@ module Clacky
680
681
  end
681
682
  end
682
683
 
684
+ # DELETE /api/brand/license
685
+ # Deactivates (unbinds) the current brand license and clears all brand state.
686
+ # Brand skills are removed from disk. Returns 200 on success.
687
+ private def api_brand_deactivate(res)
688
+ brand = Clacky::BrandConfig.load
689
+ result = brand.deactivate!
690
+ # Reload skill_loader without brand config so brand skills are no longer visible.
691
+ @skill_loader = Clacky::SkillLoader.new(working_dir: nil, brand_config: Clacky::BrandConfig.new({}))
692
+ json_response(res, 200, { ok: true })
693
+ end
694
+
683
695
  # GET /api/brand/skills
684
696
  # Fetches the brand skills list from the cloud, enriched with local installed version.
685
697
  # Returns 200 with skill list, or 403 when license is not activated.
@@ -92,11 +92,25 @@ module Clacky
92
92
  end
93
93
 
94
94
  def show_tool_call(name, args)
95
- # Skip request_user_feedback — its question is already shown as an assistant message
96
- return if name.to_s == "request_user_feedback"
97
-
98
95
  args_data = args.is_a?(String) ? (JSON.parse(args) rescue args) : args
99
96
 
97
+ # Special handling for request_user_feedback — emit a dedicated UI event
98
+ if name.to_s == "request_user_feedback"
99
+ question = args_data.is_a?(Hash) ? (args_data[:question] || args_data["question"]).to_s : ""
100
+ context = args_data.is_a?(Hash) ? (args_data[:context] || args_data["context"]).to_s : ""
101
+ options = args_data.is_a?(Hash) ? (args_data[:options] || args_data["options"]) : nil
102
+
103
+ # Normalize options to array (guard against malformed data)
104
+ options = Array(options) if options && !options.is_a?(Array)
105
+
106
+ emit("request_feedback",
107
+ question: question,
108
+ context: context,
109
+ options: options || [])
110
+ # Don't forward to IM subscribers — they get the formatted text version already
111
+ return
112
+ end
113
+
100
114
  # Generate a human-readable summary using the tool's format_call method
101
115
  summary = tool_call_summary(name, args_data)
102
116
 
@@ -179,13 +193,6 @@ module Clacky
179
193
  forward_to_subscribers { |sub| sub.show_info(message) }
180
194
  end
181
195
 
182
- # Emit a two-phase idle compression status update.
183
- # The frontend uses the same DOM element for both phases so it renders as one line.
184
- # phase: :start → show spinner message; phase: :end → update in-place with final result
185
- def show_idle_status(phase:, message:)
186
- emit("idle_status", phase: phase.to_s, message: message)
187
- end
188
-
189
196
  def show_warning(message)
190
197
  emit("warning", message: message)
191
198
  forward_to_subscribers { |sub| sub.show_warning(message) }
@@ -209,10 +216,21 @@ module Clacky
209
216
  # === Progress ===
210
217
 
211
218
  def show_progress(message = nil, prefix_newline: true, progress_type: "thinking", phase: "active", metadata: {})
212
- @progress_start_time = Time.now if phase == "active"
213
- @live_progress_message = message
214
- # Reset stdout buffer for each new command so re-subscribe only replays current run
215
- @live_stdout_buffer = [] if phase == "active"
219
+ if phase == "active"
220
+ @progress_start_time = Time.now
221
+ # Store complete progress state for replay when user switches back to this session
222
+ @live_progress_state = {
223
+ message: message,
224
+ progress_type: progress_type,
225
+ metadata: metadata
226
+ }
227
+ # Reset stdout buffer for each new command so re-subscribe only replays current run
228
+ @live_stdout_buffer = []
229
+ elsif phase == "done"
230
+ # Clear progress state when done
231
+ @live_progress_state = nil
232
+ @progress_start_time = nil
233
+ end
216
234
 
217
235
  data = {
218
236
  message: message,
@@ -225,8 +243,6 @@ module Clacky
225
243
 
226
244
  emit("progress", **data)
227
245
  forward_to_subscribers { |sub| sub.show_progress(message) }
228
-
229
- @progress_start_time = nil if phase == "done"
230
246
  end
231
247
 
232
248
  # Stream shell stdout/stderr lines to the browser while a command is running.
@@ -264,9 +280,17 @@ module Clacky
264
280
  # The frontend's appendToolStdout will attach to the last visible .tool-item
265
281
  # even when _liveLastToolItem is null (after the tab re-loaded).
266
282
  def replay_live_state
267
- return unless @live_progress_message
268
-
269
- emit("progress", message: @live_progress_message, status: "start")
283
+ return unless @live_progress_state
284
+
285
+ # Replay complete progress state (not just message)
286
+ state = @live_progress_state
287
+ emit("progress",
288
+ message: state[:message],
289
+ progress_type: state[:progress_type],
290
+ phase: "active",
291
+ status: "start",
292
+ metadata: state[:metadata] || {}
293
+ )
270
294
 
271
295
  buf = @live_stdout_buffer
272
296
  emit("tool_stdout", lines: buf) if buf && !buf.empty?
@@ -397,15 +397,18 @@ module Clacky
397
397
  end
398
398
 
399
399
  def format_waiting_input_result(command, stdout, stderr, interaction, max_output_lines)
400
+ truncated_stdout = truncate_output(stdout, max_output_lines)
401
+ truncated_stderr = truncate_output(stderr, max_output_lines)
400
402
  {
401
403
  command: command,
402
- stdout: truncate_output(stdout, max_output_lines),
403
- stderr: truncate_output(stderr, max_output_lines),
404
+ stdout: truncated_stdout,
405
+ stderr: truncated_stderr,
404
406
  exit_code: -2,
405
407
  success: false,
406
408
  state: 'WAITING_INPUT',
407
409
  interaction_type: interaction[:type],
408
- message: format_waiting_message(truncate_output(stdout, max_output_lines), interaction),
410
+ interaction: interaction,
411
+ message: format_waiting_message(truncated_stdout, interaction),
409
412
  output_truncated: output_truncated?(stdout, stderr, max_output_lines)
410
413
  }
411
414
  end
@@ -441,6 +444,11 @@ module Clacky
441
444
  MSG
442
445
  end
443
446
 
447
+ def extract_last_line(output)
448
+ return "" if output.nil? || output.empty?
449
+ output.lines.last&.strip.to_s[0..200]
450
+ end
451
+
444
452
  def format_timeout_result(command, stdout, stderr, elapsed, type, timeout, max_output_lines)
445
453
  {
446
454
  command: command,
@@ -506,32 +514,43 @@ module Clacky
506
514
  MAX_LINE_CHARS = 500
507
515
 
508
516
  def format_result_for_llm(result)
509
- return result if result[:error] || result[:state] == 'TIMEOUT' || result[:state] == 'WAITING_INPUT'
517
+ return result if result[:error]
510
518
 
511
519
  enc = Clacky::Utils::Encoding
520
+ command_name = extract_command_name(enc.to_utf8(result[:command].to_s))
521
+
522
+ # Apply truncate_and_save to all states including WAITING_INPUT and TIMEOUT
512
523
  stdout = enc.to_utf8(result[:stdout] || "")
513
524
  stderr = enc.to_utf8(result[:stderr] || "")
514
- exit_code = result[:exit_code] || 0
525
+
526
+ stdout_info = truncate_and_save(stdout, MAX_LLM_OUTPUT_CHARS, "stdout", command_name)
527
+ stderr_info = truncate_and_save(stderr, MAX_LLM_OUTPUT_CHARS, "stderr", command_name)
515
528
 
516
529
  compact = {
517
530
  command: enc.to_utf8(result[:command].to_s),
518
- exit_code: exit_code,
519
- success: result[:success]
531
+ exit_code: result[:exit_code] || 0,
532
+ success: result[:success],
533
+ stdout: stdout_info[:content],
534
+ stderr: stderr_info[:content]
520
535
  }
521
536
 
522
- compact[:elapsed] = result[:elapsed] if result[:elapsed]
523
-
524
- command_name = extract_command_name(compact[:command])
525
-
526
- stdout_info = truncate_and_save(stdout, MAX_LLM_OUTPUT_CHARS, "stdout", command_name)
527
- compact[:stdout] = stdout_info[:content]
528
537
  compact[:stdout_full] = stdout_info[:temp_file] if stdout_info[:temp_file]
529
-
530
- stderr_info = truncate_and_save(stderr, MAX_LLM_OUTPUT_CHARS, "stderr", command_name)
531
- compact[:stderr] = stderr_info[:content]
532
538
  compact[:stderr_full] = stderr_info[:temp_file] if stderr_info[:temp_file]
533
-
534
539
  compact[:output_truncated] = true if result[:output_truncated]
540
+ compact[:elapsed] = result[:elapsed] if result[:elapsed]
541
+
542
+ # Preserve WAITING_INPUT state fields
543
+ if result[:state] == 'WAITING_INPUT'
544
+ compact[:state] = 'WAITING_INPUT'
545
+ compact[:interaction_type] = result[:interaction_type]
546
+ compact[:message] = format_waiting_message(stdout_info[:content], result[:interaction_type] ? { type: result[:interaction_type], line: result.dig(:interaction, :line) || extract_last_line(stdout_info[:content]) } : { type: 'question', line: extract_last_line(stdout_info[:content]) })
547
+ end
548
+
549
+ # Preserve TIMEOUT state fields
550
+ if result[:state] == 'TIMEOUT'
551
+ compact[:state] = 'TIMEOUT'
552
+ compact[:timeout_type] = result[:timeout_type]
553
+ end
535
554
 
536
555
  compact
537
556
  end
@@ -398,6 +398,34 @@ module Clacky
398
398
  # doesn't bleed into the next one, and so the buffer is ready before
399
399
  # on_output starts firing (which can happen before show_progress is called).
400
400
  @stdout_lines = nil
401
+
402
+ # Special handling for request_user_feedback: render as a readable interactive card
403
+ # with the full question and options, rather than the truncated format_call summary.
404
+ if name.to_s == "request_user_feedback"
405
+ args_data = args.is_a?(String) ? (JSON.parse(args, symbolize_names: true) rescue {}) : args
406
+ args_data = args_data.transform_keys(&:to_sym) if args_data.is_a?(Hash)
407
+
408
+ question = args_data[:question].to_s.strip
409
+ context = args_data[:context].to_s.strip
410
+ options = Array(args_data[:options])
411
+
412
+ theme = ThemeManager.current_theme
413
+ parts = []
414
+
415
+ parts << context unless context.empty?
416
+ parts << question unless question.empty?
417
+
418
+ if options.any?
419
+ parts << ""
420
+ options.each_with_index { |opt, i| parts << " #{i + 1}. #{opt}" }
421
+ end
422
+
423
+ card_text = parts.join("\n")
424
+ output = @renderer.render_system_message(card_text, prefix_newline: true)
425
+ append_output(output)
426
+ return
427
+ end
428
+
401
429
  formatted_call = format_tool_call(name, args)
402
430
  output = @renderer.render_tool_call(tool_name: name, formatted_call: formatted_call)
403
431
  append_output(output)
@@ -466,7 +494,24 @@ module Clacky
466
494
  # Show progress indicator with dynamic elapsed time
467
495
  # @param message [String] Progress message (optional, will use random thinking verb if nil)
468
496
  # @param prefix_newline [Boolean] Whether to add a blank line before progress (default: true)
497
+ # @param progress_type [String] "thinking" | "idle_compress" | "retrying" | custom
498
+ # @param phase [String] "active" (start spinner) | "done" (stop spinner, show final message)
499
+ # @param metadata [Hash] Extensible metadata (unused in UI2 for now)
469
500
  def show_progress(message = nil, prefix_newline: true, progress_type: "thinking", phase: "active", metadata: {})
501
+ # phase: "done" → stop any active spinner and render the final message as a system message
502
+ if phase.to_s == "done"
503
+ stop_progress_thread
504
+ @stdout_lines = nil
505
+ @progress_start_time = nil
506
+ if message && !message.to_s.strip.empty?
507
+ output = @renderer.render_system_message(message.to_s, prefix_newline: false)
508
+ update_progress_line(output)
509
+ else
510
+ clear_progress_line
511
+ end
512
+ return
513
+ end
514
+
470
515
  # Stop any existing progress thread
471
516
  stop_progress_thread
472
517
 
@@ -609,14 +654,6 @@ module Clacky
609
654
  append_output(output)
610
655
  end
611
656
 
612
- # Show idle compression status (two-phase: start → end).
613
- # In terminal mode, only the final state is printed.
614
- def show_idle_status(phase:, message:)
615
- return unless phase.to_s == "end"
616
- output = @renderer.render_system_message(message)
617
- append_output(output)
618
- end
619
-
620
657
  # Show warning message
621
658
  # @param message [String] Warning message
622
659
  def show_warning(message)
@@ -113,7 +113,7 @@ module Clacky
113
113
  def self.raise_helpful_error(call, tool_registry, original_error)
114
114
  tool = tool_registry.get(call[:name])
115
115
  error_msg = build_error_message(call, tool, original_error)
116
- raise StandardError, error_msg
116
+ raise BadArgumentsError, error_msg
117
117
  end
118
118
 
119
119
  def self.build_error_message(call, tool, original_error)
@@ -173,8 +173,13 @@ module Clacky
173
173
  end
174
174
  end
175
175
 
176
+ # Raised when tool call arguments are malformed or missing required params.
177
+ # Distinct from StandardError so the agent can handle it specially
178
+ # (e.g. retract the bad assistant message from history to break cache loops).
179
+ class BadArgumentsError < StandardError; end
180
+
176
181
  # Custom exception for missing required parameters
177
- class MissingRequiredParamsError < StandardError
182
+ class MissingRequiredParamsError < BadArgumentsError
178
183
  attr_reader :tool_name, :missing_params, :provided_params
179
184
 
180
185
  def initialize(tool_name, missing_params, provided_params)
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Clacky
4
- VERSION = "0.9.30"
4
+ VERSION = "0.9.31"
5
5
  end