openclacky 0.5.6 → 0.6.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.
Files changed (48) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +71 -0
  3. data/docs/ui2-architecture.md +124 -0
  4. data/lib/clacky/agent.rb +376 -346
  5. data/lib/clacky/agent_config.rb +1 -7
  6. data/lib/clacky/cli.rb +167 -398
  7. data/lib/clacky/client.rb +68 -36
  8. data/lib/clacky/gitignore_parser.rb +26 -12
  9. data/lib/clacky/model_pricing.rb +6 -2
  10. data/lib/clacky/session_manager.rb +6 -2
  11. data/lib/clacky/tools/glob.rb +66 -10
  12. data/lib/clacky/tools/grep.rb +6 -122
  13. data/lib/clacky/tools/run_project.rb +10 -5
  14. data/lib/clacky/tools/safe_shell.rb +149 -20
  15. data/lib/clacky/tools/shell.rb +3 -51
  16. data/lib/clacky/tools/todo_manager.rb +50 -3
  17. data/lib/clacky/tools/trash_manager.rb +1 -1
  18. data/lib/clacky/tools/web_fetch.rb +4 -4
  19. data/lib/clacky/tools/web_search.rb +40 -28
  20. data/lib/clacky/ui2/README.md +214 -0
  21. data/lib/clacky/ui2/components/base_component.rb +163 -0
  22. data/lib/clacky/ui2/components/common_component.rb +98 -0
  23. data/lib/clacky/ui2/components/inline_input.rb +187 -0
  24. data/lib/clacky/ui2/components/input_area.rb +1124 -0
  25. data/lib/clacky/ui2/components/message_component.rb +80 -0
  26. data/lib/clacky/ui2/components/output_area.rb +112 -0
  27. data/lib/clacky/ui2/components/todo_area.rb +130 -0
  28. data/lib/clacky/ui2/components/tool_component.rb +106 -0
  29. data/lib/clacky/ui2/components/welcome_banner.rb +103 -0
  30. data/lib/clacky/ui2/layout_manager.rb +437 -0
  31. data/lib/clacky/ui2/line_editor.rb +201 -0
  32. data/lib/clacky/ui2/markdown_renderer.rb +80 -0
  33. data/lib/clacky/ui2/screen_buffer.rb +257 -0
  34. data/lib/clacky/ui2/theme_manager.rb +68 -0
  35. data/lib/clacky/ui2/themes/base_theme.rb +85 -0
  36. data/lib/clacky/ui2/themes/hacker_theme.rb +58 -0
  37. data/lib/clacky/ui2/themes/minimal_theme.rb +52 -0
  38. data/lib/clacky/ui2/ui_controller.rb +778 -0
  39. data/lib/clacky/ui2/view_renderer.rb +177 -0
  40. data/lib/clacky/ui2.rb +37 -0
  41. data/lib/clacky/utils/file_ignore_helper.rb +126 -0
  42. data/lib/clacky/version.rb +1 -1
  43. data/lib/clacky.rb +1 -6
  44. metadata +53 -6
  45. data/lib/clacky/ui/banner.rb +0 -155
  46. data/lib/clacky/ui/enhanced_prompt.rb +0 -786
  47. data/lib/clacky/ui/formatter.rb +0 -209
  48. data/lib/clacky/ui/statusbar.rb +0 -96
data/lib/clacky/agent.rb CHANGED
@@ -10,7 +10,7 @@ require_relative "utils/arguments_parser"
10
10
  module Clacky
11
11
  class Agent
12
12
  attr_reader :session_id, :messages, :iterations, :total_cost, :working_dir, :created_at, :total_tasks, :todos,
13
- :cache_stats, :cost_source
13
+ :cache_stats, :cost_source, :ui
14
14
 
15
15
  # System prompt for the coding agent
16
16
  SYSTEM_PROMPT = <<~PROMPT.freeze
@@ -51,7 +51,7 @@ module Clacky
51
51
  NEVER stop after just adding todos without executing them!
52
52
  PROMPT
53
53
 
54
- def initialize(client, config = {}, working_dir: nil)
54
+ def initialize(client, config = {}, working_dir: nil, ui: nil)
55
55
  @client = client
56
56
  @config = config.is_a?(AgentConfig) ? config : AgentConfig.new(config)
57
57
  @tool_registry = ToolRegistry.new
@@ -65,7 +65,8 @@ module Clacky
65
65
  cache_creation_input_tokens: 0,
66
66
  cache_read_input_tokens: 0,
67
67
  total_requests: 0,
68
- cache_hit_requests: 0
68
+ cache_hit_requests: 0,
69
+ raw_api_usage_samples: [] # Store raw API usage for debugging
69
70
  }
70
71
  @start_time = nil
71
72
  @working_dir = working_dir || Dir.pwd
@@ -74,14 +75,17 @@ module Clacky
74
75
  @cost_source = :estimated # Track whether cost is from API or estimated
75
76
  @task_cost_source = :estimated # Track cost source for current task
76
77
  @previous_total_tokens = 0 # Track tokens from previous iteration for delta calculation
78
+ @interrupted = false # Flag for user interrupt
79
+ @ui = ui # UIController for direct UI interaction
80
+ @debug_logs = [] # Debug logs for troubleshooting
77
81
 
78
82
  # Register built-in tools
79
83
  register_builtin_tools
80
84
  end
81
85
 
82
86
  # Restore from a saved session
83
- def self.from_session(client, config, session_data)
84
- agent = new(client, config)
87
+ def self.from_session(client, config, session_data, ui: nil)
88
+ agent = new(client, config, ui: ui)
85
89
  agent.restore_session(session_data)
86
90
  agent
87
91
  end
@@ -125,24 +129,60 @@ module Clacky
125
129
  end
126
130
  end
127
131
 
132
+ # Get recent user messages from conversation history
133
+ # @param limit [Integer] Number of recent user messages to retrieve (default: 5)
134
+ # @return [Array<String>] Array of recent user message contents
135
+ def get_recent_user_messages(limit: 5)
136
+ # Filter messages to only include real user messages (exclude system-injected ones)
137
+ user_messages = @messages.select do |m|
138
+ m[:role] == "user" && !m[:system_injected]
139
+ end
140
+
141
+ # Extract text content from the last N user messages
142
+ user_messages.last(limit).map do |msg|
143
+ extract_text_from_content(msg[:content])
144
+ end
145
+ end
146
+
147
+ private def extract_text_from_content(content)
148
+ if content.is_a?(String)
149
+ content
150
+ elsif content.is_a?(Array)
151
+ # Extract text from content array (may contain text and images)
152
+ text_parts = content.select { |c| c.is_a?(Hash) && c[:type] == "text" }
153
+ text_parts.map { |c| c[:text] }.join("\n")
154
+ else
155
+ content.to_s
156
+ end
157
+ end
158
+
128
159
  def add_hook(event, &block)
129
160
  @hooks.add(event, &block)
130
161
  end
131
162
 
132
- def run(user_input, images: [], &block)
163
+ def run(user_input, images: [])
133
164
  @start_time = Time.now
134
165
  @task_cost_source = :estimated # Reset for new task
135
166
  @previous_total_tokens = 0 # Reset token tracking for new task
167
+ @task_start_iterations = @iterations # Track starting iterations for this task
168
+ @task_start_cost = @total_cost # Track starting cost for this task
169
+
170
+ # Track cache stats for current task
171
+ @task_cache_stats = {
172
+ cache_creation_input_tokens: 0,
173
+ cache_read_input_tokens: 0,
174
+ total_requests: 0,
175
+ cache_hit_requests: 0
176
+ }
136
177
 
137
178
  # Add system prompt as the first message if this is the first run
138
179
  if @messages.empty?
139
180
  system_prompt = build_system_prompt
140
181
  system_message = { role: "system", content: system_prompt }
141
182
 
142
- # Enable caching for system prompt if configured and model supports it
143
- if @config.enable_prompt_caching
144
- system_message[:cache_control] = { type: "ephemeral" }
145
- end
183
+ # Note: Don't set cache_control on system prompt
184
+ # System prompt is usually < 1024 tokens (minimum for caching)
185
+ # Cache control will be set on tools and conversation history instead
146
186
 
147
187
  @messages << system_message
148
188
  end
@@ -152,7 +192,6 @@ module Clacky
152
192
  @messages << { role: "user", content: user_content }
153
193
  @total_tasks += 1
154
194
 
155
- emit_event(:on_start, { input: user_input }, &block)
156
195
  @hooks.trigger(:on_start, user_input)
157
196
 
158
197
  begin
@@ -160,30 +199,29 @@ module Clacky
160
199
  break if should_stop?
161
200
 
162
201
  @iterations += 1
163
- emit_event(:on_iteration, { iteration: @iterations }, &block)
164
202
  @hooks.trigger(:on_iteration, @iterations)
165
203
 
166
204
  # Think: LLM reasoning with tool support
167
- response = think(&block)
205
+ response = think
168
206
 
169
207
  # Debug: check for potential infinite loops
170
208
  if @config.verbose
171
- puts "[DEBUG] Iteration #{@iterations}: finish_reason=#{response[:finish_reason]}, tool_calls=#{response[:tool_calls]&.size || 'nil'}"
209
+ @ui&.log("Iteration #{@iterations}: finish_reason=#{response[:finish_reason]}, tool_calls=#{response[:tool_calls]&.size || 'nil'}", level: :debug)
172
210
  end
173
211
 
174
212
  # Check if done (no more tool calls needed)
175
213
  if response[:finish_reason] == "stop" || response[:tool_calls].nil? || response[:tool_calls].empty?
176
- emit_event(:answer, { content: response[:content] }, &block)
214
+ @ui&.show_assistant_message(response[:content]) if response[:content] && !response[:content].empty?
177
215
  break
178
216
  end
179
217
 
180
- # Emit assistant_message event if there's content before tool calls
218
+ # Show assistant message if there's content before tool calls
181
219
  if response[:content] && !response[:content].empty?
182
- emit_event(:assistant_message, { content: response[:content] }, &block)
220
+ @ui&.show_assistant_message(response[:content])
183
221
  end
184
222
 
185
223
  # Act: Execute tool calls
186
- action_result = act(response[:tool_calls], &block)
224
+ action_result = act(response[:tool_calls])
187
225
 
188
226
  # Observe: Add tool results to conversation context
189
227
  observe(response, action_result[:tool_results])
@@ -192,29 +230,37 @@ module Clacky
192
230
  if action_result[:denied]
193
231
  # If user provided feedback, treat it as a user question/instruction
194
232
  if action_result[:feedback] && !action_result[:feedback].empty?
195
- # Add user feedback as a new user message
196
- # Use a clear format that signals this is important user input
233
+ # Add user feedback as a new user message with system_injected marker
197
234
  @messages << {
198
235
  role: "user",
199
- content: "STOP. The user has a question/feedback for you: #{action_result[:feedback]}\n\nPlease respond to the user's question/feedback before continuing with any actions."
236
+ content: "STOP. The user has a question/feedback for you: #{action_result[:feedback]}\n\nPlease respond to the user's question/feedback before continuing with any actions.",
237
+ system_injected: true # Mark as system-injected message for filtering
200
238
  }
201
239
  # Continue loop to let agent respond to feedback
202
240
  next
203
241
  else
204
242
  # User just said "no" without feedback - stop and wait
205
- emit_event(:answer, { content: "Tool execution was denied. Please provide further instructions." }, &block)
243
+ @ui&.show_assistant_message("Tool execution was denied. Please give more instructions...")
206
244
  break
207
245
  end
208
246
  end
209
247
  end
210
248
 
211
249
  result = build_result(:success)
212
- emit_event(:on_complete, result, &block)
250
+ @ui&.show_complete(
251
+ iterations: result[:iterations],
252
+ cost: result[:total_cost_usd],
253
+ duration: result[:duration_seconds],
254
+ cache_stats: result[:cache_stats]
255
+ )
213
256
  @hooks.trigger(:on_complete, result)
214
257
  result
258
+ rescue Clacky::AgentInterrupted
259
+ # Let CLI handle the interrupt message
260
+ raise
215
261
  rescue StandardError => e
262
+ # Build error result for session data, but let CLI handle error display
216
263
  result = build_result(:error, error: e.message)
217
- emit_event(:on_complete, result, &block)
218
264
  raise
219
265
  end
220
266
  end
@@ -247,7 +293,8 @@ module Clacky
247
293
  total_cost_usd: @total_cost.round(4),
248
294
  duration_seconds: @start_time ? (Time.now - @start_time).round(2) : 0,
249
295
  last_status: status.to_s,
250
- cache_stats: @cache_stats
296
+ cache_stats: @cache_stats,
297
+ debug_logs: @debug_logs
251
298
  }
252
299
 
253
300
  # Add error message if status is error
@@ -262,8 +309,6 @@ module Clacky
262
309
  config: {
263
310
  model: @config.model,
264
311
  permission_mode: @config.permission_mode.to_s,
265
- max_iterations: @config.max_iterations,
266
- max_cost_usd: @config.max_cost_usd,
267
312
  enable_compression: @config.enable_compression,
268
313
  enable_prompt_caching: @config.enable_prompt_caching,
269
314
  keep_recent_messages: @config.keep_recent_messages,
@@ -279,9 +324,6 @@ module Clacky
279
324
  private
280
325
 
281
326
  def should_auto_execute?(tool_name, tool_params = {})
282
- # Check if tool is disallowed
283
- return false if @config.disallowed_tools.include?(tool_name)
284
-
285
327
  case @config.permission_mode
286
328
  when :auto_approve
287
329
  true
@@ -367,120 +409,104 @@ module Clacky
367
409
  prompt
368
410
  end
369
411
 
370
- def think(&block)
371
- emit_event(:thinking, { iteration: @iterations }, &block)
412
+ def think
413
+ @ui&.show_progress
372
414
 
373
415
  # Compress messages if needed to reduce cost
374
- compress_messages_if_needed if @config.enable_compression
416
+ compress_messages_if_needed
375
417
 
376
418
  # Always send tools definitions to allow multi-step tool calling
377
- tools_to_send = @tool_registry.allowed_definitions(@config.allowed_tools)
419
+ tools_to_send = @tool_registry.all_definitions
378
420
 
379
- # Show progress indicator while waiting for LLM response
380
- progress = ProgressIndicator.new(verbose: @config.verbose)
381
- progress.start
421
+ # Retry logic for network failures
422
+ max_retries = 10
423
+ retry_delay = 5
424
+ retries = 0
382
425
 
383
426
  begin
384
- # Retry logic for network failures
385
- max_retries = 10
386
- retry_delay = 5
387
- retries = 0
388
-
389
- begin
390
- response = @client.send_messages_with_tools(
391
- @messages,
392
- model: @config.model,
393
- tools: tools_to_send,
394
- max_tokens: @config.max_tokens,
395
- verbose: @config.verbose,
396
- enable_caching: @config.enable_prompt_caching
397
- )
398
- rescue Faraday::ConnectionFailed, Faraday::TimeoutError, Errno::ECONNREFUSED, Errno::ETIMEDOUT => e
399
- retries += 1
400
- if retries <= max_retries
401
- progress.finish
402
- puts "\n⚠️ Network request failed: #{e.class.name} - #{e.message}"
403
- puts "🔄 Retry #{retries}/#{max_retries}, waiting #{retry_delay} seconds..."
404
- sleep retry_delay
405
- progress.start
406
- retry
407
- else
408
- progress.finish
409
- puts "\n❌ Network request failed after #{max_retries} retries, giving up"
410
- raise Error, "Network connection failed after #{max_retries} retries: #{e.message}"
411
- end
427
+ response = @client.send_messages_with_tools(
428
+ @messages,
429
+ model: @config.model,
430
+ tools: tools_to_send,
431
+ max_tokens: @config.max_tokens,
432
+ enable_caching: @config.enable_prompt_caching
433
+ )
434
+ rescue Faraday::ConnectionFailed, Faraday::TimeoutError, Errno::ECONNREFUSED, Errno::ETIMEDOUT => e
435
+ retries += 1
436
+ if retries <= max_retries
437
+ @ui&.show_warning("Network failed: #{e.message}. Retry #{retries}/#{max_retries}...")
438
+ sleep retry_delay
439
+ retry
440
+ else
441
+ @ui&.show_error("Network failed after #{max_retries} retries: #{e.message}")
442
+ raise Error, "Network connection failed after #{max_retries} retries: #{e.message}"
412
443
  end
444
+ end
413
445
 
414
- track_cost(response[:usage])
446
+ # Clear progress indicator (change to gray and show final time)
447
+ @ui&.clear_progress
415
448
 
416
- # Handle truncated responses (when max_tokens limit is reached)
417
- if response[:finish_reason] == "length"
418
- # Count recent truncations to prevent infinite loops
419
- recent_truncations = @messages.last(5).count { |m|
420
- m[:role] == "user" && m[:content]&.include?("[SYSTEM] Your response was truncated")
421
- }
449
+ track_cost(response[:usage], raw_api_usage: response[:raw_api_usage])
422
450
 
423
- if recent_truncations >= 2
424
- # Too many truncations - task is too complex
425
- progress.finish
426
- puts "\n⚠️ Response truncated multiple times. Task is too complex for a single response." if @config.verbose
427
-
428
- # Create a response that tells the user to break down the task
429
- error_response = {
430
- content: "I apologize, but this task is too complex to complete in a single response. " \
431
- "Please break it down into smaller steps, or reduce the amount of content to generate at once.\n\n" \
432
- "For example, when creating a long document:\n" \
433
- "1. First create the file with a basic structure\n" \
434
- "2. Then use edit() to add content section by section",
435
- finish_reason: "stop",
436
- tool_calls: nil
437
- }
438
-
439
- # Add this as an assistant message so it appears in conversation
440
- @messages << {
441
- role: "assistant",
442
- content: error_response[:content]
443
- }
444
-
445
- return error_response
446
- end
451
+ # Handle truncated responses (when max_tokens limit is reached)
452
+ if response[:finish_reason] == "length"
453
+ # Count recent truncations to prevent infinite loops
454
+ recent_truncations = @messages.last(5).count { |m|
455
+ m[:role] == "user" && m[:content]&.include?("[SYSTEM] Your response was truncated")
456
+ }
447
457
 
448
- # Insert system message to guide LLM to retry with smaller steps
449
- @messages << {
450
- role: "user",
451
- content: "[SYSTEM] Your response was truncated due to length limit. Please retry with a different approach:\n" \
452
- "- For long file content: create the file with structure first, then use edit() to add content section by section\n" \
453
- "- Break down large tasks into multiple smaller steps\n" \
454
- "- Avoid putting more than 2000 characters in a single tool call argument\n" \
455
- "- Use multiple tool calls instead of one large call"
458
+ if recent_truncations >= 2
459
+ # Too many truncations - task is too complex
460
+ @ui&.show_error("Response truncated multiple times. Task is too complex.")
461
+
462
+ # Create a response that tells the user to break down the task
463
+ error_response = {
464
+ content: "I apologize, but this task is too complex to complete in a single response. " \
465
+ "Please break it down into smaller steps, or reduce the amount of content to generate at once.\n\n" \
466
+ "For example, when creating a long document:\n" \
467
+ "1. First create the file with a basic structure\n" \
468
+ "2. Then use edit() to add content section by section",
469
+ finish_reason: "stop",
470
+ tool_calls: nil
456
471
  }
457
472
 
458
- puts "⚠️ Response truncated due to length limit. Retrying with smaller steps..." if @config.verbose
473
+ # Add this as an assistant message so it appears in conversation
474
+ @messages << {
475
+ role: "assistant",
476
+ content: error_response[:content]
477
+ }
459
478
 
460
- # Recursively retry
461
- return think(&block)
479
+ return error_response
462
480
  end
463
481
 
464
- # Add assistant response to messages
465
- msg = { role: "assistant" }
466
- # Always include content field (some APIs require it even with tool_calls)
467
- # Use empty string instead of null for better compatibility
468
- msg[:content] = response[:content] || ""
469
- msg[:tool_calls] = format_tool_calls_for_api(response[:tool_calls]) if response[:tool_calls]
470
- @messages << msg
482
+ # Insert system message to guide LLM to retry with smaller steps
483
+ @messages << {
484
+ role: "user",
485
+ content: "[SYSTEM] Your response was truncated due to length limit. Please retry with a different approach:\n" \
486
+ "- For long file content: create the file with structure first, then use edit() to add content section by section\n" \
487
+ "- Break down large tasks into multiple smaller steps\n" \
488
+ "- Avoid putting more than 2000 characters in a single tool call argument\n" \
489
+ "- Use multiple tool calls instead of one large call"
490
+ }
471
491
 
472
- if @config.verbose
473
- puts "\n[DEBUG] Assistant response added to messages:"
474
- puts JSON.pretty_generate(msg)
475
- end
492
+ @ui&.show_warning("Response truncated. Retrying with smaller steps...")
476
493
 
477
- response
478
- ensure
479
- progress.finish
494
+ # Recursively retry
495
+ return think
480
496
  end
497
+
498
+ # Add assistant response to messages
499
+ msg = { role: "assistant" }
500
+ # Always include content field (some APIs require it even with tool_calls)
501
+ # Use empty string instead of null for better compatibility
502
+ msg[:content] = response[:content] || ""
503
+ msg[:tool_calls] = format_tool_calls_for_api(response[:tool_calls]) if response[:tool_calls]
504
+ @messages << msg
505
+
506
+ response
481
507
  end
482
508
 
483
- def act(tool_calls, &block)
509
+ def act(tool_calls)
484
510
  return { denied: false, feedback: nil, tool_results: [] } unless tool_calls
485
511
 
486
512
  denied = false
@@ -491,7 +517,7 @@ module Clacky
491
517
  # Hook: before_tool_use
492
518
  hook_result = @hooks.trigger(:before_tool_use, call)
493
519
  if hook_result[:action] == :deny
494
- emit_event(:tool_denied, call, &block)
520
+ @ui&.show_warning("Tool #{call[:name]} denied by hook")
495
521
  results << build_error_result(call, hook_result[:reason] || "Tool use denied by hook")
496
522
  next
497
523
  end
@@ -499,34 +525,38 @@ module Clacky
499
525
  # Permission check (if not in auto-approve mode)
500
526
  unless should_auto_execute?(call[:name], call[:arguments])
501
527
  if @config.is_plan_only?
502
- emit_event(:tool_planned, call, &block)
528
+ @ui&.show_info("Planned: #{call[:name]}")
503
529
  results << build_planned_result(call)
504
530
  next
505
531
  end
506
532
 
507
- confirmation = confirm_tool_use?(call, &block)
533
+ confirmation = confirm_tool_use?(call)
508
534
  unless confirmation[:approved]
509
- emit_event(:tool_denied, call, &block)
535
+ # Show denial warning with user feedback if provided
536
+ denial_message = "Tool #{call[:name]} denied"
537
+ if confirmation[:feedback] && !confirmation[:feedback].empty?
538
+ denial_message += ": #{confirmation[:feedback]}"
539
+ end
540
+ @ui&.show_warning(denial_message)
541
+
510
542
  denied = true
511
543
  user_feedback = confirmation[:feedback]
512
544
  feedback = user_feedback if user_feedback
513
545
  results << build_denied_result(call, user_feedback)
514
546
 
515
- # If user provided feedback, stop processing remaining tools immediately
516
- # Let the agent respond to the feedback in the next iteration
517
- if user_feedback && !user_feedback.empty?
518
- # Fill in denied results for all remaining tool calls to avoid mismatch
519
- remaining_calls = tool_calls[(index + 1)..-1] || []
520
- remaining_calls.each do |remaining_call|
521
- results << build_denied_result(remaining_call, "Auto-denied due to user feedback on previous tool")
522
- end
523
- break
547
+ # Auto-deny all remaining tools
548
+ remaining_calls = tool_calls[(index + 1)..-1] || []
549
+ remaining_calls.each do |remaining_call|
550
+ reason = user_feedback && !user_feedback.empty? ?
551
+ user_feedback :
552
+ "Auto-denied due to user rejection of previous tool"
553
+ results << build_denied_result(remaining_call, reason)
524
554
  end
525
- next
555
+ break
526
556
  end
527
557
  end
528
558
 
529
- emit_event(:tool_call, call, &block)
559
+ @ui&.show_tool_call(call[:name], call[:arguments])
530
560
 
531
561
  # Execute tool
532
562
  begin
@@ -540,16 +570,30 @@ module Clacky
540
570
  args[:todos_storage] = @todos
541
571
  end
542
572
 
573
+ # Show progress for potentially slow tools (no prefix newline)
574
+ if potentially_slow_tool?(call[:name], args)
575
+ progress_message = build_tool_progress_message(call[:name], args)
576
+ @ui&.show_progress(progress_message, prefix_newline: false)
577
+ end
578
+
543
579
  result = tool.execute(**args)
544
580
 
581
+ # Clear progress if shown
582
+ @ui&.clear_progress if potentially_slow_tool?(call[:name], args)
583
+
545
584
  # Hook: after_tool_use
546
585
  @hooks.trigger(:after_tool_use, call, result)
547
586
 
548
- emit_event(:observation, { tool: call[:name], result: result }, &block)
587
+ # Update todos display after todo_manager execution
588
+ if call[:name] == "todo_manager"
589
+ @ui&.update_todos(@todos.dup)
590
+ end
591
+
592
+ @ui&.show_tool_result(tool.format_result(result))
549
593
  results << build_success_result(call, result)
550
594
  rescue StandardError => e
551
595
  @hooks.trigger(:on_tool_error, call, e)
552
- emit_event(:tool_error, { call: call, error: e }, &block)
596
+ @ui&.show_tool_error(e)
553
597
  results << build_error_result(call, e.message)
554
598
  end
555
599
  end
@@ -594,27 +638,75 @@ module Clacky
594
638
  end
595
639
  end
596
640
 
641
+ # Interrupt the agent's current run
642
+ # Called when user presses Ctrl+C during agent execution
643
+ def interrupt!
644
+ @interrupted = true
645
+ end
646
+
647
+ # Check if agent is currently running
648
+ def running?
649
+ @start_time != nil && !should_stop?
650
+ end
651
+
597
652
  def should_stop?
598
- if @iterations >= @config.max_iterations
599
- puts "\n⚠️ Reached maximum iterations (#{@config.max_iterations})" if @config.verbose
653
+ if @interrupted
654
+ @interrupted = false # Reset for next run
600
655
  return true
601
656
  end
602
657
 
603
- if @total_cost >= @config.max_cost_usd
604
- puts "\n⚠️ Reached maximum cost ($#{@config.max_cost_usd})" if @config.verbose
605
- return true
606
- end
607
658
 
608
- # Check timeout only if configured (nil means no timeout)
609
- if @config.timeout_seconds && Time.now - @start_time > @config.timeout_seconds
610
- puts "\n⚠️ Reached timeout (#{@config.timeout_seconds}s)" if @config.verbose
611
- return true
659
+ false
660
+ end
661
+
662
+ # Check if a tool is potentially slow and should show progress
663
+ private def potentially_slow_tool?(tool_name, args)
664
+ case tool_name.to_s.downcase
665
+ when 'shell', 'safe_shell'
666
+ # Check if the command is a slow command
667
+ command = args[:command] || args['command']
668
+ return false unless command
669
+
670
+ # List of slow command patterns
671
+ slow_patterns = [
672
+ /bundle\s+(install|exec\s+rspec|exec\s+rake)/,
673
+ /npm\s+(install|run\s+test|run\s+build)/,
674
+ /yarn\s+(install|test|build)/,
675
+ /pnpm\s+install/,
676
+ /cargo\s+(build|test)/,
677
+ /go\s+(build|test)/,
678
+ /make\s+(test|build)/,
679
+ /pytest/,
680
+ /jest/
681
+ ]
682
+
683
+ slow_patterns.any? { |pattern| command.match?(pattern) }
684
+ when 'web_fetch', 'web_search'
685
+ true # Network operations can be slow
686
+ else
687
+ false # Most file operations are fast
612
688
  end
689
+ end
613
690
 
614
- false
691
+ # Build progress message for tool execution
692
+ private def build_tool_progress_message(tool_name, args)
693
+ case tool_name.to_s.downcase
694
+ when 'shell', 'safe_shell'
695
+ command = args[:command] || args['command']
696
+ # Extract the main command for display
697
+ cmd_parts = command.to_s.split
698
+ main_cmd = cmd_parts.first(2).join(' ')
699
+ "Running #{main_cmd}"
700
+ when 'web_fetch'
701
+ "Fetching web page"
702
+ when 'web_search'
703
+ "Searching web"
704
+ else
705
+ "Executing #{tool_name}"
706
+ end
615
707
  end
616
708
 
617
- def track_cost(usage)
709
+ def track_cost(usage, raw_api_usage: nil)
618
710
  # Priority 1: Use API-provided cost if available (OpenRouter, LiteLLM, etc.)
619
711
  iteration_cost = nil
620
712
  if usage[:api_cost]
@@ -622,7 +714,7 @@ module Clacky
622
714
  @cost_source = :api
623
715
  @task_cost_source = :api
624
716
  iteration_cost = usage[:api_cost]
625
- puts "[DEBUG] Using API-provided cost: $#{usage[:api_cost]}" if @config.verbose
717
+ @ui&.log("Using API-provided cost: $#{usage[:api_cost]}", level: :debug) if @config.verbose
626
718
  else
627
719
  # Priority 2: Calculate from tokens using ModelPricing
628
720
  result = ModelPricing.calculate_cost(model: @config.model, usage: usage)
@@ -637,15 +729,15 @@ module Clacky
637
729
 
638
730
  if @config.verbose
639
731
  source_label = pricing_source == :price ? "model pricing" : "default pricing"
640
- puts "[DEBUG] Calculated cost for #{@config.model} using #{source_label}: $#{cost.round(6)}"
641
- puts "[DEBUG] Usage breakdown: prompt=#{usage[:prompt_tokens]}, completion=#{usage[:completion_tokens]}, cache_write=#{usage[:cache_creation_input_tokens] || 0}, cache_read=#{usage[:cache_read_input_tokens] || 0}"
732
+ @ui&.log("Calculated cost for #{@config.model} using #{source_label}: $#{cost.round(6)}", level: :debug)
733
+ @ui&.log("Usage breakdown: prompt=#{usage[:prompt_tokens]}, completion=#{usage[:completion_tokens]}, cache_write=#{usage[:cache_creation_input_tokens] || 0}, cache_read=#{usage[:cache_read_input_tokens] || 0}", level: :debug)
642
734
  end
643
735
  end
644
736
 
645
737
  # Display token usage statistics for this iteration
646
738
  display_iteration_tokens(usage, iteration_cost)
647
739
 
648
- # Track cache usage statistics
740
+ # Track cache usage statistics (global)
649
741
  @cache_stats[:total_requests] += 1
650
742
 
651
743
  if usage[:cache_creation_input_tokens]
@@ -656,6 +748,27 @@ module Clacky
656
748
  @cache_stats[:cache_read_input_tokens] += usage[:cache_read_input_tokens]
657
749
  @cache_stats[:cache_hit_requests] += 1
658
750
  end
751
+
752
+ # Store raw API usage samples (keep last 3 for debugging)
753
+ if raw_api_usage
754
+ @cache_stats[:raw_api_usage_samples] ||= []
755
+ @cache_stats[:raw_api_usage_samples] << raw_api_usage
756
+ @cache_stats[:raw_api_usage_samples] = @cache_stats[:raw_api_usage_samples].last(3)
757
+ end
758
+
759
+ # Track cache usage for current task
760
+ if @task_cache_stats
761
+ @task_cache_stats[:total_requests] += 1
762
+
763
+ if usage[:cache_creation_input_tokens]
764
+ @task_cache_stats[:cache_creation_input_tokens] += usage[:cache_creation_input_tokens]
765
+ end
766
+
767
+ if usage[:cache_read_input_tokens]
768
+ @task_cache_stats[:cache_read_input_tokens] += usage[:cache_read_input_tokens]
769
+ @task_cache_stats[:cache_hit_requests] += 1
770
+ end
771
+ end
659
772
  end
660
773
 
661
774
  # Display token usage for current iteration
@@ -669,53 +782,20 @@ module Clacky
669
782
  # Calculate token delta from previous iteration
670
783
  delta_tokens = total_tokens - @previous_total_tokens
671
784
  @previous_total_tokens = total_tokens # Update for next iteration
672
-
673
- # Build token summary string
674
- token_info = []
675
-
676
- # Delta tokens with color coding at the beginning
677
- require 'pastel'
678
- pastel = Pastel.new
679
-
680
- delta_str = "+#{delta_tokens}"
681
- colored_delta = if delta_tokens > 10000
682
- pastel.red.bold(delta_str) # Error level: red for > 10k
683
- elsif delta_tokens > 5000
684
- pastel.yellow.bold(delta_str) # Warn level: yellow for > 5k
685
- else
686
- pastel.green(delta_str) # Normal: green for <= 5k
687
- end
688
-
689
- token_info << colored_delta
690
-
691
- # Cache status indicator
692
- cache_used = cache_read > 0 || cache_write > 0
693
- if cache_used
694
- cache_indicator = "✓ Cached"
695
- token_info << pastel.cyan(cache_indicator)
696
- end
697
-
698
- # Input tokens (with cache breakdown if available)
699
- if cache_write > 0 || cache_read > 0
700
- input_detail = "#{prompt_tokens} (cache: #{cache_read} read, #{cache_write} write)"
701
- token_info << "Input: #{input_detail}"
702
- else
703
- token_info << "Input: #{prompt_tokens}"
704
- end
705
-
706
- # Output tokens
707
- token_info << "Output: #{completion_tokens}"
708
-
709
- # Total
710
- token_info << "Total: #{total_tokens}"
711
785
 
712
- # Cost for this iteration
713
- if cost
714
- token_info << "Cost: $#{cost.round(6)}"
715
- end
786
+ # Prepare data for UI to format and display
787
+ token_data = {
788
+ delta_tokens: delta_tokens,
789
+ prompt_tokens: prompt_tokens,
790
+ completion_tokens: completion_tokens,
791
+ total_tokens: total_tokens,
792
+ cache_write: cache_write,
793
+ cache_read: cache_read,
794
+ cost: cost
795
+ }
716
796
 
717
- # Display with color
718
- puts pastel.dim(" [Tokens] #{token_info.join(' | ')}")
797
+ # Let UI handle formatting and display
798
+ @ui&.show_token_usage(token_data)
719
799
  end
720
800
 
721
801
  def compress_messages_if_needed
@@ -729,57 +809,30 @@ module Clacky
729
809
  original_size = @messages.size
730
810
  target_size = @config.keep_recent_messages + 2
731
811
 
732
- # Show compression progress using ProgressIndicator
733
- progress = ProgressIndicator.new(
734
- verbose: @config.verbose,
735
- message: "🗜️ Compressing conversation history (#{original_size} → ~#{target_size} messages)"
736
- )
737
- progress.start
812
+ @ui&.show_info("Compressing history (#{original_size} -> ~#{target_size} messages)...")
738
813
 
739
- begin
740
- # Find the system message (should be first)
741
- system_msg = @messages.find { |m| m[:role] == "system" }
814
+ # Find the system message (should be first)
815
+ system_msg = @messages.find { |m| m[:role] == "system" }
742
816
 
743
- # Get the most recent N messages, ensuring tool_calls/tool results pairs are kept together
744
- recent_messages = get_recent_messages_with_tool_pairs(@messages, @config.keep_recent_messages)
817
+ # Get the most recent N messages, ensuring tool_calls/tool results pairs are kept together
818
+ recent_messages = get_recent_messages_with_tool_pairs(@messages, @config.keep_recent_messages)
745
819
 
746
- # Get messages to compress (everything except system and recent)
747
- messages_to_compress = @messages.reject { |m| m[:role] == "system" || recent_messages.include?(m) }
820
+ # Get messages to compress (everything except system and recent)
821
+ messages_to_compress = @messages.reject { |m| m[:role] == "system" || recent_messages.include?(m) }
748
822
 
749
- if messages_to_compress.empty?
750
- progress.finish
751
- return
752
- end
823
+ return if messages_to_compress.empty?
753
824
 
754
- # Create summary of compressed messages
755
- summary = summarize_messages(messages_to_compress)
825
+ # Create summary of compressed messages
826
+ summary = summarize_messages(messages_to_compress)
756
827
 
757
- # Rebuild messages array: [system, summary, recent_messages]
758
- # Preserve cache_control on system message if it exists
759
- rebuilt_messages = [system_msg, summary, *recent_messages].compact
828
+ # Rebuild messages array: [system, summary, recent_messages]
829
+ rebuilt_messages = [system_msg, summary, *recent_messages].compact
760
830
 
761
- # Re-apply cache control to system message if caching is enabled
762
- if @config.enable_prompt_caching && rebuilt_messages.first&.dig(:role) == "system"
763
- rebuilt_messages.first[:cache_control] = { type: "ephemeral" }
764
- end
831
+ @messages = rebuilt_messages
765
832
 
766
- @messages = rebuilt_messages
833
+ final_size = @messages.size
767
834
 
768
- final_size = @messages.size
769
-
770
- # Finish progress and show completion message
771
- progress.finish
772
- puts "✅ Compressed conversation history (#{original_size} → #{final_size} messages)"
773
-
774
- # Show detailed summary in verbose mode
775
- if @config.verbose
776
- puts "\n[COMPRESSION SUMMARY]"
777
- puts summary[:content]
778
- puts ""
779
- end
780
- ensure
781
- progress.finish
782
- end
835
+ @ui&.show_info("Compressed (#{original_size} -> #{final_size} messages)")
783
836
  end
784
837
 
785
838
  def get_recent_messages_with_tool_pairs(messages, count)
@@ -908,77 +961,51 @@ module Clacky
908
961
  }
909
962
  end
910
963
 
911
- def emit_event(type, data, &block)
912
- return unless block
913
-
914
- block.call({
915
- type: type,
916
- data: data,
917
- iteration: @iterations,
918
- cost: @total_cost
919
- })
920
- end
921
-
922
- def confirm_tool_use?(call, &block)
923
- emit_event(:tool_confirmation_required, call, &block)
924
-
964
+ def confirm_tool_use?(call)
925
965
  # Show preview first and check for errors
926
966
  preview_error = show_tool_preview(call)
927
967
 
928
- # If preview detected an error (e.g., edit with non-existent string),
929
- # auto-deny and provide detailed feedback
968
+ # If preview detected an error, auto-deny and provide feedback
930
969
  if preview_error && preview_error[:error]
931
- puts "\nTool call auto-denied due to preview error"
932
-
933
- # Build helpful feedback message
934
- feedback = case call[:name]
935
- when "edit"
936
- "The edit operation will fail because the old_string was not found in the file. " \
937
- "Please use file_reader to read '#{preview_error[:path]}' first, " \
938
- "find the correct string to replace, and try again with the exact string (including whitespace)."
939
- else
940
- "Tool preview error: #{preview_error[:error]}"
941
- end
942
-
970
+ @ui&.show_warning("Tool call auto-denied due to preview error")
971
+ feedback = build_preview_error_feedback(call[:name], preview_error)
943
972
  return { approved: false, feedback: feedback }
944
973
  end
945
974
 
946
- # Then show the confirmation prompt with better formatting
947
- prompt_text = format_tool_prompt(call)
948
- puts "\n❓ #{prompt_text}"
949
-
950
- # Use TTY::Prompt for better input handling
951
- tty_prompt = TTY::Prompt.new(interrupt: :exit)
952
-
953
- begin
954
- response = tty_prompt.ask(" (Enter/y to approve, n to deny, or provide feedback):", required: false) do |q|
955
- q.modify :strip
975
+ # Request confirmation via UI
976
+ if @ui
977
+ prompt_text = format_tool_prompt(call)
978
+ result = @ui.request_confirmation(prompt_text, default: true)
979
+
980
+ case result
981
+ when true
982
+ { approved: true, feedback: nil }
983
+ when false, nil
984
+ # User denied - add visual marker based on tool type
985
+ tool_name_capitalized = call[:name].capitalize
986
+ @ui&.show_info(" ↳ #{tool_name_capitalized} cancelled", prefix_newline: false)
987
+ { approved: false, feedback: nil }
988
+ else
989
+ # String feedback - also add visual marker
990
+ tool_name_capitalized = call[:name].capitalize
991
+ @ui&.show_info(" ↳ #{tool_name_capitalized} cancelled", prefix_newline: false)
992
+ { approved: false, feedback: result.to_s }
956
993
  end
957
- rescue TTY::Reader::InputInterrupt
958
- # Handle Ctrl+C
959
- puts
960
- return { approved: false, feedback: nil }
961
- end
962
-
963
- # Handle nil response (EOF/pipe input)
964
- if response.nil? || response.empty?
965
- return { approved: true, feedback: nil } # Empty means approved
966
- end
967
-
968
- response_lower = response.downcase
969
-
970
- # "y"/"yes" = approved
971
- if response_lower == "y" || response_lower == "yes"
972
- return { approved: true, feedback: nil }
994
+ else
995
+ # Fallback: auto-approve if no UI
996
+ { approved: true, feedback: nil }
973
997
  end
998
+ end
974
999
 
975
- # "n"/"no" = denied without feedback
976
- if response_lower == "n" || response_lower == "no"
977
- return { approved: false, feedback: nil }
1000
+ private def build_preview_error_feedback(tool_name, error_info)
1001
+ case tool_name
1002
+ when "edit"
1003
+ "The edit operation will fail because the old_string was not found in the file. " \
1004
+ "Please use file_reader to read '#{error_info[:path]}' first, " \
1005
+ "find the correct string to replace, and try again with the exact string (including whitespace)."
1006
+ else
1007
+ "Tool preview error: #{error_info[:error]}"
978
1008
  end
979
-
980
- # Any other input = denied with feedback
981
- { approved: false, feedback: response }
982
1009
  end
983
1010
 
984
1011
  def format_tool_prompt(call)
@@ -1018,6 +1045,8 @@ module Clacky
1018
1045
  end
1019
1046
 
1020
1047
  def show_tool_preview(call)
1048
+ return nil unless @ui
1049
+
1021
1050
  begin
1022
1051
  args = JSON.parse(call[:arguments], symbolize_names: true)
1023
1052
 
@@ -1028,22 +1057,22 @@ module Clacky
1028
1057
  when "edit"
1029
1058
  preview_error = show_edit_preview(args)
1030
1059
  when "shell", "safe_shell"
1031
- preview_error = show_shell_preview(args)
1060
+ show_shell_preview(args)
1032
1061
  else
1033
1062
  # For other tools, show formatted arguments
1034
1063
  tool = @tool_registry.get(call[:name]) rescue nil
1035
1064
  if tool
1036
1065
  formatted = tool.format_call(args) rescue "#{call[:name]}(...)"
1037
- puts "\nArgs: #{formatted}"
1066
+ @ui&.show_tool_args(formatted)
1038
1067
  else
1039
- puts "\nArgs: #{call[:arguments]}"
1068
+ @ui&.show_tool_args(call[:arguments])
1040
1069
  end
1041
1070
  end
1042
1071
 
1043
- return preview_error
1072
+ preview_error
1044
1073
  rescue JSON::ParserError
1045
- puts " Args: #{call[:arguments]}"
1046
- return nil
1074
+ @ui&.show_tool_args(call[:arguments])
1075
+ nil
1047
1076
  end
1048
1077
  end
1049
1078
 
@@ -1051,17 +1080,16 @@ module Clacky
1051
1080
  path = args[:path] || args['path']
1052
1081
  new_content = args[:content] || args['content'] || ""
1053
1082
 
1054
- puts "\n📝 File: #{path || '(unknown)'}"
1083
+ is_new_file = !(path && File.exist?(path))
1084
+ @ui&.show_file_write_preview(path, is_new_file: is_new_file)
1055
1085
 
1056
- if path && File.exist?(path)
1057
- old_content = File.read(path)
1058
- puts "Modifying existing file\n"
1059
- show_diff(old_content, new_content, max_lines: 50)
1086
+ if is_new_file
1087
+ @ui&.show_diff("", new_content, max_lines: 50)
1060
1088
  else
1061
- puts "Creating new file\n"
1062
- # Show diff from empty content to new content (all additions)
1063
- show_diff("", new_content, max_lines: 50)
1089
+ old_content = File.read(path)
1090
+ @ui&.show_diff(old_content, new_content, max_lines: 50)
1064
1091
  end
1092
+ nil
1065
1093
  end
1066
1094
 
1067
1095
  def show_edit_preview(args)
@@ -1069,20 +1097,20 @@ module Clacky
1069
1097
  old_string = args[:old_string] || args['old_string'] || ""
1070
1098
  new_string = args[:new_string] || args['new_string'] || ""
1071
1099
 
1072
- puts "\n📝 File: #{path || '(unknown)'}"
1100
+ @ui&.show_file_edit_preview(path)
1073
1101
 
1074
1102
  if !path || path.empty?
1075
- puts " ⚠️ No file path provided"
1103
+ @ui&.show_file_error("No file path provided")
1076
1104
  return { error: "No file path provided for edit operation" }
1077
1105
  end
1078
1106
 
1079
1107
  unless File.exist?(path)
1080
- puts " ⚠️ File not found: #{path}"
1081
- return { error: "File not found: #{path}" }
1108
+ @ui&.show_file_error("File not found: #{path}")
1109
+ return { error: "File not found: #{path}", path: path }
1082
1110
  end
1083
1111
 
1084
1112
  if old_string.empty?
1085
- puts " ⚠️ No old_string provided (nothing to replace)"
1113
+ @ui&.show_file_error("No old_string provided (nothing to replace)")
1086
1114
  return { error: "No old_string provided (nothing to replace)" }
1087
1115
  end
1088
1116
 
@@ -1090,9 +1118,19 @@ module Clacky
1090
1118
 
1091
1119
  # Check if old_string exists in file
1092
1120
  unless file_content.include?(old_string)
1093
- puts " ⚠️ String to replace not found in file"
1094
- puts " Looking for (first 100 chars):"
1095
- puts " #{old_string[0..100].inspect}"
1121
+ # Log debug info for troubleshooting
1122
+ @debug_logs << {
1123
+ timestamp: Time.now.iso8601,
1124
+ event: "edit_preview_failed",
1125
+ path: path,
1126
+ looking_for: old_string[0..500],
1127
+ file_content_preview: file_content[0..1000],
1128
+ file_size: file_content.length
1129
+ }
1130
+
1131
+ @ui&.show_file_error("String to replace not found in file")
1132
+ @ui&.show_file_error("Looking for (first 100 chars):")
1133
+ @ui&.show_file_error(old_string[0..100].inspect)
1096
1134
  return {
1097
1135
  error: "String to replace not found in file",
1098
1136
  path: path,
@@ -1101,34 +1139,20 @@ module Clacky
1101
1139
  end
1102
1140
 
1103
1141
  new_content = file_content.sub(old_string, new_string)
1104
- show_diff(file_content, new_content, max_lines: 50)
1142
+ @ui&.show_diff(file_content, new_content, max_lines: 50)
1105
1143
  nil # No error
1106
1144
  end
1107
1145
 
1108
1146
  def show_shell_preview(args)
1109
1147
  command = args[:command] || ""
1110
- puts "\n💻 Command: #{command}"
1111
- end
1112
-
1113
- def show_diff(old_content, new_content, max_lines: 50)
1114
- require 'diffy'
1115
-
1116
- diff = Diffy::Diff.new(old_content, new_content, context: 3)
1117
- all_lines = diff.to_s(:color).lines
1118
- display_lines = all_lines.first(max_lines)
1119
-
1120
- display_lines.each { |line| puts line.chomp }
1121
- puts "\n... (#{all_lines.size - max_lines} more lines, diff truncated)" if all_lines.size > max_lines
1122
- rescue LoadError
1123
- # Fallback if diffy is not available
1124
- puts " Old size: #{old_content.bytesize} bytes"
1125
- puts " New size: #{new_content.bytesize} bytes"
1148
+ @ui&.show_shell_preview(command)
1149
+ nil
1126
1150
  end
1127
1151
 
1128
1152
  def build_success_result(call, result)
1129
1153
  # Try to get tool instance to use its format_result_for_llm method
1130
1154
  tool = @tool_registry.get(call[:name]) rescue nil
1131
-
1155
+
1132
1156
  formatted_result = if tool && tool.respond_to?(:format_result_for_llm)
1133
1157
  # Tool provides a custom LLM-friendly format
1134
1158
  tool.format_result_for_llm(result)
@@ -1136,7 +1160,7 @@ module Clacky
1136
1160
  # Fallback: use the original result
1137
1161
  result
1138
1162
  end
1139
-
1163
+
1140
1164
  {
1141
1165
  id: call[:id],
1142
1166
  content: JSON.generate(formatted_result)
@@ -1174,14 +1198,20 @@ module Clacky
1174
1198
  end
1175
1199
 
1176
1200
  def build_result(status, error: nil)
1201
+ # Calculate iterations for current task only
1202
+ task_iterations = @iterations - (@task_start_iterations || 0)
1203
+
1204
+ # Calculate cost for current task only
1205
+ task_cost = @total_cost - (@task_start_cost || 0)
1206
+
1177
1207
  {
1178
1208
  status: status,
1179
1209
  session_id: @session_id,
1180
- iterations: @iterations,
1210
+ iterations: task_iterations, # Show only current task iterations
1181
1211
  duration_seconds: Time.now - @start_time,
1182
- total_cost_usd: @total_cost.round(4),
1212
+ total_cost_usd: task_cost.round(4), # Show only current task cost
1183
1213
  cost_source: @task_cost_source, # Add cost source for this task
1184
- cache_stats: @cache_stats,
1214
+ cache_stats: @task_cache_stats || @cache_stats, # Use task cache stats if available
1185
1215
  messages: @messages,
1186
1216
  error: error
1187
1217
  }