openclacky 1.1.0 → 1.1.1

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: e897a7568d8b457d317c56ff402a76c1907b4a158e0e7382586e2b66115ee0f7
4
- data.tar.gz: 5fbeb452695441035d7f7d4f7ba1f0ef63dbb198daebb17600eb6e9f9fb72c46
3
+ metadata.gz: 22bdc5132636582b787ebc49b43157871d15cfc0519a8ba93bc69fcfa9b58a2b
4
+ data.tar.gz: 97c40c43d3ae3252f81b1d3b92c89812cf28bdb181094d1fca6f5fea8ea9daa9
5
5
  SHA512:
6
- metadata.gz: 64d4764470f2f8bac52e7e8233afbf441336c8a81238def9800cf6735a90798435cc8d43e9056aa4c6e53f51760067e57b5b2670263339590b7f9fd744cc6920
7
- data.tar.gz: c27a2313d3595adcb48a66e396c9eec8947550132a74bb5b8535b11d93e639b240bc1dafeab0a7bf9b4af665c2fa6a22630f8b23ab0061b2de4ebbfcae1f3299
6
+ metadata.gz: 1c195480e06945b0a09e031498e5f1f4df5d5bb661ef123cf139cd570b33522a47475b8d698e3999a44de8979884e7e8f4dcc3adc424896965ad69cbac83902d
7
+ data.tar.gz: a151a7178e6541880ddad41930adf0e652571bb732cd82ba6b6c2de8e9d880e587a1e680d2c76a3dcb8f9609152f092b23a38afb1c443f8082b1f081d34d81da
data/CHANGELOG.md CHANGED
@@ -5,6 +5,22 @@ All notable changes to this project will be documented in this file.
5
5
  The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
6
6
  and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
7
7
 
8
+ ## [1.1.1] - 2026-05-17
9
+
10
+ ### Added
11
+ - **WeChat SendQueue with batching, throttling, and retry.** Messages sent to multiple WeChat official account users are now queued, batched (up to 100 recipients per call), throttled to 1 batch/second, and automatically retried on failure — preventing 45007 rate-limit errors during broadcasts. (#127)
12
+ - **Session ID in TUI session bar.** The terminal UI session bar now displays the session ID alongside the session name, making it easy to identify sessions when cross-referencing with logs or Web UI.
13
+ - **TUI todo clean-up on task completion.** Completed todos are now removed from the terminal display when a task finishes, keeping the TUI uncluttered. (#94)
14
+
15
+ ### Improved
16
+ - **Brand skills persist across same-brand upgrades.** Brand skills are no longer removed and re-downloaded when the brand stays the same after an upgrade — eliminating unnecessary network calls and keeping skill state stable.
17
+ - **Ruby 2.6 install reliability.** The installer now pre-installs rouge 3.30.0 before `gem install` and retries with a pinned version on Ruby 2.6, avoiding dependency resolution failures on older macOS system Ruby.
18
+
19
+ ### Fixed
20
+ - **TUI progress bar flicker.** The progress bar in terminal mode no longer flashes when updating rapidly, providing a smoother visual experience.
21
+ - **Xcode command auto-install loop.** The agent no longer gets stuck in a loop trying to auto-install missing Xcode command-line tools.
22
+ - **Brand license warning after 3-day idle.** Fixed a spurious license warning that appeared on startup after the server had been idle for 3 days.
23
+
8
24
  ## [1.1.0] - 2026-05-15
9
25
 
10
26
  ### Added
@@ -91,6 +91,20 @@ module Clacky
91
91
  # Keeps context tokens bounded regardless of how many skills are installed.
92
92
  MAX_CONTEXT_SKILLS = 30
93
93
 
94
+ # Process-wide deduper for the "skill context limit" warning so that
95
+ # every newly constructed Agent (sub-agents, retries, web turns…) doesn't
96
+ # re-emit the same line.
97
+ @skill_limit_warned_signatures = {}
98
+ @skill_limit_warn_mutex = Mutex.new
99
+
100
+ def self.warn_skill_limit_once(signature, &block)
101
+ @skill_limit_warn_mutex.synchronize do
102
+ return if @skill_limit_warned_signatures[signature]
103
+ @skill_limit_warned_signatures[signature] = true
104
+ end
105
+ block.call
106
+ end
107
+
94
108
  # Generate skill context - loads all auto-invocable skills allowed by the agent profile
95
109
  # @return [String] Skill context to add to system prompt
96
110
  def build_skill_context
@@ -103,17 +117,16 @@ module Clacky
103
117
  auto_invocable = all_skills.select(&:model_invocation_allowed?)
104
118
 
105
119
  # Enforce system prompt injection limit to control token usage.
106
- # Warn only when the set of dropped skills *changes* this message
107
- # is otherwise emitted once per agent turn (build_skill_context is
108
- # called during every system prompt assembly) and floods the log.
120
+ # Warn at most once per process per dropped-set signaturebuild_skill_context
121
+ # runs on every system-prompt assembly and is invoked from many short-lived
122
+ # Agent instances (sub-agents, web turns…), so per-instance dedup wasn't enough.
109
123
  if auto_invocable.size > MAX_CONTEXT_SKILLS
110
124
  kept = auto_invocable.first(MAX_CONTEXT_SKILLS)
111
125
  dropped = auto_invocable.drop(MAX_CONTEXT_SKILLS)
112
126
  dropped_names = dropped.map(&:identifier)
113
127
  signature = dropped_names.sort.join(",")
114
128
 
115
- if @skill_limit_warned_signature != signature
116
- @skill_limit_warned_signature = signature
129
+ SkillManager.warn_skill_limit_once(signature) do
117
130
  Clacky::Logger.warn(
118
131
  "Skill context limit: #{auto_invocable.size} auto-invocable skills found, " \
119
132
  "only injecting first #{MAX_CONTEXT_SKILLS} " \
@@ -44,7 +44,7 @@ module Clacky
44
44
  :license_expires_at, :license_last_heartbeat, :device_id,
45
45
  :logo_url, :support_contact, :license_user_id,
46
46
  :support_qr_url, :theme_color, :homepage_url,
47
- :distribution_last_refreshed_at
47
+ :distribution_last_refreshed_at, :license_last_heartbeat_failure
48
48
 
49
49
  def initialize(attrs = {})
50
50
  @product_name = attrs["product_name"]
@@ -66,6 +66,11 @@ module Clacky
66
66
  # #refresh_distribution!). Persisted to brand.yml so 24h throttling
67
67
  # survives restarts.
68
68
  @distribution_last_refreshed_at = parse_time(attrs["distribution_last_refreshed_at"])
69
+ # Tracks when heartbeats started failing continuously. Set on a failed
70
+ # heartbeat (only if currently nil), cleared on a successful one.
71
+ # grace_period_exceeded? uses this — NOT last_heartbeat — so a user who
72
+ # simply hasn't run the app in days doesn't see a stale "offline" warning.
73
+ @license_last_heartbeat_failure = parse_time(attrs["license_last_heartbeat_failure"])
69
74
 
70
75
  # In-memory decryption key cache: "skill_id:skill_version_id" => { key:, expires_at: }
71
76
  # Never persisted to disk. Survives across multiple skill invocations within one session.
@@ -130,16 +135,19 @@ module Clacky
130
135
  due
131
136
  end
132
137
 
133
- # Returns true when the grace period for missed heartbeats has expired.
138
+ # Returns true when heartbeats have been failing continuously for longer
139
+ # than the grace period. Only considers ACTUAL failure streaks — a user
140
+ # who hasn't launched the app in a week is NOT in violation, since no
141
+ # heartbeat attempt has actually failed.
134
142
  def grace_period_exceeded?
135
- if @license_last_heartbeat.nil?
136
- Clacky::Logger.debug("[Brand] grace_period_exceeded? => false (no heartbeat recorded)")
143
+ if @license_last_heartbeat_failure.nil?
144
+ Clacky::Logger.debug("[Brand] grace_period_exceeded? => false (no active failure streak)")
137
145
  return false
138
146
  end
139
147
 
140
- elapsed = Time.now.utc - @license_last_heartbeat
148
+ elapsed = Time.now.utc - @license_last_heartbeat_failure
141
149
  exceeded = elapsed >= HEARTBEAT_GRACE_PERIOD
142
- Clacky::Logger.debug("[Brand] grace_period_exceeded? elapsed=#{elapsed.to_i}s grace=#{HEARTBEAT_GRACE_PERIOD}s => #{exceeded}")
150
+ Clacky::Logger.debug("[Brand] grace_period_exceeded? failing_since=#{@license_last_heartbeat_failure.iso8601} elapsed=#{elapsed.to_i}s grace=#{HEARTBEAT_GRACE_PERIOD}s => #{exceeded}")
143
151
  exceeded
144
152
  end
145
153
 
@@ -178,6 +186,7 @@ module Clacky
178
186
  @license_user_id = nil
179
187
  @device_id = nil
180
188
  @distribution_last_refreshed_at = nil
189
+ @license_last_heartbeat_failure = nil
181
190
  { success: true }
182
191
  end
183
192
 
@@ -210,9 +219,20 @@ module Clacky
210
219
  data = response[:data]
211
220
  @license_activated_at = Time.now.utc
212
221
  @license_last_heartbeat = Time.now.utc
222
+ @license_last_heartbeat_failure = nil
213
223
  @license_expires_at = parse_time(data["expires_at"])
214
224
  server_device_id = data["device_id"].to_s.strip
215
225
  @device_id = server_device_id unless server_device_id.empty?
226
+
227
+ # Decide whether the new key belongs to the SAME brand as the previously
228
+ # activated one. If yes (e.g. trial → paid), keep the installed brand
229
+ # skills — they are still decryptable and the user shouldn't have to
230
+ # re-download. If no (switching brands), wipe them.
231
+ prev_package_name = @package_name
232
+ prev_product_name = @product_name
233
+ new_dist = data["distribution"].is_a?(Hash) ? data["distribution"] : {}
234
+ same_brand = brand_identity_match?(prev_package_name, prev_product_name, new_dist)
235
+
216
236
  # Clear ALL stale fields first, then apply fresh values from the new key.
217
237
  # Order matters: reset everything before re-assigning so no old value lingers.
218
238
  @product_name = nil
@@ -229,10 +249,10 @@ module Clacky
229
249
  owner_uid = data["owner_user_id"]
230
250
  @license_user_id = owner_uid.to_s.strip if owner_uid && !owner_uid.to_s.strip.empty?
231
251
  apply_distribution(data["distribution"])
232
- # Clear previously installed brand skills before saving the new license.
233
- # Skills from the old brand are encrypted with that brand's keys — they
234
- # cannot be decrypted with the new license and must be re-downloaded.
235
- clear_brand_skills!
252
+ # Skills from a different brand are encrypted with that brand's keys —
253
+ # they cannot be decrypted with the new license and must be re-downloaded.
254
+ # Same-brand re-activation (trial→paid, key rotation) preserves them.
255
+ clear_brand_skills! unless same_brand
236
256
  save
237
257
  { success: true, message: "License activated successfully!", product_name: @product_name,
238
258
  user_id: @license_user_id, data: data }
@@ -258,14 +278,19 @@ module Clacky
258
278
 
259
279
  # Always derive product_name fresh from the key in mock mode,
260
280
  # so switching keys produces a different brand each time.
261
- user_id = parse_user_id_from_key(@license_key)
262
- @product_name = "Brand#{user_id}"
281
+ user_id = parse_user_id_from_key(@license_key)
282
+ new_product_name = "Brand#{user_id}"
283
+ prev_product_name = @product_name
284
+ same_brand = brand_identity_match?(@package_name, prev_product_name,
285
+ { "product_name" => new_product_name })
286
+ @product_name = new_product_name
263
287
 
264
288
  @license_activated_at = Time.now.utc
265
289
  @license_last_heartbeat = Time.now.utc
290
+ @license_last_heartbeat_failure = nil
266
291
  @license_expires_at = Time.now.utc + (365 * 86_400) # 1 year from now
267
- # Clear old brand skills so stale encrypted files from a previous brand don't linger
268
- clear_brand_skills!
292
+ # Same-brand re-activation preserves installed skills; switching brands wipes them.
293
+ clear_brand_skills! unless same_brand
269
294
  save
270
295
 
271
296
  {
@@ -306,13 +331,16 @@ module Clacky
306
331
 
307
332
  if response[:success]
308
333
  @license_last_heartbeat = Time.now.utc
334
+ @license_last_heartbeat_failure = nil
309
335
  @license_expires_at = parse_time(response[:data]["expires_at"]) if response[:data]["expires_at"]
310
336
  apply_distribution(response[:data]["distribution"])
311
337
  save
312
338
  Clacky::Logger.info("[Brand] heartbeat! success — expires_at=#{@license_expires_at&.iso8601} last_heartbeat=#{@license_last_heartbeat.iso8601}")
313
339
  { success: true, message: "Heartbeat OK" }
314
340
  else
315
- Clacky::Logger.warn("[Brand] heartbeat! failed — #{response[:error]}")
341
+ @license_last_heartbeat_failure ||= Time.now.utc
342
+ save
343
+ Clacky::Logger.warn("[Brand] heartbeat! failed — #{response[:error]} (failing_since=#{@license_last_heartbeat_failure.iso8601})")
316
344
  { success: false, message: response[:error] || "Heartbeat failed" }
317
345
  end
318
346
  end
@@ -1134,6 +1162,7 @@ module Clacky
1134
1162
  # Persist user_id so user-licensed features remain available across restarts
1135
1163
  data["license_user_id"] = @license_user_id if @license_user_id && !@license_user_id.strip.empty?
1136
1164
  data["distribution_last_refreshed_at"] = @distribution_last_refreshed_at.iso8601 if @distribution_last_refreshed_at
1165
+ data["license_last_heartbeat_failure"] = @license_last_heartbeat_failure.iso8601 if @license_last_heartbeat_failure
1137
1166
  YAML.dump(data)
1138
1167
  end
1139
1168
 
@@ -1155,6 +1184,30 @@ module Clacky
1155
1184
  self.class.version_older?(installed, latest)
1156
1185
  end
1157
1186
 
1187
+ # Decide whether a re-activation key targets the same brand as the
1188
+ # currently-loaded one, so we know whether installed brand skills can stay.
1189
+ #
1190
+ # Identity preference, in order:
1191
+ # 1. package_name — bundle identifier, the strongest brand signal
1192
+ # 2. product_name — display name fallback when package_name is missing
1193
+ #
1194
+ # If neither is present on either side, treat as different brand (safe default:
1195
+ # wipe skills) since we can't confirm continuity.
1196
+ private def brand_identity_match?(prev_package_name, prev_product_name, new_dist)
1197
+ new_dist = {} unless new_dist.is_a?(Hash)
1198
+ new_pkg = new_dist["package_name"].to_s.strip
1199
+ old_pkg = prev_package_name.to_s.strip
1200
+ if !new_pkg.empty? && !old_pkg.empty?
1201
+ return new_pkg == old_pkg
1202
+ end
1203
+
1204
+ new_prod = new_dist["product_name"].to_s.strip
1205
+ old_prod = prev_product_name.to_s.strip
1206
+ return new_prod == old_prod if !new_prod.empty? && !old_prod.empty?
1207
+
1208
+ false
1209
+ end
1210
+
1158
1211
  # Apply distribution fields from API response.
1159
1212
  # Updates product_name, package_name, logo_url, support_contact, support_qr_url,
1160
1213
  # theme_color, and homepage_url from the distribution hash.
data/lib/clacky/cli.rb CHANGED
@@ -365,19 +365,12 @@ module Clacky
365
365
  return
366
366
  end
367
367
 
368
+ # Heartbeat is fire-and-forget — startup must never block on the
369
+ # license server. The grace_period_exceeded? check below now keys off
370
+ # license_last_heartbeat_failure (set on a failed heartbeat, cleared
371
+ # on success), so a user who simply hasn't run the app for >3 days
372
+ # no longer sees a false "offline" warning on first launch.
368
373
  if brand.heartbeat_due?
369
- # Fire-and-forget heartbeat in a background thread.
370
- #
371
- # Rationale: a slow/unreachable license server would otherwise block
372
- # CLI startup for up to ~92s (2 hosts × 2 attempts × 23s timeout)
373
- # before the user sees the prompt. Heartbeat is "best-effort" by
374
- # design — its only job is to refresh `last_heartbeat` / `expires_at`
375
- # on disk for the next run's grace-period calculation. Missing a
376
- # single heartbeat is harmless; the next launch will try again.
377
- #
378
- # Consequence: if this run was going to trigger the
379
- # grace_period_exceeded warning, the user will see it on the *next*
380
- # launch instead of this one. Acceptable trade-off.
381
374
  Clacky::Logger.info("[Brand] check_brand_license_cli: heartbeat due, dispatching async...")
382
375
  Thread.new do
383
376
  begin
@@ -395,10 +388,6 @@ module Clacky
395
388
  Clacky::Logger.debug("[Brand] check_brand_license_cli: heartbeat not due yet")
396
389
  end
397
390
 
398
- # Surface the grace-period warning based on *already-persisted* state
399
- # (computed from last_heartbeat on disk). This works whether the
400
- # previous run's heartbeat succeeded, failed, or was interrupted —
401
- # grace_period_exceeded? reads last_heartbeat, not this run's result.
402
391
  if brand.grace_period_exceeded?
403
392
  say ""
404
393
  say "WARNING: Could not reach the #{brand.product_name} license server.", :yellow
@@ -750,6 +739,9 @@ module Clacky
750
739
  # Inject UI into agent
751
740
  agent.instance_variable_set(:@ui, ui_controller)
752
741
 
742
+ # Inject current session id into UI session bar (parity with WebUI #sib-id)
743
+ ui_controller.update_sessionbar(session_id: agent.session_id)
744
+
753
745
  # Set skill loader for command suggestions, filtered by agent profile whitelist
754
746
  ui_controller.set_skill_loader(agent.skill_loader, agent.agent_profile)
755
747
 
@@ -859,7 +851,7 @@ module Clacky
859
851
  end
860
852
  ui_controller.show_info("Session cleared. Starting fresh.")
861
853
  # Update session bar with reset values
862
- ui_controller.update_sessionbar(tasks: agent.total_tasks, cost: agent.total_cost)
854
+ ui_controller.update_sessionbar(tasks: agent.total_tasks, cost: agent.total_cost, session_id: agent.session_id)
863
855
  # Clear todo area display
864
856
  ui_controller.update_todos([])
865
857
  next
@@ -8,6 +8,163 @@ module Clacky
8
8
  module Channel
9
9
  module Adapters
10
10
  module Weixin
11
+ # Per-user send queue with buffering, throttling, and retry for Weixin iLink.
12
+ #
13
+ # Design:
14
+ # - Each chat_id has a pending buffer of text fragments.
15
+ # - A background flusher thread periodically checks all buffers.
16
+ # - Flush triggers: char threshold reached, time interval elapsed, or explicit flush.
17
+ # - Actual send calls are spaced by MIN_SEND_INTERVAL to avoid rate-limiting.
18
+ # - ret:-2 (rate-limited) triggers exponential backoff retry.
19
+ class SendQueue
20
+ FLUSH_CHAR_THRESHOLD = 400
21
+ FLUSH_INTERVAL = 0.8
22
+ MIN_SEND_INTERVAL = 1.0
23
+ RETRY_BACKOFFS = [1.0, 2.0, 4.0]
24
+
25
+ Entry = Struct.new(:text, :context_token, :enqueued_at, keyword_init: true)
26
+
27
+ def initialize(api_client, logger: Clacky::Logger)
28
+ @api_client = api_client
29
+ @logger = logger
30
+ @buffers = {}
31
+ @buffer_mutex = Mutex.new
32
+ @last_sent_at = {}
33
+ @last_mutex = Mutex.new
34
+ @running = true
35
+ @flusher = Thread.new { flush_loop }
36
+ end
37
+
38
+ # Enqueue text for a chat_id. Non-blocking.
39
+ def enqueue(chat_id, text, context_token)
40
+ @buffer_mutex.synchronize do
41
+ @buffers[chat_id] ||= []
42
+ @buffers[chat_id] << Entry.new(text: text, context_token: context_token, enqueued_at: Time.now)
43
+ end
44
+ end
45
+
46
+ # Force-flush all pending text for a chat_id. Non-blocking.
47
+ def flush(chat_id)
48
+ entries = @buffer_mutex.synchronize { @buffers.delete(chat_id) || [] }
49
+ send_entries(chat_id, entries) unless entries.empty?
50
+ end
51
+
52
+ # Stop the flusher thread. Waits up to 30s for pending messages to drain.
53
+ def stop
54
+ @running = false
55
+ @flusher.join(30)
56
+ # Force-flush any remaining entries regardless of threshold.
57
+ drain_all_buffers
58
+ end
59
+
60
+ private def flush_loop
61
+ while @running
62
+ sleep 0.2
63
+ begin
64
+ drain_buffers
65
+ rescue => e
66
+ @logger.error("[WeixinSendQueue] drain_buffers error: #{e.message}")
67
+ end
68
+ end
69
+ end
70
+
71
+ private def drain_buffers
72
+ now = Time.now
73
+ ready = {}
74
+
75
+ @buffer_mutex.synchronize do
76
+ @buffers.each do |chat_id, entries|
77
+ next if entries.empty?
78
+ total_chars = entries.sum { |e| e.text.chars.length }
79
+ elapsed = now - entries.first.enqueued_at
80
+ if total_chars >= FLUSH_CHAR_THRESHOLD || elapsed >= FLUSH_INTERVAL
81
+ ready[chat_id] = entries
82
+ end
83
+ end
84
+ ready.each_key { |chat_id| @buffers.delete(chat_id) }
85
+ end
86
+
87
+ ready.each do |chat_id, entries|
88
+ send_entries(chat_id, entries)
89
+ end
90
+ end
91
+
92
+ # Unconditionally drain every buffer. Used on stop to guarantee delivery.
93
+ private def drain_all_buffers
94
+ ready = @buffer_mutex.synchronize do
95
+ snapshot = @buffers.reject { |_, entries| entries.empty? }
96
+ @buffers.clear
97
+ snapshot
98
+ end
99
+
100
+ ready.each do |chat_id, entries|
101
+ begin
102
+ send_entries(chat_id, entries)
103
+ rescue => e
104
+ @logger.error("[WeixinSendQueue] final drain error for #{chat_id}: #{e.message}")
105
+ end
106
+ end
107
+ end
108
+
109
+ private def send_entries(chat_id, entries)
110
+ return if entries.empty?
111
+
112
+ combined = entries.map(&:text).join("\n")
113
+ ctoken = entries.last.context_token
114
+
115
+ # Split into ≤2000 char chunks
116
+ chunks = split_message(combined)
117
+ chunks.each do |chunk|
118
+ throttle
119
+ send_with_retry(chat_id, chunk, ctoken)
120
+ end
121
+ end
122
+
123
+ private def throttle
124
+ @last_mutex.synchronize do
125
+ last = @last_sent_at[:global] || Time.at(0)
126
+ wait = MIN_SEND_INTERVAL - (Time.now - last)
127
+ sleep(wait) if wait > 0
128
+ @last_sent_at[:global] = Time.now
129
+ end
130
+ end
131
+
132
+ private def send_with_retry(chat_id, text, context_token)
133
+ RETRY_BACKOFFS.each_with_index do |delay, idx|
134
+ begin
135
+ @api_client.send_text(to_user_id: chat_id, text: text, context_token: context_token)
136
+ return
137
+ rescue ApiClient::ApiError => e
138
+ if e.code == -2 && idx < RETRY_BACKOFFS.length - 1
139
+ @logger.warn("[WeixinSendQueue] ret=-2 for #{chat_id}, retry in #{delay}s (#{idx + 1}/#{RETRY_BACKOFFS.length})")
140
+ sleep delay
141
+ next
142
+ end
143
+ raise
144
+ end
145
+ end
146
+ rescue => e
147
+ @logger.error("[WeixinSendQueue] send_text failed for #{chat_id}: #{e.message}")
148
+ end
149
+
150
+ # Split text into ≤2000 Unicode character chunks.
151
+ private def split_message(text, limit: 2000)
152
+ return [text] if text.chars.length <= limit
153
+ chunks = []
154
+ while text.chars.length > limit
155
+ window = text.chars.first(limit).join
156
+ cut = window.rindex("\n\n")
157
+ cut = window.rindex("\n") if cut.nil?
158
+ cut = window.rindex(" ") if cut.nil?
159
+ cut = limit if cut.nil? || cut.zero?
160
+ chunks << text.chars.first(cut).join.rstrip
161
+ text = text.chars.drop(cut).join.lstrip
162
+ end
163
+ chunks << text unless text.empty?
164
+ chunks
165
+ end
166
+ end
167
+
11
168
  # Weixin (WeChat iLink) adapter.
12
169
  #
13
170
  # Protocol: HTTP long-poll via ilinkai.weixin.qq.com
@@ -76,6 +233,7 @@ module Clacky
76
233
  @context_tokens = {}
77
234
  @ctx_mutex = Mutex.new
78
235
  @api_client = ApiClient.new(base_url: @base_url, token: @token)
236
+ @send_queue = SendQueue.new(@api_client)
79
237
  # Typing keepalive: user_id → { ticket:, thread:, cached_at: }
80
238
  @typing_tickets = {}
81
239
  @typing_mutex = Mutex.new
@@ -129,10 +287,12 @@ module Clacky
129
287
 
130
288
  def stop
131
289
  @running = false
290
+ @send_queue.stop
132
291
  end
133
292
 
134
293
  # Send a plain text reply to a user.
135
294
  # The context_token from the inbound message is required by the Weixin protocol.
295
+ # Text is enqueued and sent in batches by the background flusher to avoid rate-limiting.
136
296
  def send_text(chat_id, text, reply_to: nil)
137
297
  ctoken = lookup_context_token(chat_id)
138
298
  unless ctoken
@@ -141,14 +301,15 @@ module Clacky
141
301
  end
142
302
 
143
303
  plain = markdown_to_plain(text)
144
- split_message(plain).each do |chunk|
145
- @api_client.send_text(to_user_id: chat_id, text: chunk, context_token: ctoken)
146
- end
304
+ return { message_id: nil } if plain.empty?
147
305
 
306
+ @send_queue.enqueue(chat_id, plain, ctoken)
148
307
  { message_id: nil }
149
- rescue => e
150
- Clacky::Logger.error("[WeixinAdapter] send_text failed for #{chat_id} (context_token=#{lookup_context_token(chat_id).to_s.slice(0, 20)}...): #{e.message}")
151
- { message_id: nil }
308
+ end
309
+
310
+ # Force-flush pending text for a chat_id. Called before sending files or on task completion.
311
+ def flush_pending(chat_id)
312
+ @send_queue.flush(chat_id)
152
313
  end
153
314
 
154
315
  # Send a file to a user.
@@ -161,6 +322,8 @@ module Clacky
161
322
  return { message_id: nil }
162
323
  end
163
324
 
325
+ @send_queue.flush(chat_id)
326
+
164
327
  @api_client.send_file(
165
328
  to_user_id: chat_id,
166
329
  file_path: file_path,
@@ -62,6 +62,7 @@ module Clacky
62
62
  # raw markdown links would just be noise in the chat.
63
63
  text = content.to_s.gsub(/!?\[[^\]]*\]\(file:\/\/[^)]+\)/, "").strip
64
64
  send_text(text) unless text.empty?
65
+ flush_adapter_pending
65
66
  files.each do |f|
66
67
  Clacky::Logger.info("[ChannelUI] sending file path=#{f[:path].inspect} name=#{f[:name].inspect}")
67
68
  send_file(f[:path], f[:name])
@@ -120,6 +121,7 @@ module Clacky
120
121
  end
121
122
  parts << "#{duration.round(1)}s" if duration
122
123
  send_text(parts.join(" · "))
124
+ flush_adapter_pending
123
125
  end
124
126
 
125
127
  def append_output(content)
@@ -218,6 +220,10 @@ module Clacky
218
220
  send_text(@buffer.join("\n"))
219
221
  @buffer.clear
220
222
  end
223
+
224
+ def flush_adapter_pending
225
+ @adapter.flush_pending(@chat_id) if @adapter.respond_to?(:flush_pending)
226
+ end
221
227
  end
222
228
  end
223
229
  end
@@ -3156,6 +3156,7 @@ module Clacky
3156
3156
  # Export a session bundle as a .zip download containing:
3157
3157
  # - session.json (always)
3158
3158
  # - chunk-*.md (0..N archived conversation chunks)
3159
+ # - logs/clacky-YYYY-MM-DD.log (today's logger file, if present)
3159
3160
  # Useful for debugging — user clicks "download" in the WebUI status bar
3160
3161
  # and we can ask them to attach the zip to a bug report.
3161
3162
  def api_export_session(session_id, res)
@@ -3177,6 +3178,12 @@ module Clacky
3177
3178
  zos.put_next_entry(File.basename(chunk_path))
3178
3179
  zos.write(File.binread(chunk_path))
3179
3180
  end
3181
+
3182
+ log_path = Clacky::Logger.current_log_file
3183
+ if log_path && File.exist?(log_path)
3184
+ zos.put_next_entry("logs/#{File.basename(log_path)}")
3185
+ zos.write(File.binread(log_path))
3186
+ end
3180
3187
  end
3181
3188
  buffer.rewind
3182
3189
  data = buffer.read
@@ -494,6 +494,12 @@ module Clacky
494
494
  else
495
495
  cleanup_session(session)
496
496
  end
497
+ if xcode_tools_missing?(cleaned)
498
+ cleaned = "Xcode Command Line Tools are not installed.\n" \
499
+ "Run: bash ~/.clacky/scripts/install_system_deps.sh\n" \
500
+ "Then retry the original command."
501
+ exit_code = 1
502
+ end
497
503
  {
498
504
  output: cleaned,
499
505
  exit_code: exit_code,
@@ -521,6 +527,11 @@ module Clacky
521
527
  end
522
528
  end
523
529
 
530
+ private def xcode_tools_missing?(output)
531
+ return false if output.nil? || output.empty?
532
+ output.include?("xcode-select") && output.include?("No developer tools were found")
533
+ end
534
+
524
535
  private def session_healthy?(session)
525
536
  return false unless session
526
537
  return false if %w[exited killed].include?(session.status.to_s)
@@ -57,6 +57,7 @@ module Clacky
57
57
 
58
58
  # Session bar info
59
59
  @sessionbar_info = {
60
+ session_id: nil, # Full session id; rendered as first 8 chars (parity with WebUI)
60
61
  working_dir: nil,
61
62
  mode: nil,
62
63
  model: nil,
@@ -138,6 +139,7 @@ module Clacky
138
139
  end
139
140
 
140
141
  # Update session bar info
142
+ # @param session_id [String] Full session id (rendered as first 8 chars)
141
143
  # @param working_dir [String] Working directory
142
144
  # @param mode [String] Permission mode
143
145
  # @param model [String] AI model name
@@ -145,7 +147,8 @@ module Clacky
145
147
  # @param cost [Float] Total cost
146
148
  # @param cost_source [Symbol, nil] :api / :price / :default — :default renders as N/A
147
149
  # @param status [String] Workspace status ('idle' or 'working')
148
- def update_sessionbar(working_dir: nil, mode: nil, model: nil, tasks: nil, cost: nil, cost_source: nil, status: nil)
150
+ def update_sessionbar(session_id: nil, working_dir: nil, mode: nil, model: nil, tasks: nil, cost: nil, cost_source: nil, status: nil)
151
+ @sessionbar_info[:session_id] = session_id if session_id
149
152
  @sessionbar_info[:working_dir] = working_dir if working_dir
150
153
  @sessionbar_info[:mode] = mode if mode
151
154
  @sessionbar_info[:model] = model if model
@@ -1136,6 +1139,12 @@ module Clacky
1136
1139
  parts << "#{status_indicator} #{@pastel.public_send(status_color, @sessionbar_info[:status])}"
1137
1140
  end
1138
1141
 
1142
+ # Session id — first 8 chars (parity with WebUI #sib-id)
1143
+ if @sessionbar_info[:session_id]
1144
+ sid_short = @sessionbar_info[:session_id].to_s[0, 8]
1145
+ parts << theme.format_text(sid_short, :statusbar_secondary) unless sid_short.empty?
1146
+ end
1147
+
1139
1148
  # Working directory (shortened if too long)
1140
1149
  if @sessionbar_info[:working_dir]
1141
1150
  dir_display = shorten_path(@sessionbar_info[:working_dir])
@@ -14,9 +14,13 @@ module Clacky
14
14
 
15
15
  def initialize
16
16
  @todos = []
17
+ @pending_todos = []
18
+ @completed_count = 0
19
+ @total_count = 0
17
20
  @pastel = Pastel.new
18
21
  @width = TTY::Screen.width
19
22
  @height = 0 # Dynamic height based on todos
23
+ @hidden = false
20
24
  end
21
25
 
22
26
  # Update todos list
@@ -27,8 +31,24 @@ module Clacky
27
31
  @completed_count = @todos.count { |t| t[:status] == "completed" }
28
32
  @total_count = @todos.size
29
33
 
30
- # Calculate height: 0 if no pending, otherwise 1 line per task (up to MAX_DISPLAY_TASKS)
31
- if @pending_todos.empty?
34
+ recalc_height
35
+ end
36
+
37
+ # Hide the area without discarding todos data; show again to restore.
38
+ def hide
39
+ return if @hidden
40
+ @hidden = true
41
+ @height = 0
42
+ end
43
+
44
+ def show
45
+ return unless @hidden
46
+ @hidden = false
47
+ recalc_height
48
+ end
49
+
50
+ private def recalc_height
51
+ if @hidden || @pending_todos.empty?
32
52
  @height = 0
33
53
  else
34
54
  @height = [@pending_todos.size, MAX_DISPLAY_TASKS].min
@@ -126,6 +126,10 @@ module Clacky
126
126
 
127
127
  old_lines = entry.lines.dup
128
128
  new_lines = wrap_content_to_lines(content)
129
+ if old_lines == new_lines
130
+ screen.flush
131
+ return
132
+ end
129
133
  @buffer.replace(id, new_lines)
130
134
 
131
135
  unless @fullscreen_mode
@@ -288,24 +292,28 @@ module Clacky
288
292
  end
289
293
  end
290
294
 
291
- # Clear the rows the entry currently occupies
292
- (start_row...@output_row).each do |row|
293
- screen.move_cursor(row, 0)
294
- screen.clear_line
295
- end
296
-
297
- # Paint the new content
295
+ # Clear only rows whose content actually changed, then repaint
296
+ # those. Lines that are byte-identical to the previous frame stay
297
+ # untouched — avoiding the clear-then-redraw flicker that an
298
+ # always-on ticker produces 2-10x per second on slower terminals.
298
299
  cur = start_row
299
- new_lines.each do |line|
300
- screen.move_cursor(cur, 0)
301
- print line
300
+ new_lines.each_with_index do |line, i|
301
+ if i >= old_n || old_lines[i] != line
302
+ screen.move_cursor(cur, 0)
303
+ screen.clear_line
304
+ print line
305
+ end
302
306
  cur += 1
303
307
  end
308
+ # If content shrank, blank out the rows the old frame occupied
309
+ # below the new tail.
310
+ if new_n < old_n
311
+ (cur...(start_row + old_n)).each do |row|
312
+ screen.move_cursor(row, 0)
313
+ screen.clear_line
314
+ end
315
+ end
304
316
  @output_row = start_row + new_n
305
-
306
- # If content shrank, extra rows below may still hold the old content
307
- # if they were outside the cleared range — but since we cleared the
308
- # full old span above, nothing extra is needed here.
309
317
  end
310
318
 
311
319
  # Clear the last N rows of the output area (used by remove_entry on tail).
@@ -502,6 +510,54 @@ module Clacky
502
510
  end
503
511
  end
504
512
 
513
+ # Hide todo area while preserving its data; pair with show_todos.
514
+ def hide_todos
515
+ return unless @todo_area
516
+
517
+ @render_mutex.synchronize do
518
+ old_height = @todo_area.height
519
+ old_gap_row = @gap_row
520
+
521
+ @todo_area.hide
522
+ new_height = @todo_area.height
523
+
524
+ if old_height != new_height
525
+ calculate_layout
526
+ ([old_gap_row, 0].max...screen.height).each do |row|
527
+ screen.move_cursor(row, 0)
528
+ screen.clear_line
529
+ end
530
+ end
531
+
532
+ render_fixed_areas
533
+ screen.flush
534
+ end
535
+ end
536
+
537
+ # Show todo area again after a previous hide_todos.
538
+ def show_todos
539
+ return unless @todo_area
540
+
541
+ @render_mutex.synchronize do
542
+ old_height = @todo_area.height
543
+ old_gap_row = @gap_row
544
+
545
+ @todo_area.show
546
+ new_height = @todo_area.height
547
+
548
+ if old_height != new_height
549
+ calculate_layout
550
+ ([old_gap_row, 0].max...screen.height).each do |row|
551
+ screen.move_cursor(row, 0)
552
+ screen.clear_line
553
+ end
554
+ end
555
+
556
+ render_fixed_areas
557
+ screen.flush
558
+ end
559
+ end
560
+
505
561
 
506
562
 
507
563
  # -----------------------------------------------------------------------
@@ -225,6 +225,15 @@ module Clacky
225
225
  render_now
226
226
  end
227
227
 
228
+ # Like __reattach_entry! but skips the render_now hop. Used by the
229
+ # owner when it has just painted a frame into the new entry itself
230
+ # (e.g. while rotating the handle to remain at the buffer tail) and
231
+ # is still inside its own synchronization — calling render_now there
232
+ # would re-enter the owner's mutex.
233
+ def __rebind_entry!(new_entry_id)
234
+ @monitor.synchronize { @entry_id = new_entry_id }
235
+ end
236
+
228
237
  # Test hook: force a synchronous render regardless of tick cadence.
229
238
  def __force_render!
230
239
  render_now
@@ -49,6 +49,7 @@ module Clacky
49
49
  @time_machine_callback = nil
50
50
  @tasks_count = 0
51
51
  @total_cost = 0.0
52
+ @session_id = nil
52
53
  @last_diff_lines = nil
53
54
 
54
55
  # ── Progress subsystem (v2: owned handles, stacked) ──────────────
@@ -73,6 +74,7 @@ module Clacky
73
74
 
74
75
  # Set session bar data before initializing screen
75
76
  @input_area.update_sessionbar(
77
+ session_id: @session_id,
76
78
  working_dir: @config[:working_dir],
77
79
  mode: @config[:mode],
78
80
  model: @config[:model],
@@ -109,10 +111,13 @@ module Clacky
109
111
  # @param cost_source [Symbol, nil] :api / :price / :default (optional)
110
112
  # @param status [String] Workspace status ('idle' or 'working') (optional)
111
113
  # @param latency [Hash, nil] Latency metrics; accepted but not displayed in the TUI.
112
- def update_sessionbar(tasks: nil, cost: nil, cost_source: nil, status: nil, latency: nil)
114
+ # @param session_id [String, nil] Full session id; rendered as first 8 chars (parity with WebUI).
115
+ def update_sessionbar(tasks: nil, cost: nil, cost_source: nil, status: nil, latency: nil, session_id: nil)
113
116
  @tasks_count = tasks if tasks
114
117
  @total_cost = cost if cost
118
+ @session_id = session_id if session_id
115
119
  @input_area.update_sessionbar(
120
+ session_id: @session_id,
116
121
  working_dir: @config[:working_dir],
117
122
  mode: @config[:mode],
118
123
  model: @config[:model],
@@ -193,9 +198,35 @@ module Clacky
193
198
  @input_area.set_agent(agent, agent_profile)
194
199
  end
195
200
 
196
- # Append output to the output area
197
- # @param content [String] Content to append
201
+ # Append output to the output area.
202
+ #
203
+ # If a progress indicator is currently active (somewhere in the
204
+ # buffer), rotate it to the tail after the append: business content
205
+ # ends up above, the spinner stays at the bottom. Without this,
206
+ # every subsequent ticker tick on a non-tail progress entry would
207
+ # trigger a full output repaint (visible flicker) and the visual
208
+ # order would have business messages appearing below the spinner.
198
209
  def append_output(content)
210
+ @progress_mutex.synchronize do
211
+ top = @progress_stack.last
212
+ if top && top.entry_id
213
+ @layout.remove_entry(top.entry_id)
214
+ top.__detach_entry!
215
+ new_id = @layout.append_output(content)
216
+ progress_id = @layout.append_output(render_for(top))
217
+ top.__rebind_entry!(progress_id)
218
+ new_id
219
+ else
220
+ @layout.append_output(content)
221
+ end
222
+ end
223
+ end
224
+
225
+ # Internal append that bypasses the progress-rotation logic and the
226
+ # @progress_mutex. Used by register_progress / unregister_progress,
227
+ # which already hold the mutex and are themselves placing a fresh
228
+ # progress entry at the tail.
229
+ private def append_output_unlocked(content)
199
230
  @layout.append_output(content)
200
231
  end
201
232
 
@@ -481,6 +512,8 @@ module Clacky
481
512
 
482
513
  # Clear user tip when agent stops working
483
514
  @input_area.clear_user_tip
515
+ # Hide todo area while idle (data preserved, restored on next work)
516
+ @layout.hide_todos
484
517
  @layout.render_input
485
518
 
486
519
  # Don't show completion message if awaiting user feedback
@@ -597,7 +630,7 @@ module Clacky
597
630
  end
598
631
 
599
632
  @progress_stack.push(handle)
600
- entry_id = append_output(render_for(handle))
633
+ entry_id = append_output_unlocked(render_for(handle))
601
634
  recompute_sessionbar_status
602
635
  entry_id
603
636
  end
@@ -623,7 +656,7 @@ module Clacky
623
656
  # Restore the new top, if any: allocate a fresh entry and let it
624
657
  # resume rendering from where it left off.
625
658
  if (restored = @progress_stack.last)
626
- new_id = append_output(render_for(restored))
659
+ new_id = append_output_unlocked(render_for(restored))
627
660
  restored.__reattach_entry!(new_id)
628
661
  end
629
662
 
@@ -826,6 +859,8 @@ module Clacky
826
859
  @last_sessionbar_status = 'idle'
827
860
  # Clear user tip when agent stops working
828
861
  @input_area.clear_user_tip
862
+ # Hide todo area while idle (data preserved, restored on next work)
863
+ @layout.hide_todos
829
864
  @layout.render_input
830
865
  end
831
866
 
@@ -848,6 +883,8 @@ module Clacky
848
883
  # Set workspace status to working (called when agent starts working)
849
884
  def set_working_status
850
885
  update_sessionbar(status: 'working')
886
+ # Restore todo area if it was hidden during idle
887
+ @layout.show_todos
851
888
  # Show a random user tip with 40% probability when agent starts working
852
889
  @input_area.show_user_tip(probability: 0.4)
853
890
  @layout.render_input
@@ -1281,6 +1318,7 @@ module Clacky
1281
1318
 
1282
1319
  # Update session bar data (will be rendered by request_confirmation's render_all)
1283
1320
  @input_area.update_sessionbar(
1321
+ session_id: @session_id,
1284
1322
  working_dir: @config[:working_dir],
1285
1323
  mode: @config[:mode],
1286
1324
  model: @config[:model],
@@ -35,6 +35,13 @@ module Clacky
35
35
  @console ||= false
36
36
  end
37
37
 
38
+ # Path of the log file currently being written to (today's file).
39
+ # File may not exist yet if no log has been emitted today — callers
40
+ # should check File.exist? before reading.
41
+ def current_log_file
42
+ log_file_path(Time.now)
43
+ end
44
+
38
45
  # Log at DEBUG level.
39
46
  def debug(message, **context)
40
47
  write_log(:debug, message, context)
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Clacky
4
- VERSION = "1.1.0"
4
+ VERSION = "1.1.1"
5
5
  end
@@ -66,11 +66,15 @@ const I18n = (() => {
66
66
  "sessions.deleteTitle": "Delete session",
67
67
  "sessions.createError": "Error: ",
68
68
  "sessions.dirNotEmpty": "Directory already exists and is not empty.",
69
- "sessions.export.tooltip": "Download session files (session.json + chunks) for debugging",
69
+ "sessions.export.tooltip": "Download session files (session.json + chunks + today's log) for debugging",
70
70
  "sessions.export.failed": "Failed to download session",
71
71
  "sessions.actions.tooltip": "Click for session actions",
72
72
  "sessions.actions.download": "Download session files",
73
73
  "sessions.actions.downloadHint": "for debugging",
74
+ "sib.dir.tooltip": "Click to change directory",
75
+ "sib.dir.changePrompt": "Change working directory:",
76
+ "sib.model.tooltip": "Click to switch model",
77
+ "sib.signal.tooltip": "Recent LLM latency",
74
78
  "sessions.thinking": "Thinking…",
75
79
  "sessions.default_name": "Session {{n}}",
76
80
  "sessions.badge.cron": "Auto",
@@ -449,8 +453,8 @@ const I18n = (() => {
449
453
  "settings.brand.label.qrHint": "Scan with your phone camera",
450
454
  "settings.brand.btn.change": "Change Serial Number",
451
455
  "settings.brand.btn.unbind": "Unbind License",
452
- "settings.brand.confirmRebind": "Warning: all previously installed brand skills will be deleted and cannot be used. Continue?",
453
- "settings.brand.confirmUnbind": "Are you sure you want to unbind this license? All brand skills will be deleted and this device will no longer have access to branded features.",
456
+ "settings.brand.confirmRebind": "Enter a new serial number to rebind. If it belongs to the same brand, your installed brand skills are preserved; switching to a different brand will remove them. Continue?",
457
+ "settings.brand.confirmUnbind": "Unbind this license? All installed brand skills will be removed, and this device will no longer have access to branded features.",
454
458
  "settings.brand.unbindSuccess": "License unbound successfully.",
455
459
  "settings.brand.unbindFailed": "Failed to unbind license. Please try again.",
456
460
  "settings.brand.badge.active": "Active",
@@ -585,11 +589,15 @@ const I18n = (() => {
585
589
  "sessions.deleteTitle": "删除对话",
586
590
  "sessions.createError": "错误:",
587
591
  "sessions.dirNotEmpty": "该目录已存在且不为空,请换一个目录名。",
588
- "sessions.export.tooltip": "下载会话文件(session.json + 归档片段),用于调试",
592
+ "sessions.export.tooltip": "下载会话文件(session.json + 归档片段 + 当天日志),用于调试",
589
593
  "sessions.export.failed": "下载会话文件失败",
590
594
  "sessions.actions.tooltip": "点击查看会话操作",
591
595
  "sessions.actions.download": "下载会话文件",
592
596
  "sessions.actions.downloadHint": "用于调试",
597
+ "sib.dir.tooltip": "点击切换工作目录",
598
+ "sib.dir.changePrompt": "切换工作目录:",
599
+ "sib.model.tooltip": "点击切换模型",
600
+ "sib.signal.tooltip": "最近一次 LLM 响应延迟",
593
601
  "sessions.thinking": "思考中…",
594
602
  "sessions.default_name": "对话 {{n}}",
595
603
  "sessions.badge.cron": "定时",
@@ -612,6 +620,7 @@ const I18n = (() => {
612
620
  "sessions.search.typeCoding": "Coding",
613
621
  "sessions.search.typeSetup": "配置",
614
622
  "sessions.search.datePlaceholder": "日期",
623
+ "modal.yes": "确定",
615
624
  "modal.no": "取消",
616
625
  "modal.ok": "确定",
617
626
  "modal.cancel": "取消",
@@ -964,8 +973,8 @@ const I18n = (() => {
964
973
  "settings.brand.label.qrHint": "使用手机扫描二维码",
965
974
  "settings.brand.btn.change": "更换序列号",
966
975
  "settings.brand.btn.unbind": "解绑授权",
967
- "settings.brand.confirmRebind": "警告:所有已安装的历史品牌技能将被删除,无法继续使用。确认继续?",
968
- "settings.brand.confirmUnbind": "确定要解绑此授权吗?所有品牌技能将被删除,本设备将无法再访问品牌功能。",
976
+ "settings.brand.confirmRebind": "请输入新序列号以重新绑定。若属于同一品牌,已安装的品牌技能将保留;切换为其他品牌时才会清理。是否继续?",
977
+ "settings.brand.confirmUnbind": "确定要解绑此授权吗?所有已安装的品牌技能将被删除,本设备将无法再访问品牌功能。",
969
978
  "settings.brand.unbindSuccess": "授权解绑成功。",
970
979
  "settings.brand.unbindFailed": "解绑授权失败,请重试。",
971
980
  "settings.brand.badge.active": "已激活",
@@ -300,10 +300,10 @@
300
300
  <div id="sib-actions-dropdown" class="sib-actions-dropdown" style="display:none" role="menu"></div>
301
301
  </span>
302
302
  <span class="sib-sep sib-sep-after-id">│</span>
303
- <span id="sib-dir" title="Click to change directory"></span>
303
+ <span id="sib-dir" data-i18n-title="sib.dir.tooltip" title="Click to change directory"></span>
304
304
  <span class="sib-sep sib-sep-after-dir">│</span>
305
305
  <span id="sib-model-wrap">
306
- <span id="sib-model" class="sib-model-clickable" title="Click to switch model"></span>
306
+ <span id="sib-model" class="sib-model-clickable" data-i18n-title="sib.model.tooltip" title="Click to switch model"></span>
307
307
  <div id="sib-model-dropdown" class="sib-model-dropdown" style="display:none"></div>
308
308
  </span>
309
309
  <span class="sib-sep sib-sep-after-model">│</span>
@@ -311,7 +311,7 @@
311
311
  call completes (see updateInfoBar / Sessions.renderSignalBars). Click
312
312
  opens a mini benchmark panel (see Step 3/4 — not yet implemented). -->
313
313
  <span id="sib-signal-wrap" style="display:none">
314
- <span id="sib-signal" class="sib-signal-clickable" title="Recent LLM latency">
314
+ <span id="sib-signal" class="sib-signal-clickable" data-i18n-title="sib.signal.tooltip" title="Recent LLM latency">
315
315
  <span class="sig-bars" aria-hidden="true"><i></i><i></i><i></i><i></i></span>
316
316
  <span class="sig-text"></span>
317
317
  </span>
@@ -945,8 +945,8 @@
945
945
  </div>
946
946
  </div>
947
947
  <div class="modal-footer">
948
- <button id="new-session-create" class="btn-primary" data-i18n="sessions.modal.create">Create Session</button>
949
948
  <button id="new-session-cancel" class="btn-secondary" data-i18n="modal.cancel">Cancel</button>
949
+ <button id="new-session-create" class="btn-primary" data-i18n="sessions.modal.create">Create Session</button>
950
950
  </div>
951
951
  </div>
952
952
  </div>
@@ -968,8 +968,8 @@
968
968
  <div id="prompt-modal-message" style="font-size:14px;line-height:1.6;margin-bottom:12px"></div>
969
969
  <input type="text" id="prompt-modal-input" class="prompt-modal-input" autocomplete="off" spellcheck="false">
970
970
  <div class="modal-actions">
971
- <button id="prompt-modal-ok" class="btn-primary" data-i18n="modal.ok">OK</button>
972
971
  <button id="prompt-modal-cancel" class="btn-secondary" data-i18n="modal.cancel">Cancel</button>
972
+ <button id="prompt-modal-ok" class="btn-primary" data-i18n="modal.ok">OK</button>
973
973
  </div>
974
974
  </div>
975
975
  </div>
@@ -2065,6 +2065,7 @@ const Sessions = (() => {
2065
2065
 
2066
2066
  /** Update the session info bar below the chat header with current session metadata. */
2067
2067
  updateInfoBar(s) {
2068
+ this._lastSession = s;
2068
2069
  if (!s) {
2069
2070
  // Hide all spans when no session
2070
2071
  ["sib-id", "sib-status", "sib-dir", "sib-mode", "sib-model", "sib-tasks", "sib-cost"].forEach(id => {
@@ -2103,8 +2104,8 @@ const Sessions = (() => {
2103
2104
  const sibDir = $("sib-dir");
2104
2105
  if (sibDir && s.working_dir) {
2105
2106
  sibDir.textContent = s.working_dir;
2106
- sibDir.title = s.working_dir + " (click to change)";
2107
- // Store session ID for later use
2107
+ sibDir.title = `${s.working_dir} (${I18n.t("sib.dir.tooltip")})`;
2108
+ sibDir.dataset.workingDir = s.working_dir;
2108
2109
  sibDir.dataset.sessionId = s.id;
2109
2110
  }
2110
2111
 
@@ -2138,7 +2139,7 @@ const Sessions = (() => {
2138
2139
 
2139
2140
  // Tasks
2140
2141
  const sibTasks = $("sib-tasks");
2141
- if (sibTasks) sibTasks.textContent = `${s.total_tasks || 0} tasks`;
2142
+ if (sibTasks) sibTasks.textContent = I18n.t("sessions.metaTasks", { n: s.total_tasks || 0 });
2142
2143
 
2143
2144
  // Cost — show N/A when pricing is unknown (estimated)
2144
2145
  const sibCost = $("sib-cost");
@@ -3208,9 +3209,9 @@ const Sessions = (() => {
3208
3209
  if (dirEl) {
3209
3210
  e.stopPropagation();
3210
3211
  const sessionId = dirEl.dataset.sessionId;
3211
- const currentDir = dirEl.title.replace(" (click to change)", "");
3212
-
3213
- const newDir = await Modal.prompt("Change working directory:", currentDir);
3212
+ const currentDir = dirEl.dataset.workingDir || dirEl.textContent;
3213
+
3214
+ const newDir = await Modal.prompt(I18n.t("sib.dir.changePrompt"), currentDir);
3214
3215
  if (newDir && newDir !== currentDir) {
3215
3216
  _changeWorkingDirectory(sessionId, newDir);
3216
3217
  }
@@ -3280,7 +3281,10 @@ const Sessions = (() => {
3280
3281
  }
3281
3282
 
3282
3283
  function _populateSessionActionsDropdown(dd, sessionId) {
3283
- const t = (key, fallback) => (window.I18n && I18n.t(key)) || fallback;
3284
+ const t = (key, fallback) => {
3285
+ const s = I18n.t(key);
3286
+ return (s && s !== key) ? s : fallback;
3287
+ };
3284
3288
  dd.innerHTML = "";
3285
3289
 
3286
3290
  // Download item
@@ -3320,7 +3324,7 @@ const Sessions = (() => {
3320
3324
  if (!res.ok) {
3321
3325
  let msg = `HTTP ${res.status}`;
3322
3326
  try { const data = await res.json(); if (data.error) msg = data.error; } catch (_) {}
3323
- alert((window.I18n && I18n.t("sessions.export.failed")) || "Failed to download session: " + msg);
3327
+ alert(I18n.t("sessions.export.failed") + ": " + msg);
3324
3328
  return;
3325
3329
  }
3326
3330
  const blob = await res.blob();
@@ -3342,7 +3346,7 @@ const Sessions = (() => {
3342
3346
  setTimeout(() => URL.revokeObjectURL(url), 1000);
3343
3347
  } catch (err) {
3344
3348
  console.error("Session export failed:", err);
3345
- alert(((window.I18n && I18n.t("sessions.export.failed")) || "Failed to download session") + ": " + err.message);
3349
+ alert(I18n.t("sessions.export.failed") + ": " + err.message);
3346
3350
  } finally {
3347
3351
  if (btnEl) {
3348
3352
  try { btnEl.disabled = wasDisabled; } catch (_) {}
@@ -3370,7 +3374,8 @@ const Sessions = (() => {
3370
3374
  const sibDir = $("sib-dir");
3371
3375
  if (sibDir) {
3372
3376
  sibDir.textContent = newDir;
3373
- sibDir.title = newDir + " (click to change)";
3377
+ sibDir.title = `${newDir} (${I18n.t("sib.dir.tooltip")})`;
3378
+ sibDir.dataset.workingDir = newDir;
3374
3379
  }
3375
3380
 
3376
3381
  console.log(`Changed session ${sessionId} directory to ${newDir}`);
@@ -3381,3 +3386,7 @@ const Sessions = (() => {
3381
3386
  }
3382
3387
 
3383
3388
  })();
3389
+
3390
+ document.addEventListener("langchange", () => {
3391
+ if (Sessions._lastSession) Sessions.updateInfoBar(Sessions._lastSession);
3392
+ });
@@ -49,6 +49,7 @@ install_via_gem() {
49
49
  configure_gem_source
50
50
  setup_gem_home
51
51
 
52
+ local target source_args=()
52
53
  if [ "$USE_CN_MIRRORS" = true ]; then
53
54
  print_info "Fetching latest version from OSS..."
54
55
  local cn_version; cn_version=$(curl -fsSL "$CN_GEM_LATEST_URL" | tr -d '[:space:]')
@@ -57,17 +58,26 @@ install_via_gem() {
57
58
  local gem_file="/tmp/openclacky-${cn_version}.gem"
58
59
  print_info "Downloading openclacky-${cn_version}.gem..."
59
60
  curl -fsSL "$gem_url" -o "$gem_file"
60
- gem install "$gem_file" --no-document --source "$CN_RUBYGEMS_URL"
61
+ target="$gem_file"
62
+ source_args=(--source "$CN_RUBYGEMS_URL")
61
63
  else
62
- gem install openclacky --no-document
64
+ target="openclacky"
63
65
  fi
64
66
 
65
- if [ $? -eq 0 ]; then
67
+ # macOS system Ruby 2.6 has a buggy gem resolver that fails on rouge 4.x.
68
+ # Pre-install a 2.6-compatible rouge to avoid resolver failure.
69
+ local ruby_ver; ruby_ver=$(ruby -e 'puts RUBY_VERSION' 2>/dev/null)
70
+ if [[ "$ruby_ver" == 2.6.* ]]; then
71
+ print_warning "Ruby 2.6 detected — pinning rouge 3.30.0 first"
72
+ gem install rouge -v 3.30.0 --no-document "${source_args[@]}" || { print_error "gem install rouge failed"; return 1; }
73
+ fi
74
+
75
+ if gem install "$target" --no-document "${source_args[@]}"; then
66
76
  print_success "${DISPLAY_NAME} installed successfully!"
67
77
  return 0
68
- else
69
- print_error "gem install failed"; return 1
70
78
  fi
79
+
80
+ print_error "gem install failed"; return 1
71
81
  }
72
82
 
73
83
  # --------------------------------------------------------------------------
data/scripts/install.sh CHANGED
@@ -421,6 +421,7 @@ install_via_gem() {
421
421
  configure_gem_source
422
422
  setup_gem_home
423
423
 
424
+ local target source_args=()
424
425
  if [ "$USE_CN_MIRRORS" = true ]; then
425
426
  print_info "Fetching latest version from OSS..."
426
427
  local cn_version; cn_version=$(curl -fsSL "$CN_GEM_LATEST_URL" | tr -d '[:space:]')
@@ -429,17 +430,26 @@ install_via_gem() {
429
430
  local gem_file="/tmp/openclacky-${cn_version}.gem"
430
431
  print_info "Downloading openclacky-${cn_version}.gem..."
431
432
  curl -fsSL "$gem_url" -o "$gem_file"
432
- gem install "$gem_file" --no-document --source "$CN_RUBYGEMS_URL"
433
+ target="$gem_file"
434
+ source_args=(--source "$CN_RUBYGEMS_URL")
433
435
  else
434
- gem install openclacky --no-document
436
+ target="openclacky"
435
437
  fi
436
438
 
437
- if [ $? -eq 0 ]; then
439
+ # macOS system Ruby 2.6 has a buggy gem resolver that fails on rouge 4.x.
440
+ # Pre-install a 2.6-compatible rouge to avoid resolver failure.
441
+ local ruby_ver; ruby_ver=$(ruby -e 'puts RUBY_VERSION' 2>/dev/null)
442
+ if [[ "$ruby_ver" == 2.6.* ]]; then
443
+ print_warning "Ruby 2.6 detected — pinning rouge 3.30.0 first"
444
+ gem install rouge -v 3.30.0 --no-document "${source_args[@]}" || { print_error "gem install rouge failed"; return 1; }
445
+ fi
446
+
447
+ if gem install "$target" --no-document "${source_args[@]}"; then
438
448
  print_success "${DISPLAY_NAME} installed successfully!"
439
449
  return 0
440
- else
441
- print_error "gem install failed"; return 1
442
450
  fi
451
+
452
+ print_error "gem install failed"; return 1
443
453
  }
444
454
 
445
455
  # --------------------------------------------------------------------------
metadata CHANGED
@@ -1,14 +1,14 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: openclacky
3
3
  version: !ruby/object:Gem::Version
4
- version: 1.1.0
4
+ version: 1.1.1
5
5
  platform: ruby
6
6
  authors:
7
7
  - windy
8
8
  autorequire:
9
9
  bindir: bin
10
10
  cert_chain: []
11
- date: 2026-05-15 00:00:00.000000000 Z
11
+ date: 2026-05-17 00:00:00.000000000 Z
12
12
  dependencies:
13
13
  - !ruby/object:Gem::Dependency
14
14
  name: faraday