openclacky 1.3.3 → 1.3.5

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 (89) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +53 -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/fake_tool_call_detector.rb +52 -0
  6. data/lib/clacky/agent/llm_caller.rb +10 -4
  7. data/lib/clacky/agent/session_serializer.rb +5 -3
  8. data/lib/clacky/agent/tool_executor.rb +0 -12
  9. data/lib/clacky/agent.rb +77 -11
  10. data/lib/clacky/agent_config.rb +2 -14
  11. data/lib/clacky/api_extension.rb +343 -0
  12. data/lib/clacky/api_extension_loader.rb +168 -0
  13. data/lib/clacky/cli.rb +93 -3
  14. data/lib/clacky/client.rb +47 -25
  15. data/lib/clacky/default_agents/_panels/git/panel.js +1 -1
  16. data/lib/clacky/default_agents/_panels/time_machine/panel.js +23 -1
  17. data/lib/clacky/default_agents/base_prompt.md +1 -0
  18. data/lib/clacky/default_extensions/meeting/handler.rb +331 -0
  19. data/lib/clacky/default_extensions/meeting/meeting.js +790 -0
  20. data/lib/clacky/default_extensions/meeting/meta.yml +3 -0
  21. data/lib/clacky/default_extensions/meeting/skills/meeting-summarizer/SKILL.md +44 -0
  22. data/lib/clacky/default_skills/media-gen/SKILL.md +72 -6
  23. data/lib/clacky/default_skills/media-gen/scripts/video_seq.sh +114 -0
  24. data/lib/clacky/idle_compression_timer.rb +3 -1
  25. data/lib/clacky/json_ui_controller.rb +1 -1
  26. data/lib/clacky/locales/en.rb +26 -0
  27. data/lib/clacky/locales/i18n.rb +26 -0
  28. data/lib/clacky/locales/zh.rb +26 -0
  29. data/lib/clacky/media/base.rb +60 -0
  30. data/lib/clacky/media/dashscope.rb +385 -21
  31. data/lib/clacky/media/gemini.rb +9 -0
  32. data/lib/clacky/media/generator.rb +52 -0
  33. data/lib/clacky/media/openai_compat.rb +166 -0
  34. data/lib/clacky/null_ui_controller.rb +13 -0
  35. data/lib/clacky/plain_ui_controller.rb +1 -1
  36. data/lib/clacky/providers.rb +50 -2
  37. data/lib/clacky/rich_ui/components/base_component.rb +50 -0
  38. data/lib/clacky/rich_ui/components/dialogs/approval_dialog.rb +142 -0
  39. data/lib/clacky/rich_ui/components/dialogs/config_menu_dialog.rb +106 -0
  40. data/lib/clacky/rich_ui/components/dialogs/form_dialog.rb +128 -0
  41. data/lib/clacky/rich_ui/components/sidebar.rb +119 -0
  42. data/lib/clacky/rich_ui/components/sidebar_panels.rb +134 -0
  43. data/lib/clacky/rich_ui/components/status_view.rb +58 -0
  44. data/lib/clacky/rich_ui/components/thinking_live_view.rb +79 -0
  45. data/lib/clacky/rich_ui/entry_tracker.rb +56 -0
  46. data/lib/clacky/rich_ui/layout_adapter.rb +16 -0
  47. data/lib/clacky/rich_ui/progress_handle_adapter.rb +24 -0
  48. data/lib/clacky/rich_ui/rich_ui_controller.rb +868 -0
  49. data/lib/clacky/rich_ui/shell/rich_agent_shell.rb +184 -0
  50. data/lib/clacky/rich_ui/view_renderer.rb +291 -0
  51. data/lib/clacky/rich_ui.rb +57 -0
  52. data/lib/clacky/rich_ui_controller.rb +3 -1549
  53. data/lib/clacky/server/api_extension_dispatcher.rb +120 -0
  54. data/lib/clacky/server/channel/channel_ui_controller.rb +1 -1
  55. data/lib/clacky/server/http_server.rb +291 -109
  56. data/lib/clacky/server/session_registry.rb +5 -3
  57. data/lib/clacky/server/web_ui_controller.rb +3 -2
  58. data/lib/clacky/shell_hook_loader.rb +1 -1
  59. data/lib/clacky/skill_loader.rb +14 -2
  60. data/lib/clacky/tools/edit.rb +14 -2
  61. data/lib/clacky/tools/terminal/output_cleaner.rb +1 -3
  62. data/lib/clacky/tools/terminal.rb +0 -43
  63. data/lib/clacky/ui2/components/modal_component.rb +1 -1
  64. data/lib/clacky/ui2/ui_controller.rb +147 -31
  65. data/lib/clacky/ui_interface.rb +10 -1
  66. data/lib/clacky/utils/encoding.rb +25 -0
  67. data/lib/clacky/version.rb +1 -1
  68. data/lib/clacky/web/app.css +201 -81
  69. data/lib/clacky/web/app.js +65 -7
  70. data/lib/clacky/web/components/onboard.js +19 -16
  71. data/lib/clacky/web/core/aside.js +8 -3
  72. data/lib/clacky/web/core/ext.js +1 -1
  73. data/lib/clacky/web/features/brand/view.js +8 -5
  74. data/lib/clacky/web/features/channels/store.js +1 -20
  75. data/lib/clacky/web/features/mcp/store.js +1 -20
  76. data/lib/clacky/web/features/profile/store.js +1 -13
  77. data/lib/clacky/web/features/profile/view.js +16 -4
  78. data/lib/clacky/web/features/skills/store.js +36 -23
  79. data/lib/clacky/web/features/skills/view.js +32 -1
  80. data/lib/clacky/web/features/version/store.js +2 -0
  81. data/lib/clacky/web/features/workspace/view.js +1 -1
  82. data/lib/clacky/web/i18n.js +48 -13
  83. data/lib/clacky/web/index.html +24 -17
  84. data/lib/clacky/web/sessions.js +427 -79
  85. data/lib/clacky/web/settings.js +143 -113
  86. data/lib/clacky/web/ws-dispatcher.js +18 -6
  87. data/lib/clacky.rb +27 -5
  88. metadata +45 -2
  89. data/lib/clacky/media/output_dir.rb +0 -43
@@ -0,0 +1,168 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "fileutils"
4
+
5
+ module Clacky
6
+ # Discovers and loads user-defined HTTP API extensions from
7
+ # ~/.clacky/api_ext/<name>/handler.rb. Each handler is expected to define a
8
+ # subclass of Clacky::ApiExtension; the subclass is auto-registered with the
9
+ # framework and its routes become available under /api/ext/<name>/.
10
+ #
11
+ # A broken extension (syntax error, missing base class, route conflict) is
12
+ # isolated: skipped with a logged warning, never aborts the load of others.
13
+ module ApiExtensionLoader
14
+ DEFAULT_DIR = File.expand_path("~/.clacky/api_ext")
15
+ BUILTIN_DIR = File.expand_path("../default_extensions", __FILE__)
16
+ DISABLED_DIR = "_disabled"
17
+
18
+ Result = Struct.new(:loaded, :skipped, keyword_init: true)
19
+
20
+ class << self
21
+ def load_all(dir: DEFAULT_DIR, builtin: true)
22
+ result = Result.new(loaded: [], skipped: [])
23
+ Clacky::ApiExtension.reset_registry!
24
+
25
+ # Load built-in (gem-shipped) extensions first (lowest priority)
26
+ if builtin && Dir.exist?(BUILTIN_DIR)
27
+ Dir.glob(File.join(BUILTIN_DIR, "*", "handler.rb")).sort.each do |handler_path|
28
+ ext_dir = File.dirname(handler_path)
29
+ ext_id = File.basename(ext_dir)
30
+ next if ext_id.start_with?("_")
31
+ load_one(ext_id, ext_dir, handler_path, result)
32
+ end
33
+ end
34
+
35
+ # Load user extensions (higher priority — same ext_id overwrites built-in)
36
+ if Dir.exist?(dir)
37
+ Dir.glob(File.join(dir, "*", "handler.rb")).sort.each do |handler_path|
38
+ ext_dir = File.dirname(handler_path)
39
+ ext_id = File.basename(ext_dir)
40
+ next if ext_id == DISABLED_DIR || ext_id.start_with?("_")
41
+ load_one(ext_id, ext_dir, handler_path, result)
42
+ end
43
+ end
44
+
45
+ @last_result = result
46
+ log_summary(result)
47
+ result
48
+ end
49
+
50
+ def last_result
51
+ @last_result || load_all
52
+ end
53
+
54
+ def load_one(ext_id, ext_dir, handler_path, result)
55
+ meta = read_meta(ext_dir)
56
+ before = Clacky::ApiExtension.pending_subclasses.size
57
+
58
+ require handler_path
59
+
60
+ new_subclasses = Clacky::ApiExtension.pending_subclasses[before..] || []
61
+ klass = new_subclasses.last
62
+
63
+ unless klass
64
+ result.skipped << [ext_id, "no Clacky::ApiExtension subclass defined in handler.rb"]
65
+ log_skip(ext_id, result.skipped.last[1])
66
+ return
67
+ end
68
+
69
+ klass.ext_id = ext_id
70
+ klass.ext_dir = ext_dir
71
+ klass.meta = meta
72
+
73
+ if klass.routes.empty?
74
+ result.skipped << [ext_id, "no routes declared (use get/post/... DSL)"]
75
+ log_skip(ext_id, result.skipped.last[1])
76
+ Clacky::ApiExtension.registry.delete(ext_id)
77
+ return
78
+ end
79
+
80
+ if (gap = validate_public_endpoints(klass, meta))
81
+ result.skipped << [ext_id, gap]
82
+ log_skip(ext_id, gap)
83
+ return
84
+ end
85
+
86
+ Clacky::ApiExtension.register(ext_id, klass)
87
+ result.loaded << ext_id
88
+ public_count = klass.public_paths.size
89
+ suffix = public_count > 0 ? " (#{public_count} public)" : ""
90
+ Clacky::Logger.info("[ApiExtensionLoader] Loaded '#{ext_id}' — #{klass.routes.size} route(s)#{suffix}")
91
+ rescue StandardError, ScriptError => e
92
+ result.skipped << [ext_id, e.message]
93
+ log_skip(ext_id, e.message)
94
+ end
95
+
96
+ private def read_meta(ext_dir)
97
+ path = File.join(ext_dir, "meta.yml")
98
+ return {} unless File.exist?(path)
99
+
100
+ YAMLCompat.load_file(path) || {}
101
+ rescue StandardError => e
102
+ Clacky::Logger.warn("[ApiExtensionLoader] Failed to read meta.yml in #{ext_dir}: #{e.message}")
103
+ {}
104
+ end
105
+
106
+ private def validate_public_endpoints(klass, meta)
107
+ return nil if klass.public_paths.empty?
108
+ return nil if meta["public"] == true
109
+
110
+ "uses public_endpoint but meta.yml is missing 'public: true'"
111
+ end
112
+
113
+ private def log_skip(ext_id, reason)
114
+ Clacky::Logger.warn("[ApiExtensionLoader] Skipped '#{ext_id}': #{reason}")
115
+ end
116
+
117
+ private def log_summary(result)
118
+ return if result.loaded.empty? && result.skipped.empty?
119
+
120
+ total_routes = result.loaded.sum { |id| Clacky::ApiExtension.registry[id]&.routes&.size || 0 }
121
+ Clacky::Logger.info("[ApiExtensionLoader] #{result.loaded.size} extension(s), #{total_routes} route(s); #{result.skipped.size} skipped")
122
+ end
123
+
124
+ # Generate a starter handler.rb at ~/.clacky/api_ext/<name>/handler.rb.
125
+ # Returns the path to the generated file.
126
+ def scaffold(name, dir: DEFAULT_DIR)
127
+ slug = name.to_s.strip.downcase.gsub(/[^a-z0-9_-]+/, "-").gsub(/\A-+|-+\z/, "")
128
+ raise ArgumentError, "invalid api_ext name: #{name.inspect}" if slug.empty?
129
+
130
+ target_dir = File.join(dir, slug)
131
+ path = File.join(target_dir, "handler.rb")
132
+ raise ArgumentError, "api_ext already exists: #{path}" if File.exist?(path)
133
+
134
+ FileUtils.mkdir_p(target_dir)
135
+ File.write(path, skeleton(slug))
136
+ path
137
+ end
138
+
139
+ private def skeleton(slug)
140
+ const = slug.split(/[-_]/).map(&:capitalize).join + "Ext"
141
+ <<~RUBY
142
+ # frozen_string_literal: true
143
+
144
+ # Custom HTTP API extension mounted at /api/ext/#{slug}/
145
+ # Scaffolded by `clacky api_ext_new #{slug}` — fill in the routes you need.
146
+ class #{const} < Clacky::ApiExtension
147
+ get "/hello" do
148
+ json(message: "hello from #{slug}")
149
+ end
150
+
151
+ # Examples — uncomment and adapt:
152
+ #
153
+ # post "/items" do
154
+ # body = json_body
155
+ # error!("name required", status: 422) unless body["name"]
156
+ # File.write(data_path("items.json"), body.to_json)
157
+ # json(ok: true)
158
+ # end
159
+ #
160
+ # get "/items/:id" do
161
+ # json(id: params[:id])
162
+ # end
163
+ end
164
+ RUBY
165
+ end
166
+ end
167
+ end
168
+ end
data/lib/clacky/cli.rb CHANGED
@@ -268,8 +268,9 @@ module Clacky
268
268
  config.save
269
269
  end
270
270
 
271
- # Refresh UI bar
271
+ # Refresh UI bar and model list
272
272
  ui_controller.config[:model] = config.model_name
273
+ ui_controller.available_models = config.model_names
273
274
  ui_controller.update_sessionbar(
274
275
  tasks: agent.total_tasks,
275
276
  cost: agent.total_cost
@@ -836,11 +837,12 @@ module Clacky
836
837
  say "Error: Rich UI requires Ruby >= 2.6. Use --ui ui2 on Ruby #{RUBY_VERSION}.", :red
837
838
  exit 1
838
839
  end
839
- require_relative "rich_ui_controller"
840
+ require_relative "rich_ui"
840
841
  RichUIController.new(
841
842
  working_dir: working_dir,
842
843
  mode: agent_config.permission_mode.to_s,
843
844
  model: agent_config.model_name,
845
+ model_names: agent_config.model_names,
844
846
  theme: options[:theme]
845
847
  )
846
848
  else
@@ -901,6 +903,21 @@ module Clacky
901
903
  agent_config.permission_mode = new_mode.to_sym
902
904
  end
903
905
 
906
+ # Set up model switch handler (from /model slash command)
907
+ ui_controller.on_model_switch do |model, persist|
908
+ next unless agent_config.switch_model_by_name(model)
909
+
910
+ id = agent_config.current_model_id
911
+ agent.switch_model_by_id(id)
912
+ if persist
913
+ agent_config.set_default_model_by_id(id)
914
+ agent_config.save
915
+ ui_controller.show_success("Model switched to #{model} (saved)")
916
+ else
917
+ ui_controller.show_success("Model switched to #{model} (session only)")
918
+ end
919
+ end
920
+
904
921
  # Set up time machine handler (ESC key)
905
922
  ui_controller.on_time_machine do
906
923
  handle_time_machine_command(ui_controller, agent, session_manager)
@@ -921,6 +938,16 @@ module Clacky
921
938
  end
922
939
 
923
940
  if (not current_task_thread&.alive?) && input_was_empty
941
+ # Rich UI: require double-tap Ctrl+C to exit. When the user
942
+ # just copied terminal-native text selection, the viewport
943
+ # has no knowledge of the selection, yet Ctrl+C must not exit.
944
+ # First press only sets the warning; second press exits.
945
+ if ui_controller.respond_to?(:ctrl_c_warning) && !ui_controller.ctrl_c_warning
946
+ ui_controller.instance_variable_set(:@ctrl_c_warning, "Press Ctrl+C again to exit")
947
+ ui_controller.set_input_tips("Press Ctrl+C again to exit.", type: :info)
948
+ next
949
+ end
950
+
924
951
  # Save final session state before exit
925
952
  if session_manager && agent.total_tasks > 0
926
953
  session_data = agent.to_session_data(status: :exited)
@@ -1038,7 +1065,13 @@ module Clacky
1038
1065
  # Update session bar with agent's cumulative stats
1039
1066
  ui_controller.update_sessionbar(tasks: agent.total_tasks, cost: agent.total_cost)
1040
1067
  rescue Clacky::AgentInterrupted, StandardError => e
1041
- handle_agent_exception(ui_controller, agent, session_manager, e)
1068
+ begin
1069
+ handle_agent_exception(ui_controller, agent, session_manager, e)
1070
+ rescue StandardError => ex
1071
+ # If handle_agent_exception itself raises (e.g. UI in bad state),
1072
+ # prevent the thread from dying with an unhandled exception.
1073
+ $stderr.puts "[cli] handle_agent_exception failed: #{ex.class}: #{ex.message}"
1074
+ end
1042
1075
  ensure
1043
1076
  current_task_thread = nil
1044
1077
  # Start idle timer after agent completes
@@ -1181,6 +1214,63 @@ module Clacky
1181
1214
  exit 1 if result.skipped.any?
1182
1215
  end
1183
1216
 
1217
+ desc "api_ext_new NAME", "Scaffold a custom HTTP API extension at ~/.clacky/api_ext/NAME/"
1218
+ long_desc <<-LONGDESC
1219
+ Generate a ready-to-edit HTTP API extension skeleton. The skeleton mounts
1220
+ a sample route under /api/ext/NAME/ — fill in your routes, then verify with
1221
+ `clacky api_ext_verify`.
1222
+
1223
+ Examples:
1224
+ $ clacky api_ext_new my-dashboard
1225
+ LONGDESC
1226
+ def api_ext_new(name)
1227
+ path = Clacky::ApiExtensionLoader.scaffold(name)
1228
+ puts "Created api extension: #{path}"
1229
+ puts "Edit the routes, then run: clacky api_ext_verify"
1230
+ rescue ArgumentError => e
1231
+ warn "Error: #{e.message}"
1232
+ exit 1
1233
+ end
1234
+
1235
+ desc "api_ext_verify", "Load user API extensions and report which are valid"
1236
+ def api_ext_verify
1237
+ result = Clacky::ApiExtensionLoader.load_all
1238
+
1239
+ if result.loaded.empty? && result.skipped.empty?
1240
+ puts "No api extensions found in ~/.clacky/api_ext/"
1241
+ return
1242
+ end
1243
+
1244
+ result.loaded.each do |id|
1245
+ klass = Clacky::ApiExtension.registry[id]
1246
+ public_count = klass.public_paths.size
1247
+ suffix = public_count > 0 ? " (#{public_count} public)" : ""
1248
+ puts "[OK] #{id} — #{klass.routes.size} route(s)#{suffix}"
1249
+ end
1250
+ result.skipped.each { |(n, reason)| puts "[SKIP] #{n} — #{reason}" }
1251
+ exit 1 if result.skipped.any?
1252
+ end
1253
+
1254
+ desc "api_ext_list", "List loaded API extensions and their routes"
1255
+ def api_ext_list
1256
+ Clacky::ApiExtensionLoader.load_all if Clacky::ApiExtension.registry.empty?
1257
+
1258
+ if Clacky::ApiExtension.registry.empty?
1259
+ puts "No api extensions loaded."
1260
+ return
1261
+ end
1262
+
1263
+ Clacky::ApiExtension.registry.each do |id, klass|
1264
+ public_tag = klass.public_paths.any? ? " (public)" : ""
1265
+ puts "#{id}#{public_tag}"
1266
+ klass.routes.each do |route|
1267
+ full_path = "/api/ext/#{id}#{route.pattern}".chomp("/")
1268
+ full_path = "/api/ext/#{id}/" if full_path == "/api/ext/#{id}"
1269
+ puts " #{route.method.to_s.upcase.ljust(6)} #{full_path}"
1270
+ end
1271
+ end
1272
+ end
1273
+
1184
1274
  desc "billing", "Show billing summary and usage statistics"
1185
1275
  long_desc <<-LONGDESC
1186
1276
  Display billing summary with token usage and cost breakdown.
data/lib/clacky/client.rb CHANGED
@@ -34,11 +34,6 @@ module Clacky
34
34
  # some OpenRouter-compatible relays only honour Bearer — send both).
35
35
  @provider_id = provider_id
36
36
 
37
- # Determine vision support once at construction time.
38
- # Non-vision models (DeepSeek, Kimi, MiniMax, etc.) reject image_url
39
- # content blocks; the conversion layer strips them when this is false.
40
- @vision_supported = Providers.supports?(provider_id, :vision, model_name: @model)
41
-
42
37
  # Optional override for Faraday read_timeout (e.g. benchmark calls).
43
38
  # nil means use the default (300s for streaming).
44
39
  @read_timeout = read_timeout
@@ -343,9 +338,12 @@ module Clacky
343
338
  # OpenRouter proxies Claude with the same cache_control field convention as Anthropic direct.
344
339
  messages = apply_message_caching(messages) if caching_enabled
345
340
 
341
+ # Vision support is resolved against the request's actual model (which may
342
+ # differ from @model after a runtime switch or fallback override), so the
343
+ # conversion layer strips image_url blocks for non-vision models.
346
344
  body = MessageFormat::OpenAI.build_request_body(
347
345
  messages, model, tools, max_tokens, caching_enabled,
348
- vision_supported: @vision_supported,
346
+ vision_supported: Providers.supports?(@provider_id, :vision, model_name: model),
349
347
  reasoning_effort: reasoning_effort
350
348
  )
351
349
  return send_openai_stream_request(body, on_chunk) if on_chunk
@@ -515,6 +513,12 @@ module Clacky
515
513
  end
516
514
  end
517
515
 
516
+ def reset_connections!
517
+ @bedrock_connection = nil
518
+ @openai_connection = nil
519
+ @anthropic_connection = nil
520
+ end
521
+
518
522
  def bedrock_connection
519
523
  current_epoch = Clacky::ProxyConfig.epoch
520
524
  if @bedrock_connection.nil? ||
@@ -598,11 +602,24 @@ module Clacky
598
602
  return { success: true, status: response.status } if response.status == 200
599
603
 
600
604
  error_body = JSON.parse(response.body) rescue nil
605
+ error_code = extract_error_code(error_body)
606
+
607
+ translated = case response.status
608
+ when 402 then I18n.t("llm.error.insufficient_credit")
609
+ when 400 then I18n.t("llm.error.rate_limit_400")
610
+ when 401 then I18n.t("llm.error.invalid_api_key")
611
+ when 403 then I18n.t("llm.error.403.#{error_code || "default"}")
612
+ when 404 then I18n.t("llm.error.endpoint_not_found")
613
+ when 429 then I18n.t("llm.error.rate_limit_429")
614
+ when 500..599 then I18n.t("llm.error.server_error", status: response.status)
615
+ else extract_error_message(error_body, response.body)
616
+ end
617
+
601
618
  {
602
619
  success: false,
603
620
  status: response.status,
604
- error: extract_error_message(error_body, response.body),
605
- error_code: extract_error_code(error_body)
621
+ error: translated,
622
+ error_code: error_code
606
623
  }
607
624
  end
608
625
 
@@ -620,31 +637,36 @@ module Clacky
620
637
 
621
638
  if error_code == "insufficient_credit" || response.status == 402
622
639
  raise InsufficientCreditError.new(
623
- "[LLM] Insufficient credit: #{error_message}",
640
+ "[LLM] #{I18n.t("llm.error.insufficient_credit")}",
624
641
  error_code: "insufficient_credit",
625
- provider_id: @provider_id
642
+ provider_id: @provider_id,
643
+ raw_message: error_message
626
644
  )
627
645
  end
628
646
 
629
647
  case response.status
630
648
  when 400
631
- # Well-behaved APIs (Anthropic, OpenAI) never put quota/availability issues in 400.
632
- # However, some proxy/relay providers do — so we inspect the message first.
633
- # Also, Bedrock returns ThrottlingException as 400 instead of 429.
634
649
  if error_message.match?(/ThrottlingException|unavailable|quota/i)
635
- hint = error_message.match?(/quota/i) ? " (possibly out of credits)" : ""
636
- raise RetryableError, "[LLM] Rate limit or service issue: #{error_message}#{hint}"
650
+ raise RetryableError, "[LLM] #{I18n.t("llm.error.rate_limit_400")}"
637
651
  end
638
652
 
639
- # True bad request — our message was malformed. Roll back history so the
640
- # broken message is not replayed on the next user turn.
641
- raise BadRequestError, "[LLM] Client request error: #{error_message}"
642
- when 401 then raise AgentError, "[LLM] Invalid API key"
643
- when 403 then raise AgentError, "[LLM] Access denied: #{error_message}"
644
- when 404 then raise AgentError, "[LLM] API endpoint not found: #{error_message}"
645
- when 429 then raise RetryableError, "[LLM] Rate limit exceeded, please wait a moment"
646
- when 500..599 then raise RetryableError, "[LLM] Service temporarily unavailable (#{response.status}), retrying..."
647
- else raise AgentError, "[LLM] Unexpected error (#{response.status}): #{error_message}"
653
+ raise BadRequestError.new(
654
+ "[LLM] Client request error: #{error_message}",
655
+ display_message: "[LLM] #{I18n.t("llm.error.bad_request")}",
656
+ raw_message: error_message
657
+ )
658
+ when 401
659
+ raise AgentError.new("[LLM] #{I18n.t("llm.error.invalid_api_key")}", raw_message: error_message)
660
+ when 403
661
+ i18n_key = "llm.error.403.#{error_code}"
662
+ translated = I18n.t(i18n_key)
663
+ translated = I18n.t("llm.error.403.default") if translated == i18n_key
664
+ raise AgentError.new("[LLM] #{translated}", raw_message: error_message)
665
+ when 404
666
+ raise AgentError.new("[LLM] #{I18n.t("llm.error.endpoint_not_found")}", raw_message: error_message)
667
+ when 429 then raise RetryableError, "[LLM] #{I18n.t("llm.error.rate_limit_429")}"
668
+ when 500..599 then raise RetryableError, "[LLM] #{I18n.t("llm.error.server_error", status: response.status)}"
669
+ else raise AgentError.new("[LLM] #{I18n.t("llm.error.unexpected", status: response.status)}", raw_message: error_message)
648
670
  end
649
671
  end
650
672
 
@@ -652,7 +674,7 @@ module Clacky
652
674
  def check_html_response(response)
653
675
  body = response.body.to_s.lstrip
654
676
  if body.start_with?("<!DOCTYPE", "<!doctype", "<html", "<HTML")
655
- raise RetryableError, "[LLM] Service temporarily unavailable (received HTML error page), retrying..."
677
+ raise RetryableError, "[LLM] #{I18n.t("llm.error.html_response")}"
656
678
  end
657
679
  end
658
680
 
@@ -196,6 +196,6 @@
196
196
  }, {
197
197
  panel: "git",
198
198
  order: 10,
199
- tab: { id: "changes", label: (typeof I18n !== "undefined" ? (I18n.t("changes.tab") !== "changes.tab" ? I18n.t("changes.tab") : "Git 管理") : "Git 管理") },
199
+ tab: { id: "changes", label: () => t("changes.tab") },
200
200
  });
201
201
  })();
@@ -19,6 +19,25 @@
19
19
  (() => {
20
20
  if (!window.Clacky || !Clacky.ext) return;
21
21
 
22
+ // The currently mounted panel's state, refreshed on every mount. A single WS
23
+ // hook (registered once below) reloads it when the active session completes a
24
+ // task, so new snapshots appear without a manual refresh. Kept as a closure
25
+ // singleton because WS.onEvent has no unsubscribe and the panel re-mounts on
26
+ // each session switch.
27
+ let _activeState = null;
28
+ let _wsHooked = false;
29
+
30
+ function _hookWs() {
31
+ if (_wsHooked || typeof WS === "undefined") return;
32
+ _wsHooked = true;
33
+ WS.onEvent((ev) => {
34
+ if (ev && ev.type === "complete" && _activeState &&
35
+ ev.session_id === _activeState.sessionId) {
36
+ loadHistory(_activeState);
37
+ }
38
+ });
39
+ }
40
+
22
41
  const t = (k, fallback) => {
23
42
  const v = (typeof I18n !== "undefined") ? I18n.t(k) : null;
24
43
  return (v && v !== k) ? v : fallback;
@@ -630,11 +649,14 @@
630
649
  d.mask.onclick = onMaskClick;
631
650
  document.addEventListener("keydown", onKey);
632
651
 
652
+ _activeState = state;
653
+ _hookWs();
654
+
633
655
  loadHistory(state);
634
656
  return root;
635
657
  }, {
636
658
  panel: "time_machine",
637
659
  order: 20,
638
- tab: { id: "tm", label: t("tm.tab", "时光机") },
660
+ tab: { id: "tm", label: () => t("tm.tab") },
639
661
  });
640
662
  })();
@@ -9,6 +9,7 @@
9
9
 
10
10
  - **ALWAYS use `glob` tool to find files — NEVER use shell `find` command for file discovery**
11
11
  - **All operations default to the working directory** (shown in session context)
12
+ - **NEVER write tool calls as text** (e.g. `<invoke name=...>`, `<function_calls>`). Use the structured tool_calls field — text won't execute.
12
13
 
13
14
  ## Response Style
14
15