openclacky 1.1.0 → 1.1.2

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 (60) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +37 -0
  3. data/README.md +28 -7
  4. data/lib/clacky/agent/llm_caller.rb +23 -1
  5. data/lib/clacky/agent/session_serializer.rb +6 -1
  6. data/lib/clacky/agent/skill_manager.rb +18 -5
  7. data/lib/clacky/agent.rb +14 -5
  8. data/lib/clacky/anthropic_stream_aggregator.rb +135 -0
  9. data/lib/clacky/bedrock_stream_aggregator.rb +137 -0
  10. data/lib/clacky/brand_config.rb +68 -15
  11. data/lib/clacky/cli.rb +18 -19
  12. data/lib/clacky/client.rb +146 -17
  13. data/lib/clacky/default_skills/onboard/SKILL.md +6 -2
  14. data/lib/clacky/default_skills/onboard/scripts/import_external_skills.rb +50 -6
  15. data/lib/clacky/openai_stream_aggregator.rb +130 -0
  16. data/lib/clacky/server/channel/adapters/weixin/adapter.rb +169 -6
  17. data/lib/clacky/server/channel/channel_ui_controller.rb +6 -0
  18. data/lib/clacky/server/http_server.rb +9 -3
  19. data/lib/clacky/server/web_ui_controller.rb +8 -4
  20. data/lib/clacky/tools/terminal.rb +11 -0
  21. data/lib/clacky/ui2/components/input_area.rb +10 -1
  22. data/lib/clacky/ui2/components/todo_area.rb +22 -2
  23. data/lib/clacky/ui2/layout_manager.rb +70 -14
  24. data/lib/clacky/ui2/progress_handle.rb +86 -15
  25. data/lib/clacky/ui2/ui_controller.rb +47 -7
  26. data/lib/clacky/utils/logger.rb +7 -0
  27. data/lib/clacky/version.rb +1 -1
  28. data/lib/clacky/web/app.css +6 -4
  29. data/lib/clacky/web/i18n.js +21 -6
  30. data/lib/clacky/web/index.html +8 -6
  31. data/lib/clacky/web/sessions.js +171 -58
  32. data/lib/clacky/web/vendor/katex/auto-render.min.js +1 -0
  33. data/lib/clacky/web/vendor/katex/fonts/KaTeX_AMS-Regular.woff2 +0 -0
  34. data/lib/clacky/web/vendor/katex/fonts/KaTeX_Caligraphic-Bold.woff2 +0 -0
  35. data/lib/clacky/web/vendor/katex/fonts/KaTeX_Caligraphic-Regular.woff2 +0 -0
  36. data/lib/clacky/web/vendor/katex/fonts/KaTeX_Fraktur-Bold.woff2 +0 -0
  37. data/lib/clacky/web/vendor/katex/fonts/KaTeX_Fraktur-Regular.woff2 +0 -0
  38. data/lib/clacky/web/vendor/katex/fonts/KaTeX_Main-Bold.woff2 +0 -0
  39. data/lib/clacky/web/vendor/katex/fonts/KaTeX_Main-BoldItalic.woff2 +0 -0
  40. data/lib/clacky/web/vendor/katex/fonts/KaTeX_Main-Italic.woff2 +0 -0
  41. data/lib/clacky/web/vendor/katex/fonts/KaTeX_Main-Regular.woff2 +0 -0
  42. data/lib/clacky/web/vendor/katex/fonts/KaTeX_Math-BoldItalic.woff2 +0 -0
  43. data/lib/clacky/web/vendor/katex/fonts/KaTeX_Math-Italic.woff2 +0 -0
  44. data/lib/clacky/web/vendor/katex/fonts/KaTeX_SansSerif-Bold.woff2 +0 -0
  45. data/lib/clacky/web/vendor/katex/fonts/KaTeX_SansSerif-Italic.woff2 +0 -0
  46. data/lib/clacky/web/vendor/katex/fonts/KaTeX_SansSerif-Regular.woff2 +0 -0
  47. data/lib/clacky/web/vendor/katex/fonts/KaTeX_Script-Regular.woff2 +0 -0
  48. data/lib/clacky/web/vendor/katex/fonts/KaTeX_Size1-Regular.woff2 +0 -0
  49. data/lib/clacky/web/vendor/katex/fonts/KaTeX_Size2-Regular.woff2 +0 -0
  50. data/lib/clacky/web/vendor/katex/fonts/KaTeX_Size3-Regular.woff2 +0 -0
  51. data/lib/clacky/web/vendor/katex/fonts/KaTeX_Size4-Regular.woff2 +0 -0
  52. data/lib/clacky/web/vendor/katex/fonts/KaTeX_Typewriter-Regular.woff2 +0 -0
  53. data/lib/clacky/web/vendor/katex/katex.min.css +1 -0
  54. data/lib/clacky/web/vendor/katex/katex.min.js +1 -0
  55. data/lib/clacky/web/ws-dispatcher.js +19 -4
  56. data/lib/clacky.rb +3 -0
  57. data/scripts/build/src/install.sh.cc +15 -5
  58. data/scripts/install.ps1 +14 -3
  59. data/scripts/install.sh +15 -5
  60. metadata +28 -2
@@ -0,0 +1,130 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "json"
4
+
5
+ module Clacky
6
+ # Reassembles an OpenAI-compatible chat-completion event stream into the
7
+ # non-streaming response shape that MessageFormat::OpenAI.parse_response
8
+ # consumes, while invoking on_chunk(input_tokens:, output_tokens:) every
9
+ # time the upstream emits a new usage frame.
10
+ #
11
+ # Streaming frames look like:
12
+ #
13
+ # {"id":"...","choices":[{"index":0,"delta":{"role":"assistant"},"finish_reason":null}]}
14
+ # {"id":"...","choices":[{"index":0,"delta":{"content":"Hi"},"finish_reason":null}]}
15
+ # {"id":"...","choices":[{"index":0,"delta":{"tool_calls":[{"index":0,"id":"call_x","function":{"name":"shell","arguments":"{\"cmd"}}]}}]}
16
+ # {"id":"...","choices":[{"index":0,"delta":{"tool_calls":[{"index":0,"function":{"arguments":"\":\"ls\"}"}}]}}]}
17
+ # {"id":"...","choices":[{"index":0,"delta":{},"finish_reason":"tool_calls"}]}
18
+ # {"id":"...","choices":[],"usage":{"prompt_tokens":12,"completion_tokens":3,"prompt_tokens_details":{"cached_tokens":2}}}
19
+ # data: [DONE]
20
+ class OpenAIStreamAggregator
21
+ def initialize(on_chunk: nil)
22
+ @on_chunk = on_chunk
23
+ @content = +""
24
+ @reasoning_content = +""
25
+ @role = "assistant"
26
+ @finish_reason = nil
27
+ @tool_calls = {}
28
+ @usage = nil
29
+ @last_input_tokens = 0
30
+ @last_output_tokens = 0
31
+ end
32
+
33
+ def handle(data_str)
34
+ return if data_str == "[DONE]"
35
+ data = parse_or_nil(data_str)
36
+ return unless data
37
+
38
+ if (choice = (data["choices"] || []).first)
39
+ delta = choice["delta"] || {}
40
+ @role = delta["role"] if delta["role"]
41
+ @content << delta["content"] if delta["content"].is_a?(String)
42
+ @reasoning_content << delta["reasoning_content"] if delta["reasoning_content"].is_a?(String)
43
+ if (tcs = delta["tool_calls"])
44
+ tcs.each { |tc| merge_tool_call(tc) }
45
+ end
46
+ @finish_reason = choice["finish_reason"] if choice["finish_reason"]
47
+ emit_estimate_progress
48
+ end
49
+
50
+ if (u = data["usage"])
51
+ @usage = u
52
+ emit_usage_progress(u)
53
+ end
54
+ end
55
+
56
+ # Render the canonical non-streaming response shape.
57
+ def to_h
58
+ tool_calls = @tool_calls.keys.sort.map do |idx|
59
+ tc = @tool_calls[idx]
60
+ {
61
+ "id" => tc[:id],
62
+ "type" => tc[:type] || "function",
63
+ "function" => {
64
+ "name" => tc[:name],
65
+ "arguments" => tc[:arguments].to_s
66
+ }
67
+ }
68
+ end
69
+
70
+ message = {
71
+ "role" => @role,
72
+ "content" => @content.empty? ? nil : @content
73
+ }
74
+ message["tool_calls"] = tool_calls unless tool_calls.empty?
75
+ message["reasoning_content"] = @reasoning_content unless @reasoning_content.empty?
76
+
77
+ {
78
+ "choices" => [{ "index" => 0, "message" => message, "finish_reason" => @finish_reason }],
79
+ "usage" => @usage || {}
80
+ }
81
+ end
82
+
83
+ private def merge_tool_call(tc)
84
+ idx = tc["index"] || @tool_calls.size
85
+ slot = (@tool_calls[idx] ||= { id: nil, type: nil, name: nil, arguments: +"" })
86
+ slot[:id] ||= tc["id"] if tc["id"]
87
+ slot[:type] ||= tc["type"] if tc["type"]
88
+ if (fn = tc["function"])
89
+ slot[:name] ||= fn["name"] if fn["name"]
90
+ slot[:arguments] << fn["arguments"].to_s if fn["arguments"]
91
+ end
92
+ end
93
+
94
+ private def parse_or_nil(s)
95
+ JSON.parse(s)
96
+ rescue JSON::ParserError
97
+ nil
98
+ end
99
+
100
+ private def emit_estimate_progress
101
+ return unless @on_chunk
102
+ output = approximate_output_tokens
103
+ return if output == @last_output_tokens
104
+ @last_output_tokens = output
105
+ @on_chunk.call(input_tokens: @last_input_tokens, output_tokens: output)
106
+ rescue => e
107
+ Clacky::Logger.warn("[OpenAIStreamAggregator] on_chunk: #{e.class}: #{e.message}")
108
+ end
109
+
110
+ # Rough char/4 estimate; replaced by the real count when the upstream
111
+ # finally emits a usage frame (with stream_options.include_usage=true).
112
+ private def approximate_output_tokens
113
+ total_chars = @content.bytesize + @reasoning_content.bytesize +
114
+ @tool_calls.values.sum { |tc| tc[:arguments].to_s.bytesize }
115
+ (total_chars / 4.0).ceil
116
+ end
117
+
118
+ private def emit_usage_progress(u)
119
+ return unless @on_chunk
120
+ total_prompt = u["prompt_tokens"].to_i
121
+ output = u["completion_tokens"].to_i
122
+ return if total_prompt == @last_input_tokens && output == @last_output_tokens
123
+ @last_input_tokens = total_prompt
124
+ @last_output_tokens = output
125
+ @on_chunk.call(input_tokens: total_prompt, output_tokens: output)
126
+ rescue => e
127
+ Clacky::Logger.warn("[OpenAIStreamAggregator] on_chunk: #{e.class}: #{e.message}")
128
+ end
129
+ end
130
+ end
@@ -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
@@ -3365,9 +3372,8 @@ module Clacky
3365
3372
  return unless agent
3366
3373
 
3367
3374
  # Auto-name the session from the first user message (before agent starts running).
3368
- # Check messages.empty? only — agent.name may already hold a default placeholder
3369
- # like "Session 1" assigned at creation time, so it's not a reliable signal.
3370
- if agent.history.empty?
3375
+ # Skip if the name looks like it was set by the user (not a system-generated "Session N").
3376
+ if agent.history.empty? && agent.name.match?(/\ASession \d+\z/)
3371
3377
  auto_name = content.gsub(/\s+/, " ").strip[0, 30]
3372
3378
  auto_name += "…" if content.strip.length > 30
3373
3379
  agent.rename(auto_name)
@@ -225,15 +225,19 @@ module Clacky
225
225
 
226
226
  def show_progress(message = nil, prefix_newline: true, progress_type: "thinking", phase: "active", metadata: {})
227
227
  if phase == "active"
228
- @progress_start_time = Time.now
229
- # Store complete progress state for replay when user switches back to this session
228
+ # Only set start time when transitioning into a fresh progress phase.
229
+ # Streaming LLM calls fire show_progress every chunk for token updates;
230
+ # resetting the timer each time would make the elapsed counter jitter
231
+ # back to 0 in the UI and force the frontend to rebuild its interval.
232
+ if @live_progress_state.nil? || @live_progress_state[:progress_type] != progress_type
233
+ @progress_start_time = Time.now
234
+ @live_stdout_buffer = []
235
+ end
230
236
  @live_progress_state = {
231
237
  message: message,
232
238
  progress_type: progress_type,
233
239
  metadata: metadata
234
240
  }
235
- # Reset stdout buffer for each new command so re-subscribe only replays current run
236
- @live_stdout_buffer = []
237
241
  elsif phase == "done"
238
242
  @live_tool_call = nil # command finished — nothing left to replay
239
243
  # Keep @live_stdout_buffer intact — it will be reset on the next show_progress call.
@@ -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
  # -----------------------------------------------------------------------